Files
tinker_tickets/create_ticket_api.php
Jared Vititoe de9da756e9 Fix duplicate ticket creation for evolving SMART errors
Problem: When SMART errors evolved on the same drive, new tickets were created
instead of updating the existing ticket. This happened because the hash was
based on specific error values (e.g., "Reallocated_Sector_Ct: 8") instead of
just the issue category.

Root Cause:
- Old hash included specific SMART attribute names and values
- When errors changed (8 → 16 reallocated sectors, or new errors appeared),
  the hash changed, allowing duplicate tickets
- Only matched "Warning" attributes, missing "Critical" and "Error X occurred"
- Only matched /dev/sd[a-z], missing NVMe devices

Solution:
- Hash now based on: hostname + device + issue_category (e.g., "smart")
- Does NOT include specific error values or attribute names
- Supports both /dev/sdX and /dev/nvmeXnY devices
- Detects issue categories: smart, storage, memory, cpu, network

Result:
 Same drive, errors evolve → Same hash → Updates existing ticket
 Different device → Different hash → New ticket
 Drive replaced → Different device → New ticket
 NVMe devices now supported

Example:
Before:
- "Warning Reallocated: 8" → hash abc123
- "Warning Reallocated: 16" → hash xyz789 (NEW TICKET - bad!)

After:
- "Warning Reallocated: 8" → hash abc123
- "Warning Reallocated: 16" → hash abc123 (SAME TICKET - good!)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 19:27:13 -05:00

244 lines
7.2 KiB
PHP

<?php
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 1);
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
// 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;
}
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$systemUser = $apiKeyAuth->authenticate();
} catch (Exception $e) {
// Authentication failed - ApiKeyAuth already sent the response
exit;
}
$userId = $systemUser['user_id'];
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
// 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);
// Generate hash from stable components
function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches);
// Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values)
$issueCategory = '';
if (stripos($data['title'], 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
$issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) {
$issueCategory = 'network';
}
// Build stable components with only static data
$stableComponents = [
'hostname' => $hostname,
'issue_category' => $issueCategory, // Generic category, not specific errors
'environment_tags' => array_filter(
explode('][', $data['title']),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
)
];
// Only include device info 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
$ticketHash = generateTicketHash($data);
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
$checkStmt = $conn->prepare($checkDuplicateSQL);
$checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute();
$result = $checkStmt->get_result();
if ($result->num_rows > 0) {
$existingTicket = $result->fetch_assoc();
echo json_encode([
'success' => false,
'error' => 'Duplicate ticket',
'existing_ticket_id' => $existingTicket['ticket_id']
]);
exit;
}
// Force JSON content type for all incoming requests
header('Content-Type: application/json');
if (!$data) {
// Try parsing as URL-encoded data
parse_str($rawInput, $data);
}
// Generate ticket ID (9-digit format with leading zeros)
$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
);
if ($stmt->execute()) {
// Log ticket creation to audit log
$auditLog = new AuditLogModel($conn);
$auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type
]);
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => $conn->error
]);
}
$stmt->close();
$conn->close();
// Discord webhook
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
// Map priorities to Discord colors (decimal format)
$priorityColors = [
"1" => 16736589, // --priority-1: #ff4d4d
"2" => 16753958, // --priority-2: #ffa726
"3" => 4363509, // --priority-3: #42a5f5
"4" => 6736490 // --priority-4: #66bb6a
];
$discord_data = [
"content" => "",
"embeds" => [[
"title" => "New Ticket Created: #" . $ticket_id,
"description" => $title,
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
"color" => $priorityColors[$priority],
"fields" => [
["name" => "Priority", "value" => $priority, "inline" => true],
["name" => "Category", "value" => $category, "inline" => true],
["name" => "Type", "value" => $type, "inline" => true]
]
]]
];
$ch = curl_init($discord_webhook_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);