6b89a14a47
Use random_int(100000000-999999999) so IDs are always 9 digits without
a leading zero, matching the behaviour of TicketModel::createTicket().
The old sprintf('%09d', mt_rand(1, ...)) could produce IDs like
000123456 which broke PHP array key lookups elsewhere.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
391 lines
14 KiB
PHP
391 lines
14 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)']
|
|
));
|
|
|
|
// 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" . $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
|
|
// 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]);
|
|
}
|