Rewrite create_ticket_api.php dedup logic: reopen closed tickets on recurrence

Instead of crashing (PHP 8.2 TypeError) or silently failing on duplicate hash,
the API now:
- Checks for any existing ticket with the same hash (no 24h limit)
- If open/pending/in-progress: returns Duplicate ticket with existing ID
- If closed: reopens the ticket, posts a recurrence comment, returns action=reopened
- If new: creates the ticket as before
- Wraps INSERT in try/catch for mysqli_sql_exception to handle race conditions
  gracefully when multiple nodes POST simultaneously

Also improves the hash function:
- Ceph issues now include a subtype (bluestore_slow, clock_skew, osd_down, etc.)
  so different Ceph warnings get distinct tickets instead of colliding
- LXC storage issues include the container ID so each container gets its own ticket
- Fixed potential null-subject issue in preg_match for missing titles
- Added early input validation (400 + JSON error) before any processing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 17:40:36 -04:00
parent 570b1749da
commit fe9c6b3ee0
+146 -90
View File
@@ -102,148 +102,204 @@ if (!is_array($data) || empty($data['title'])) {
// Generate hash from stable components // Generate hash from stable components
function generateTicketHash($data) { function generateTicketHash($data) {
$title = (string)($data['title'] ?? '');
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns) // Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches); preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
$isDriveTicket = !empty($deviceMatches); $isDriveTicket = !empty($deviceMatches);
// Extract hostname from title [hostname][tags]... // Extract first bracketed tag as hostname/source
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches); preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
$hostname = $hostMatches[1] ?? ''; $hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values) // Detect issue category and optional sub-type
$issueCategory = ''; $issueCategory = '';
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash) $issueSubtype = '';
$isClusterWide = false;
if (stripos($data['title'], 'SMART issues') !== false) { if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart'; $issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) { } elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage'; $issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) { // 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'; $issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) { } elseif (stripos($title, 'cpu') !== false) {
$issueCategory = 'cpu'; $issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) { } elseif (stripos($title, 'network') !== false) {
$issueCategory = 'network'; $issueCategory = 'network';
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) { } elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
$issueCategory = 'ceph'; $issueCategory = 'ceph';
// Ceph cluster-wide issues should deduplicate across all nodes if (stripos($title, '[cluster-wide]') !== false ||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node) stripos($title, 'HEALTH_ERR') !== false ||
if (stripos($data['title'], '[cluster-wide]') !== false || stripos($title, 'HEALTH_WARN') !== false ||
stripos($data['title'], 'HEALTH_ERR') !== false || stripos($title, 'cluster usage') !== false) {
stripos($data['title'], 'HEALTH_WARN') !== false ||
stripos($data['title'], 'cluster usage') !== false) {
$isClusterWide = true; $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 with only static data // Build stable components
$stableComponents = [ $stableComponents = [
'issue_category' => $issueCategory, // Generic category, not specific errors 'issue_category' => $issueCategory,
'environment_tags' => array_filter( 'issue_subtype' => $issueSubtype,
explode('][', $data['title']), 'environment_tags' => array_values(array_filter(
explode('][', $title),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide']) fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
) )),
]; ];
// Only include hostname for non-cluster-wide issues // Include hostname for node-specific issues
// This allows cluster-wide issues to deduplicate across all nodes
if (!$isClusterWide) { if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname; $stableComponents['hostname'] = $hostname;
} }
// Only include device info for drive-specific tickets // Include device path for drive-specific tickets
if ($isDriveTicket) { if ($isDriveTicket) {
$stableComponents['device'] = $deviceMatches[0]; $stableComponents['device'] = $deviceMatches[0];
} }
// Sort arrays for consistent hashing
sort($stableComponents['environment_tags']); sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES)); return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
} }
// Check for duplicate tickets // 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); $ticketHash = generateTicketHash($data);
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"; $auditLog = new AuditLogModel($conn);
$checkStmt = $conn->prepare($checkDuplicateSQL);
// Look up any existing ticket with this hash (open OR closed)
$checkStmt = $conn->prepare("SELECT ticket_id, status FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
$checkStmt->bind_param("s", $ticketHash); $checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute(); $checkStmt->execute();
$result = $checkStmt->get_result(); $existing = $checkStmt->get_result()->fetch_assoc();
$checkStmt->close();
if ($existing) {
$existingId = $existing['ticket_id'];
$existingStatus = $existing['status'];
if ($existingStatus !== 'Closed') {
// Ticket is still active — do not create a duplicate
echo json_encode([
'success' => false,
'error' => 'Duplicate ticket',
'existing_ticket_id' => $existingId,
]);
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');
if ($result->num_rows > 0) {
$existingTicket = $result->fetch_assoc();
echo json_encode([ echo json_encode([
'success' => false, 'success' => true,
'error' => 'Duplicate ticket', 'ticket_id' => $existingId,
'existing_ticket_id' => $existingTicket['ticket_id'] 'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
]); ]);
exit; exit;
} }
// Force JSON content type for all incoming requests // No existing ticket — create a new one
header('Content-Type: application/json');
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999)); $ticket_id = sprintf('%09d', mt_rand(1, 999999999));
$insertStmt = $conn->prepare(
// Prepare insert query with created_by field "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; );
$insertStmt->bind_param("ssssssssi",
$stmt = $conn->prepare($sql); $ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
// First, store all values in variables
$title = $data['title'];
$description = $data['description'];
$status = $data['status'] ?? 'Open';
$priority = $data['priority'] ?? '4';
$category = $data['category'] ?? 'General';
$type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
"ssssssssi",
$ticket_id,
$title,
$description,
$status,
$priority,
$category,
$type,
$ticketHash,
$userId
); );
if ($stmt->execute()) { try {
// Log ticket creation to audit log $inserted = $insertStmt->execute();
$auditLog = new AuditLogModel($conn); } 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, [ $auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title, 'title' => $title,
'priority' => $priority, 'priority' => $priority,
'category' => $category, 'category' => $category,
'type' => $type '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([ echo json_encode([
'success' => true, 'success' => true,
'ticket_id' => $ticket_id, 'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully' 'message' => 'Ticket created successfully',
]); ]);
} else { } else {
echo json_encode([ echo json_encode(['success' => false, 'error' => $conn->error]);
'success' => false,
'error' => $conn->error
]);
} }
$stmt->close();
$conn->close();
// Matrix webhook notification
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => $status,
], 'automated');