README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.
Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.
Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.
For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.
Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
486 lines
19 KiB
PHP
486 lines
19 KiB
PHP
<?php
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
require_once __DIR__ . '/helpers/ErrorHandler.php';
|
|
ErrorHandler::init();
|
|
|
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Early friendly JSON error if .env is missing, before config.php's own
|
|
// (plain-text die()) handling would otherwise run — this is a JSON API
|
|
// endpoint and must always respond with a JSON body.
|
|
if (!file_exists(__DIR__ . '/.env')) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Configuration file not found'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
|
|
// DB connection below (via Database::getConnection()) gets the same
|
|
// charset/timezone sync as every other endpoint instead of a hand-rolled
|
|
// second connection.
|
|
require_once __DIR__ . '/config/config.php';
|
|
require_once __DIR__ . '/helpers/Database.php';
|
|
|
|
try {
|
|
$conn = Database::getConnection();
|
|
} catch (\Throwable $e) {
|
|
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Internal server error'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
require_once __DIR__ . '/models/StatsModel.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;
|
|
}
|
|
|
|
// Ticket creation is a write — a read-only key must be rejected with 403.
|
|
$apiKeyAuth->requireScope('read_write');
|
|
|
|
$userId = $systemUser['user_id'];
|
|
|
|
// 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' => (function () use ($title) {
|
|
// Extract each [bracketed] tag, then keep the known environment ones.
|
|
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
|
|
// "[production] ..." never matched and the env tag was dropped from the
|
|
// dedup hash — letting prod and staging issues collide onto one ticket.)
|
|
preg_match_all('/\[([^\]]+)\]/', $title, $m);
|
|
return array_values(array_filter(
|
|
$m[1],
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
|
|
));
|
|
})(),
|
|
];
|
|
|
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
|
if ($sourceType === 'manual') {
|
|
$stableComponents['title'] = $title;
|
|
}
|
|
|
|
// 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');
|
|
|
|
// Validate externally-supplied status and priority. (category/type are free-form
|
|
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
|
|
// the ticket below P1 on the dedup/update path.
|
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
|
if (!in_array($status, $validStatuses, true)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid status']);
|
|
exit;
|
|
}
|
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
|
|
exit;
|
|
}
|
|
$priority = (int)$priority;
|
|
|
|
$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).
|
|
// Keep it short: the full sensor data is refreshed in the ticket description,
|
|
// so the comment just records the bump + a brief reason (no ASCII dump).
|
|
if (isset($changes['priority'])) {
|
|
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
|
$fromP = (int)$changes['priority']['from'];
|
|
$toP = (int)$changes['priority']['to'];
|
|
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
|
$toL = $pLabels[$toP] ?? "P{$toP}";
|
|
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
|
. "The latest monitoring scan reported a more severe condition for this issue, "
|
|
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
|
$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');
|
|
}
|
|
|
|
// Ticket state (priority/title/description) changed — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
}
|
|
|
|
Database::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" .
|
|
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
|
. "original report; see this comment's timestamp for when the issue recurred.";
|
|
$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)',
|
|
]);
|
|
|
|
// Ticket reopened (Closed → Open) — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
|
|
Database::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.
|
|
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
|
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
|
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
|
|
// is correctly reported as a duplicate rather than a dropped hardware alert.
|
|
$ticket_id = null;
|
|
$maxAttempts = 50;
|
|
$attempts = 0;
|
|
do {
|
|
try {
|
|
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
|
} catch (Exception $e) {
|
|
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
|
}
|
|
|
|
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
|
$idCheckStmt->bind_param("s", $candidateId);
|
|
$idCheckStmt->execute();
|
|
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
|
$idCheckStmt->close();
|
|
|
|
if (!$idExists) {
|
|
$ticket_id = $candidateId;
|
|
}
|
|
$attempts++;
|
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
|
|
|
if ($ticket_id === null) {
|
|
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
exit;
|
|
}
|
|
|
|
$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 {
|
|
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|
|
exit;
|
|
}
|
|
$insertStmt->close();
|
|
|
|
if ($inserted) {
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
]);
|
|
|
|
// New ticket created — refresh dashboard stats.
|
|
(new StatsModel($conn))->invalidateCache();
|
|
|
|
Database::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 {
|
|
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|