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, 'ZFS pool') !== false) { $issueCategory = 'zfs'; // Extract pool name so each pool gets its own ticket if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) { $poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1])); if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) { $issueSubtype = 'pool_state_' . $poolName; } elseif (stripos($title, 'usage') !== false) { $issueSubtype = 'pool_usage_' . $poolName; } elseif (stripos($title, 'errors') !== false) { $issueSubtype = 'pool_errors_' . $poolName; } else { $issueSubtype = 'pool_' . $poolName; } } } 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 || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) { // Include the specific OSD ID so each individual OSD gets its own ticket if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) { $issueSubtype = 'osd_down_' . $osdMatch[1]; } else { $issueSubtype = 'osd_down'; } } elseif (stripos($title, 'HEALTH_ERR') !== false) { $issueSubtype = 'health_err'; } } // Include source type so automated tickets never collide with manual ones $sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual'; // Build stable components $stableComponents = [ 'source_type' => $sourceType, '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, escalate priority, and refresh // description with latest sensor data. $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]; } // Always refresh the description so the ticket body shows current sensor data if (!empty($description)) { $updateSql .= ", description = ?"; $bindTypes .= "s"; $bindVals[] = $description; $changes['description_refreshed'] = true; } if (!empty($changes)) { $updateSql .= " WHERE ticket_id = ?"; $bindTypes .= "s"; $bindVals[] = $existingId; $updStmt = $conn->prepare($updateSql); $updStmt->bind_param($bindTypes, ...$bindVals); $updStmt->execute(); $updStmt->close(); // Only post a comment on priority escalation — title and description updates // are silent (title changes like rising counters would spam a comment every run) if (isset($changes['priority'])) { $commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```"; $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( array_diff_key($changes, ['description_refreshed' => true]), ['reason' => 'auto-updated by hwmonDaemon (condition worsened)'] )); // Only notify on priority escalation — title-only updates (e.g. rising // Power_On_Hours counter) should not generate a Matrix ping every hour. if (isset($changes['priority'])) { require_once __DIR__ . '/helpers/NotificationHelper.php'; NotificationHelper::sendTicketNotification($existingId, [ 'title' => $title, 'priority' => $changes['priority']['to'], '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```\n" . $description . "\n```"; $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 // Use random_int range 100000000-999999999 to avoid leading-zero IDs try { $ticket_id = (string)random_int(100000000, 999999999); } catch (Exception $e) { $ticket_id = (string)mt_rand(100000000, 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]); }