499060795e
Two changes to the external ticket API: 1. Serial-based dedup: generateTicketHash() now uses the `serial` field from the hwmonDaemon payload as the stable drive identifier instead of extracting /dev/sdX from the title. Device path is kept as a fallback for payloads without a serial field (backwards compatible). Hash key renamed from `device` to `drive` to reflect this. 2. Active-ticket updates: when a duplicate is detected and the ticket is still open, the API now compares the incoming title and priority against the existing ticket. If the title changed or priority escalated (lower number), the ticket is updated and a comment is added explaining what changed. Previously the API silently returned "Duplicate ticket" with no update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
382 lines
13 KiB
PHP
382 lines
13 KiB
PHP
<?php
|
|
header('Content-Type: application/json');
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0);
|
|
|
|
// Load environment variables with error check
|
|
$envFile = __DIR__ . '/.env';
|
|
if (!file_exists($envFile)) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Configuration file not found'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|
if (!$envVars) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Invalid configuration file'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Strip quotes from values if present (parse_ini_file may include them)
|
|
foreach ($envVars as $key => $value) {
|
|
if (is_string($value)) {
|
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
|
$envVars[$key] = substr($value, 1, -1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Database connection with detailed error handling
|
|
$conn = new mysqli(
|
|
$envVars['DB_HOST'],
|
|
$envVars['DB_USER'],
|
|
$envVars['DB_PASS'],
|
|
$envVars['DB_NAME']
|
|
);
|
|
|
|
if ($conn->connect_error) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Database connection failed: ' . $conn->connect_error
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Load application config so UrlHelper can resolve APP_DOMAIN
|
|
require_once __DIR__ . '/config/config.php';
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
|
|
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
|
|
|
try {
|
|
$systemUser = $apiKeyAuth->authenticate();
|
|
} catch (Exception $e) {
|
|
// Authentication failed - ApiKeyAuth already sent the response
|
|
exit;
|
|
}
|
|
|
|
$userId = $systemUser['user_id'];
|
|
|
|
// Create tickets table with hash column if not exists
|
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
ticket_id VARCHAR(9) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
hash VARCHAR(64) NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY unique_hash (hash)
|
|
)";
|
|
|
|
$conn->query($createTableSQL);
|
|
|
|
// Parse input regardless of content-type header
|
|
$rawInput = file_get_contents('php://input');
|
|
$data = json_decode($rawInput, true);
|
|
|
|
// Validate required fields before any processing
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
// Try URL-encoded fallback
|
|
if (empty($data['title'])) {
|
|
parse_str($rawInput, $urlData);
|
|
if (!empty($urlData['title'])) {
|
|
$data = $urlData;
|
|
}
|
|
}
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Generate hash from stable components
|
|
function generateTicketHash($data) {
|
|
$title = (string)($data['title'] ?? '');
|
|
|
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
|
// for backwards compatibility with older hwmonDaemon versions.
|
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
|
? (string)$data['serial']
|
|
: null;
|
|
|
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
|
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
|
|
|
|
// Extract first bracketed tag as hostname/source
|
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
|
$hostname = $hostMatches[1] ?? '';
|
|
|
|
// Detect issue category and optional sub-type
|
|
$issueCategory = '';
|
|
$issueSubtype = '';
|
|
$isClusterWide = false;
|
|
|
|
if (stripos($title, 'SMART issues') !== false) {
|
|
$issueCategory = 'smart';
|
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
|
$issueCategory = 'storage';
|
|
// Include the LXC container ID so each container gets its own ticket
|
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
|
}
|
|
} elseif (stripos($title, 'memory') !== false) {
|
|
$issueCategory = 'memory';
|
|
} elseif (stripos($title, 'cpu') !== false) {
|
|
$issueCategory = 'cpu';
|
|
} elseif (stripos($title, 'network') !== false) {
|
|
$issueCategory = 'network';
|
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
|
$issueCategory = 'ceph';
|
|
if (stripos($title, '[cluster-wide]') !== false ||
|
|
stripos($title, 'HEALTH_ERR') !== false ||
|
|
stripos($title, 'HEALTH_WARN') !== false ||
|
|
stripos($title, 'cluster usage') !== false) {
|
|
$isClusterWide = true;
|
|
}
|
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
|
$issueSubtype = 'bluestore_slow';
|
|
} elseif (stripos($title, 'clock skew') !== false) {
|
|
$issueSubtype = 'clock_skew';
|
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
|
$issueSubtype = 'usage';
|
|
} elseif (stripos($title, 'OSD down') !== false) {
|
|
$issueSubtype = 'osd_down';
|
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
|
$issueSubtype = 'health_err';
|
|
}
|
|
}
|
|
|
|
// Build stable components
|
|
$stableComponents = [
|
|
'issue_category' => $issueCategory,
|
|
'issue_subtype' => $issueSubtype,
|
|
'environment_tags' => array_values(array_filter(
|
|
explode('][', $title),
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
|
)),
|
|
];
|
|
|
|
// Include hostname for node-specific issues
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Include drive identifier for drive-specific tickets.
|
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
|
// device path for tickets created before serial was added to the payload.
|
|
if ($isDriveTicket) {
|
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
|
}
|
|
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Shared ticket data
|
|
$title = (string)($data['title'] ?? '');
|
|
$description = (string)($data['description'] ?? '');
|
|
$status = (string)($data['status'] ?? 'Open');
|
|
$priority = $data['priority'] ?? '4';
|
|
$category = (string)($data['category'] ?? 'General');
|
|
$type = (string)($data['type'] ?? 'Issue');
|
|
|
|
$ticketHash = generateTicketHash($data);
|
|
$auditLog = new AuditLogModel($conn);
|
|
|
|
// Look up any existing ticket with this hash (open OR closed)
|
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
|
$checkStmt->bind_param("s", $ticketHash);
|
|
$checkStmt->execute();
|
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
|
$checkStmt->close();
|
|
|
|
if ($existing) {
|
|
$existingId = $existing['ticket_id'];
|
|
$existingStatus = $existing['status'];
|
|
$existingTitle = $existing['title'];
|
|
$existingPriority = (int)$existing['priority'];
|
|
$newPriority = (int)$priority;
|
|
|
|
if ($existingStatus !== 'Closed') {
|
|
// Ticket is still active — update title and escalate priority if the new
|
|
// report is more severe (lower number = higher severity).
|
|
$changes = [];
|
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
|
$bindTypes = "i";
|
|
$bindVals = [$userId];
|
|
|
|
if ($title !== $existingTitle) {
|
|
$updateSql .= ", title = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $title;
|
|
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
|
}
|
|
|
|
if ($newPriority < $existingPriority) {
|
|
$updateSql .= ", priority = ?";
|
|
$bindTypes .= "i";
|
|
$bindVals[] = $newPriority;
|
|
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
|
}
|
|
|
|
if (!empty($changes)) {
|
|
$updateSql .= " WHERE ticket_id = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $existingId;
|
|
|
|
$updStmt = $conn->prepare($updateSql);
|
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
|
$updStmt->execute();
|
|
$updStmt->close();
|
|
|
|
// Comment summarising what changed
|
|
$changeLines = [];
|
|
if (isset($changes['title'])) {
|
|
$changeLines[] = "- **Title updated** to reflect current issue";
|
|
}
|
|
if (isset($changes['priority'])) {
|
|
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
|
|
}
|
|
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
|
|
implode("\n", $changeLines) . "\n\nLatest report:\n\n" . $description;
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
|
$changes,
|
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
|
));
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $newPriority < $existingPriority ? $newPriority : $existingPriority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $existingStatus,
|
|
], 'automated');
|
|
}
|
|
|
|
$conn->close();
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
|
'changes' => $changes,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Ticket was closed — reopen it and add a recurrence comment
|
|
$reopenStmt = $conn->prepare(
|
|
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
|
);
|
|
$reopenStmt->bind_param("is", $userId, $existingId);
|
|
$reopenStmt->execute();
|
|
$reopenStmt->close();
|
|
|
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
|
"New report received from hwmonDaemon:\n\n" . $description;
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
|
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => 'Open',
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => 'Existing closed ticket reopened',
|
|
'action' => 'reopened',
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// No existing ticket — create a new one
|
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
|
$insertStmt = $conn->prepare(
|
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
$insertStmt->bind_param("ssssssssi",
|
|
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
|
|
);
|
|
|
|
try {
|
|
$inserted = $insertStmt->execute();
|
|
} catch (mysqli_sql_exception $e) {
|
|
$insertStmt->close();
|
|
if ($e->getCode() === 1062) {
|
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
|
} else {
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
$insertStmt->close();
|
|
|
|
if ($inserted) {
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $status,
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $ticket_id,
|
|
'message' => 'Ticket created successfully',
|
|
]);
|
|
} else {
|
|
echo json_encode(['success' => false, 'error' => $conn->error]);
|
|
}
|