diff --git a/create_ticket_api.php b/create_ticket_api.php index f08b56d..c15ab4a 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -102,148 +102,204 @@ 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 ($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([ - 'success' => false, - 'error' => 'Duplicate ticket', - 'existing_ticket_id' => $existingTicket['ticket_id'] + 'success' => true, + 'ticket_id' => $existingId, + 'message' => 'Existing closed ticket reopened', + 'action' => 'reopened', ]); exit; } -// Force JSON content type for all incoming requests -header('Content-Type: application/json'); - -// Generate ticket ID (9-digit format with leading zeros) +// 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, + 'title' => $title, 'priority' => $priority, '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([ - 'success' => true, - 'ticket_id' => $ticket_id, - 'message' => 'Ticket created successfully' + 'success' => true, + 'ticket_id' => $ticket_id, + 'message' => 'Ticket created successfully', ]); } else { - echo json_encode([ - 'success' => false, - 'error' => $conn->error - ]); + 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, - 'priority' => $priority, - 'category' => $category, - 'type' => $type, - 'status' => $status, -], 'automated');