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:
+136
-80
@@ -102,143 +102,190 @@ if (!is_array($data) || empty($data['title'])) {
|
||||
|
||||
// Generate hash from stable components
|
||||
function generateTicketHash($data) {
|
||||
$title = (string)($data['title'] ?? '');
|
||||
|
||||
// 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);
|
||||
|
||||
// Extract hostname from title [hostname][tags]...
|
||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
||||
// Extract first bracketed tag as hostname/source
|
||||
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
||||
$hostname = $hostMatches[1] ?? '';
|
||||
|
||||
// Detect issue category (not specific attribute values)
|
||||
// Detect issue category and optional sub-type
|
||||
$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';
|
||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
||||
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||
$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';
|
||||
} elseif (stripos($data['title'], 'cpu') !== false) {
|
||||
} elseif (stripos($title, 'cpu') !== false) {
|
||||
$issueCategory = 'cpu';
|
||||
} elseif (stripos($data['title'], 'network') !== false) {
|
||||
} elseif (stripos($title, 'network') !== false) {
|
||||
$issueCategory = 'network';
|
||||
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
|
||||
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
||||
$issueCategory = 'ceph';
|
||||
// Ceph cluster-wide issues should deduplicate across all nodes
|
||||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
||||
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
||||
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
||||
stripos($data['title'], 'HEALTH_WARN') !== false ||
|
||||
stripos($data['title'], 'cluster usage') !== false) {
|
||||
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 with only static data
|
||||
// Build stable components
|
||||
$stableComponents = [
|
||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
||||
'environment_tags' => array_filter(
|
||||
explode('][', $data['title']),
|
||||
'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'])
|
||||
)
|
||||
)),
|
||||
];
|
||||
|
||||
// Only include hostname for non-cluster-wide issues
|
||||
// This allows cluster-wide issues to deduplicate across all nodes
|
||||
// Include hostname for node-specific issues
|
||||
if (!$isClusterWide) {
|
||||
$stableComponents['hostname'] = $hostname;
|
||||
}
|
||||
|
||||
// Only include device info for drive-specific tickets
|
||||
// Include device path for drive-specific tickets
|
||||
if ($isDriveTicket) {
|
||||
$stableComponents['device'] = $deviceMatches[0];
|
||||
}
|
||||
|
||||
// Sort arrays for consistent hashing
|
||||
sort($stableComponents['environment_tags']);
|
||||
|
||||
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);
|
||||
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
||||
$checkStmt = $conn->prepare($checkDuplicateSQL);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// 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->execute();
|
||||
$result = $checkStmt->get_result();
|
||||
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||
$checkStmt->close();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$existingTicket = $result->fetch_assoc();
|
||||
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' => $existingTicket['ticket_id']
|
||||
'existing_ticket_id' => $existingId,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Force JSON content type for all incoming requests
|
||||
header('Content-Type: application/json');
|
||||
// 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();
|
||||
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$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));
|
||||
|
||||
// Prepare insert query with created_by field
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
// 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
|
||||
$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
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
// Log ticket creation to audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
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
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
'message' => 'Ticket created successfully'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $conn->error
|
||||
]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
// Matrix webhook notification
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'title' => $title,
|
||||
@@ -247,3 +294,12 @@ NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user