2024-11-30 19:48:01 -05:00
< ? php
2026-04-13 20:56:10 -04:00
2024-11-30 19:48:01 -05:00
header ( 'Content-Type: application/json' );
2024-12-02 21:21:10 -05:00
error_reporting ( E_ALL );
2026-01-20 09:55:01 -05:00
ini_set ( 'display_errors' , 0 );
2024-12-02 21:21:10 -05:00
2024-11-30 19:48:01 -05:00
// Load environment variables with error check
$envFile = __DIR__ . '/.env' ;
if ( ! file_exists ( $envFile )) {
echo json_encode ([
'success' => false ,
'error' => 'Configuration file not found'
]);
exit ;
}
2026-01-01 16:52:35 -05:00
$envVars = parse_ini_file ( $envFile , false , INI_SCANNER_TYPED );
2024-11-30 19:48:01 -05:00
if ( ! $envVars ) {
echo json_encode ([
'success' => false ,
'error' => 'Invalid configuration file'
]);
exit ;
}
2026-01-01 16:52:35 -05:00
// Strip quotes from values if present (parse_ini_file may include them)
foreach ( $envVars as $key => $value ) {
if ( is_string ( $value )) {
2026-04-13 20:56:10 -04:00
if (
( substr ( $value , 0 , 1 ) === '"' && substr ( $value , - 1 ) === '"' ) ||
( substr ( $value , 0 , 1 ) === " ' " && substr ( $value , - 1 ) === " ' " )
) {
2026-01-01 16:52:35 -05:00
$envVars [ $key ] = substr ( $value , 1 , - 1 );
}
}
}
2024-11-30 19:48:01 -05:00
// Database connection with detailed error handling
$conn = new mysqli (
2024-12-02 21:21:10 -05:00
$envVars [ 'DB_HOST' ],
$envVars [ 'DB_USER' ],
$envVars [ 'DB_PASS' ],
$envVars [ 'DB_NAME' ]
2024-11-30 19:48:01 -05:00
);
if ( $conn -> connect_error ) {
echo json_encode ([
'success' => false ,
'error' => 'Database connection failed: ' . $conn -> connect_error
]);
exit ;
}
2026-02-10 12:31:49 -05:00
// Load application config so UrlHelper can resolve APP_DOMAIN
require_once __DIR__ . '/config/config.php' ;
2024-11-30 19:48:01 -05:00
2026-01-01 15:40:32 -05:00
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php' ;
require_once __DIR__ . '/models/AuditLogModel.php' ;
2026-01-30 18:51:16 -05:00
require_once __DIR__ . '/helpers/UrlHelper.php' ;
2026-01-01 15:40:32 -05:00
$apiKeyAuth = new ApiKeyAuth ( $conn );
try {
$systemUser = $apiKeyAuth -> authenticate ();
} catch ( Exception $e ) {
// Authentication failed - ApiKeyAuth already sent the response
exit ;
}
$userId = $systemUser [ 'user_id' ];
2025-02-27 21:37:38 -05:00
// 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 );
2025-02-27 22:12:28 -05:00
// Parse input regardless of content-type header
$rawInput = file_get_contents ( 'php://input' );
$data = json_decode ( $rawInput , true );
2026-04-06 17:07:11 -04:00
// 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 ;
}
}
2025-02-27 21:37:38 -05:00
// Generate hash from stable components
2026-04-13 20:56:10 -04:00
function generateTicketHash ( $data )
{
2026-04-06 17:40:36 -04:00
$title = ( string )( $data [ 'title' ] ? ? '' );
2026-04-06 18:55:15 -04:00
// 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 ;
2026-01-07 19:27:13 -05:00
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
2026-04-06 17:40:36 -04:00
preg_match ( '/\/dev\/(sd[a-z]+|nvme\d+n\d+)/' , $title , $deviceMatches );
2026-04-06 18:55:15 -04:00
$isDriveTicket = ! empty ( $deviceMatches ) || $serial !== null ;
2026-01-07 19:27:13 -05:00
2026-04-06 17:40:36 -04:00
// Extract first bracketed tag as hostname/source
preg_match ( '/^\[([^\]]+)\]/' , $title , $hostMatches );
2025-03-03 18:23:44 -05:00
$hostname = $hostMatches [ 1 ] ? ? '' ;
2026-04-06 17:40:36 -04:00
// Detect issue category and optional sub-type
2026-01-07 19:27:13 -05:00
$issueCategory = '' ;
2026-04-06 17:40:36 -04:00
$issueSubtype = '' ;
$isClusterWide = false ;
2026-01-17 15:53:45 -05:00
2026-04-06 17:40:36 -04:00
if ( stripos ( $title , 'SMART issues' ) !== false ) {
2026-01-07 19:27:13 -05:00
$issueCategory = 'smart' ;
2026-04-06 17:40:36 -04:00
} elseif ( stripos ( $title , 'LXC' ) !== false || stripos ( $title , 'storage usage' ) !== false ) {
2026-01-07 19:27:13 -05:00
$issueCategory = 'storage' ;
2026-04-06 17:40:36 -04:00
// 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 ) {
2026-01-07 19:27:13 -05:00
$issueCategory = 'memory' ;
2026-04-06 17:40:36 -04:00
} elseif ( stripos ( $title , 'cpu' ) !== false ) {
2026-01-07 19:27:13 -05:00
$issueCategory = 'cpu' ;
2026-04-06 17:40:36 -04:00
} elseif ( stripos ( $title , 'network' ) !== false ) {
2026-01-07 19:27:13 -05:00
$issueCategory = 'network' ;
2026-04-06 17:40:36 -04:00
} elseif ( stripos ( $title , 'Ceph' ) !== false || stripos ( $title , '[ceph]' ) !== false ) {
2026-01-17 15:53:45 -05:00
$issueCategory = 'ceph' ;
2026-04-13 20:56:10 -04:00
if (
stripos ( $title , '[cluster-wide]' ) !== false ||
2026-04-06 17:40:36 -04:00
stripos ( $title , 'HEALTH_ERR' ) !== false ||
stripos ( $title , 'HEALTH_WARN' ) !== false ||
2026-04-13 20:56:10 -04:00
stripos ( $title , 'cluster usage' ) !== false
) {
2026-01-17 15:53:45 -05:00
$isClusterWide = true ;
}
2026-04-06 17:40:36 -04:00
// 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' ;
2026-04-16 08:09:12 -04:00
} elseif ( stripos ( $title , 'OSD down' ) !== false || preg_match ( '/osd\.\d+\s+is\s+DOWN/i' , $title )) {
2026-04-16 08:06:13 -04:00
// 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' ;
}
2026-04-06 17:40:36 -04:00
} elseif ( stripos ( $title , 'HEALTH_ERR' ) !== false ) {
$issueSubtype = 'health_err' ;
}
2026-01-07 19:27:13 -05:00
}
2025-03-03 18:23:44 -05:00
2026-04-16 08:06:13 -04:00
// Include source type so automated tickets never collide with manual ones
$sourceType = stripos ( $title , '[auto]' ) !== false ? 'auto' : 'manual' ;
2026-04-06 17:40:36 -04:00
// Build stable components
2025-02-27 21:37:38 -05:00
$stableComponents = [
2026-04-16 08:06:13 -04:00
'source_type' => $sourceType ,
2026-04-06 17:40:36 -04:00
'issue_category' => $issueCategory ,
'issue_subtype' => $issueSubtype ,
'environment_tags' => array_values ( array_filter (
explode ( '][' , $title ),
2026-01-17 15:53:45 -05:00
fn ( $tag ) => in_array ( $tag , [ 'production' , 'development' , 'staging' , 'single-node' , 'cluster-wide' ])
2026-04-06 17:40:36 -04:00
)),
2025-02-27 21:37:38 -05:00
];
2025-03-03 18:23:44 -05:00
2026-04-06 17:40:36 -04:00
// Include hostname for node-specific issues
2026-01-17 15:53:45 -05:00
if ( ! $isClusterWide ) {
$stableComponents [ 'hostname' ] = $hostname ;
}
2026-04-06 18:55:15 -04:00
// 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.
2025-03-03 18:23:44 -05:00
if ( $isDriveTicket ) {
2026-04-06 18:55:15 -04:00
$stableComponents [ 'drive' ] = $serial ? ? ( $deviceMatches [ 0 ] ? ? '' );
2025-03-03 18:23:44 -05:00
}
2025-05-15 08:33:13 -04:00
sort ( $stableComponents [ 'environment_tags' ]);
2026-01-07 19:27:13 -05:00
2025-02-27 21:37:38 -05:00
return hash ( 'sha256' , json_encode ( $stableComponents , JSON_UNESCAPED_SLASHES ));
}
2026-04-06 17:40:36 -04:00
// 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' );
2025-02-27 21:37:38 -05:00
$ticketHash = generateTicketHash ( $data );
2026-04-06 17:40:36 -04:00
$auditLog = new AuditLogModel ( $conn );
// Look up any existing ticket with this hash (open OR closed)
2026-04-06 18:55:15 -04:00
$checkStmt = $conn -> prepare ( " SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1 " );
2025-02-27 21:37:38 -05:00
$checkStmt -> bind_param ( " s " , $ticketHash );
$checkStmt -> execute ();
2026-04-06 17:40:36 -04:00
$existing = $checkStmt -> get_result () -> fetch_assoc ();
$checkStmt -> close ();
if ( $existing ) {
2026-04-06 18:55:15 -04:00
$existingId = $existing [ 'ticket_id' ];
$existingStatus = $existing [ 'status' ];
$existingTitle = $existing [ 'title' ];
$existingPriority = ( int ) $existing [ 'priority' ];
$newPriority = ( int ) $priority ;
2026-04-06 17:40:36 -04:00
if ( $existingStatus !== 'Closed' ) {
2026-04-16 08:06:13 -04:00
// Ticket is still active — update title, escalate priority, and refresh
2026-04-16 08:10:14 -04:00
<<<<<<< HEAD
2026-04-16 08:06:13 -04:00
// the description with the latest sensor data if the new report is more severe
// (lower priority number = higher severity).
2026-04-16 08:10:14 -04:00
=======
2026-04-16 08:09:12 -04:00
// description with latest sensor data.
2026-04-16 08:10:14 -04:00
>>>>>>> development
2026-04-06 18:55:15 -04:00
$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 ];
}
2026-04-16 08:06:13 -04:00
// 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 ;
}
2026-04-06 18:55:15 -04:00
if ( ! empty ( $changes )) {
$updateSql .= " WHERE ticket_id = ? " ;
$bindTypes .= " s " ;
$bindVals [] = $existingId ;
$updStmt = $conn -> prepare ( $updateSql );
$updStmt -> bind_param ( $bindTypes , ... $bindVals );
$updStmt -> execute ();
$updStmt -> close ();
2026-04-16 08:16:41 -04:00
// 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 ``` " ;
2026-04-16 08:06:13 -04:00
$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 ();
2026-04-06 18:55:15 -04:00
}
$auditLog -> log ( $userId , 'update' , 'ticket' , $existingId , array_merge (
2026-04-16 08:09:12 -04:00
array_diff_key ( $changes , [ 'description_refreshed' => true ]),
2026-04-06 18:55:15 -04:00
[ 'reason' => 'auto-updated by hwmonDaemon (condition worsened)' ]
));
2026-04-06 21:43:22 -04:00
// 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' );
}
2026-04-06 18:55:15 -04:00
}
$conn -> close ();
2026-04-06 17:40:36 -04:00
echo json_encode ([
2026-04-06 18:55:15 -04:00
'success' => true ,
'ticket_id' => $existingId ,
'message' => empty ( $changes ) ? 'Duplicate — no change' : 'Existing ticket updated' ,
'action' => empty ( $changes ) ? 'deduplicated' : 'updated' ,
'changes' => $changes ,
2026-04-06 17:40:36 -04:00
]);
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 " .
2026-04-16 08:06:13 -04:00
" New report received from hwmonDaemon: \n \n ``` \n " . $description . " \n ``` " ;
2026-04-06 17:40:36 -04:00
$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' );
2025-02-27 21:37:38 -05:00
echo json_encode ([
2026-04-06 17:40:36 -04:00
'success' => true ,
'ticket_id' => $existingId ,
'message' => 'Existing closed ticket reopened' ,
'action' => 'reopened' ,
2025-02-27 21:37:38 -05:00
]);
exit ;
}
2026-04-06 17:40:36 -04:00
// No existing ticket — create a new one
2026-04-11 13:06:08 -04:00
// 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 );
}
2026-04-06 17:40:36 -04:00
$insertStmt = $conn -> prepare (
" INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
);
2026-04-13 20:56:10 -04:00
$insertStmt -> bind_param (
" ssssssssi " ,
$ticket_id ,
$title ,
$description ,
$status ,
$priority ,
$category ,
$type ,
$ticketHash ,
$userId
2024-11-30 19:48:01 -05:00
);
2026-04-06 17:40:36 -04:00
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 ) {
2026-01-01 15:40:32 -05:00
$auditLog -> logTicketCreate ( $userId , $ticket_id , [
2026-04-06 17:40:36 -04:00
'title' => $title ,
2026-01-01 15:40:32 -05:00
'priority' => $priority ,
'category' => $category ,
2026-04-06 17:40:36 -04:00
'type' => $type ,
2026-01-01 15:40:32 -05:00
]);
2026-04-06 17:40:36 -04:00
$conn -> close ();
require_once __DIR__ . '/helpers/NotificationHelper.php' ;
NotificationHelper :: sendTicketNotification ( $ticket_id , [
'title' => $title ,
'priority' => $priority ,
'category' => $category ,
'type' => $type ,
'status' => $status ,
], 'automated' );
2024-11-30 19:48:01 -05:00
echo json_encode ([
2026-04-06 17:40:36 -04:00
'success' => true ,
'ticket_id' => $ticket_id ,
'message' => 'Ticket created successfully' ,
2024-11-30 19:48:01 -05:00
]);
} else {
2026-04-06 17:40:36 -04:00
echo json_encode ([ 'success' => false , 'error' => $conn -> error ]);
2024-11-30 19:48:01 -05:00
}