Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bc3ab159 | ||
|
|
2b8d593ab0 | ||
|
|
600c46f673 | ||
|
|
5808b93cdb | ||
|
|
597e1b1eea | ||
|
|
35a2b66038 |
@@ -19,7 +19,9 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-pip
|
||||
pip3 install semgrep
|
||||
# Debian's Python is externally managed (PEP 668); the runner is
|
||||
# ephemeral so installing system-wide is fine here.
|
||||
pip3 install --break-system-packages semgrep
|
||||
|
||||
- name: Run semgrep
|
||||
run: |
|
||||
|
||||
@@ -59,7 +59,9 @@ $assignSql = "SELECT
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT 15";
|
||||
|
||||
$assignLike = '%"assigned_to":' . $userId . '%';
|
||||
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
||||
// match 120/123/etc. The assign detail is logged as {"assigned_to":<int>}.
|
||||
$assignLike = '%"assigned_to":' . (int)$userId . '}%';
|
||||
$stmt = $conn->prepare($assignSql);
|
||||
$stmt->bind_param('is', $userId, $assignLike);
|
||||
$stmt->execute();
|
||||
|
||||
+20
-15
@@ -127,6 +127,25 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Validate visibility BEFORE any DB write so a bad payload can't leave the
|
||||
// ticket half-updated (core fields committed but request reported as failed).
|
||||
$visibilityGroups = null;
|
||||
if (isset($data['visibility'])) {
|
||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||
// Convert array to comma-separated string if needed
|
||||
if (is_array($visibilityGroups)) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||
}
|
||||
|
||||
// Internal visibility requires at least one group
|
||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
@@ -160,22 +179,8 @@ try {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Handle visibility update if provided
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||
// Convert array to comma-separated string if needed
|
||||
if (is_array($visibilityGroups)) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||
}
|
||||
|
||||
// Validate internal visibility requires groups
|
||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified'
|
||||
];
|
||||
}
|
||||
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if ($visResult && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
|
||||
+13
-4
@@ -110,6 +110,7 @@ $safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
|
||||
$filter = "(uid=$safeUsername)";
|
||||
|
||||
$avatarData = null;
|
||||
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
|
||||
|
||||
try {
|
||||
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
||||
@@ -137,20 +138,28 @@ try {
|
||||
$avatarData = $entries[0]['avatar'][0];
|
||||
}
|
||||
|
||||
// The query ran to completion — any "no avatar" result is authoritative.
|
||||
$ldapQueryOk = true;
|
||||
|
||||
ldap_unbind($ldap);
|
||||
} catch (Exception $e) {
|
||||
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
||||
// Fall through to 404
|
||||
// Transient LDAP failure: do NOT poison the negative cache. Fall through to
|
||||
// a plain 404 so the avatar is retried on the next request once LDAP recovers.
|
||||
}
|
||||
|
||||
if ($avatarData === null || strlen($avatarData) < 100) {
|
||||
// Write sentinel so we don't hammer LDAP for users without avatars
|
||||
file_put_contents($noAvatarSentinel, '');
|
||||
// Only cache "no avatar" when LDAP actually answered. On an error/timeout we
|
||||
// leave no sentinel, so the lookup is retried instead of being stuck for the TTL.
|
||||
if ($ldapQueryOk) {
|
||||
file_put_contents($noAvatarSentinel, '');
|
||||
}
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate it's actually a JPEG (magic bytes FF D8 FF)
|
||||
// Validate it's actually a JPEG (magic bytes FF D8 FF). A successful LDAP read of
|
||||
// non-JPEG data is a genuine "no usable avatar", so the sentinel is appropriate here.
|
||||
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
||||
error_log("user_avatar: non-JPEG data for username=$username");
|
||||
file_put_contents($noAvatarSentinel, '');
|
||||
|
||||
@@ -103,7 +103,13 @@ while ($row = $watchersResult->fetch_assoc()) {
|
||||
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
||||
}
|
||||
$watchersStmt->close();
|
||||
$count = count($watchers);
|
||||
|
||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||
$countStmt->bind_param("i", $ticketId);
|
||||
$countStmt->execute();
|
||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||
$countStmt->close();
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
|
||||
@@ -25,10 +25,14 @@ function parseMarkdown(markdown) {
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Escape HTML first to prevent XSS
|
||||
// Escape HTML first to prevent XSS. Quotes MUST be escaped too: user-controlled
|
||||
// text (e.g. image/link URLs and alt text) is later interpolated into "..."
|
||||
// attributes, so an unescaped " would break out and inject event handlers.
|
||||
html = html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// Ticket references (#123456789) - convert to clickable links
|
||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||
|
||||
+11
-2
@@ -45,9 +45,11 @@ $conn = new mysqli(
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Database connection failed: ' . $conn->connect_error
|
||||
'error' => 'Internal server error'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
@@ -199,6 +201,11 @@ function generateTicketHash($data)
|
||||
)),
|
||||
];
|
||||
|
||||
// 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;
|
||||
@@ -397,7 +404,9 @@ try {
|
||||
// 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()]);
|
||||
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
*
|
||||
* Cleans up expired rate limit files from the temp directory.
|
||||
* Should be run via cron every 5-10 minutes:
|
||||
* */
|
||||
|
||||
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
|
||||
* 5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||
*
|
||||
* This script can also be run manually for immediate cleanup .
|
||||
* /
|
||||
* This script can also be run manually for immediate cleanup.
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
|
||||
@@ -5,19 +5,18 @@
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||
* Recommended: Run every 5-15 minutes
|
||||
* Recommended: run every 5-15 minutes.
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */
|
||||
|
||||
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
|
||||
* /
|
||||
* Example crontab entry (minute 10 of every hour):
|
||||
* 10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// Include required files
|
||||
require_once 'config/config.php';
|
||||
require_once 'helpers/Database.php';
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
@@ -31,17 +30,9 @@ function logMessage($message)
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
// Create database connection (Database::getConnection sets utf8mb4 so
|
||||
// non-ASCII titles/descriptions aren't corrupted on insert).
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Initialize models
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
@@ -59,6 +50,14 @@ try {
|
||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||
|
||||
try {
|
||||
// Claim the schedule FIRST (atomic advance of next_run_at). If another
|
||||
// cron run already claimed it, or it's no longer due, skip it — this
|
||||
// prevents duplicate-ticket floods if a later step throws.
|
||||
if (!$recurringModel->claimForRun($recurring['recurring_id'])) {
|
||||
logMessage("Skipped (already claimed or not due): " . $recurring['recurring_id']);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prepare ticket data
|
||||
$ticketData = [
|
||||
'title' => processTemplate($recurring['title_template']),
|
||||
@@ -76,9 +75,12 @@ try {
|
||||
$ticketId = $result['ticket_id'];
|
||||
logMessage("Created ticket: " . $ticketId);
|
||||
|
||||
// Assign to user if specified
|
||||
if ($recurring['assigned_to']) {
|
||||
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
||||
// Assign to user if specified. assignTicket() requires a non-null
|
||||
// "assigned_by"; fall back to the assignee when created_by is null
|
||||
// (recurring schedules may have no creator).
|
||||
if (!empty($recurring['assigned_to'])) {
|
||||
$assignedBy = (int)($recurring['created_by'] ?? $recurring['assigned_to']);
|
||||
$ticketModel->assignTicket($ticketId, (int)$recurring['assigned_to'], $assignedBy);
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
@@ -90,9 +92,6 @@ try {
|
||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
|
||||
// Update the recurring ticket's next run time
|
||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
@@ -106,7 +105,7 @@ try {
|
||||
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
Database::close();
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
|
||||
@@ -94,6 +94,10 @@ class BulkOperationsModel
|
||||
// Start transaction for data consistency
|
||||
$this->conn->begin_transaction();
|
||||
|
||||
// Attachment files for deleted tickets are removed only AFTER a successful
|
||||
// commit, so a rollback can't leave tickets with their files already gone.
|
||||
$filesToDelete = [];
|
||||
|
||||
try {
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
$ticketId = trim($ticketId);
|
||||
@@ -200,7 +204,7 @@ class BulkOperationsModel
|
||||
break;
|
||||
|
||||
case 'bulk_delete':
|
||||
$success = $ticketModel->deleteTicket($ticketId);
|
||||
$success = $ticketModel->deleteTicket($ticketId, $filesToDelete);
|
||||
if ($success) {
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
@@ -249,6 +253,16 @@ class BulkOperationsModel
|
||||
|
||||
// Commit the transaction
|
||||
$this->conn->commit();
|
||||
|
||||
// Now that the DB delete is durable, remove the physical files. Files
|
||||
// are deleted first; directory entries (no trailing filename) last.
|
||||
foreach ($filesToDelete as $path) {
|
||||
if (is_dir($path)) {
|
||||
@rmdir($path); // only succeeds if empty
|
||||
} elseif (file_exists($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Rollback on any unexpected error
|
||||
$this->conn->rollback();
|
||||
|
||||
@@ -151,6 +151,44 @@ class RecurringTicketModel
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a due schedule for processing.
|
||||
*
|
||||
* Advances next_run_at (and stamps last_run_at) in a single conditional
|
||||
* UPDATE gated on the row still being active and due. Returns true only if
|
||||
* THIS call won the claim. This must be done BEFORE creating the ticket so
|
||||
* that:
|
||||
* - two overlapping cron runs can't both process the same schedule, and
|
||||
* - a failure in a later step (ticket create, assignment, audit) can't
|
||||
* leave next_run_at in the past, which would re-fire — and re-create a
|
||||
* duplicate ticket — on every subsequent cron run.
|
||||
*
|
||||
* @return bool true if the schedule was claimed by this call
|
||||
*/
|
||||
public function claimForRun($recurringId)
|
||||
{
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets
|
||||
SET last_run_at = NOW(), next_run_at = ?
|
||||
WHERE recurring_id = ? AND is_active = 1 AND next_run_at <= NOW()";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$stmt->execute();
|
||||
$claimed = $stmt->affected_rows > 0;
|
||||
$stmt->close();
|
||||
return $claimed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
|
||||
+18
-6
@@ -740,9 +740,13 @@ class TicketModel
|
||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param array|null &$deferredFiles When provided, attachment file paths to
|
||||
* remove are appended here instead of being unlinked immediately, so a
|
||||
* caller running inside a DB transaction can delete them only AFTER a
|
||||
* successful commit (avoids destroying files for a rolled-back delete).
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteTicket(string $ticketId): bool
|
||||
public function deleteTicket(string $ticketId, ?array &$deferredFiles = null): bool
|
||||
{
|
||||
// Collect attachment filenames before deleting DB rows
|
||||
$attachmentFiles = [];
|
||||
@@ -804,13 +808,21 @@ class TicketModel
|
||||
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
|
||||
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
|
||||
if (is_dir($ticketDir)) {
|
||||
foreach ($attachmentFiles as $filename) {
|
||||
$file = $ticketDir . '/' . basename($filename);
|
||||
if (file_exists($file)) {
|
||||
@unlink($file);
|
||||
if ($deferredFiles !== null) {
|
||||
// Defer physical deletion to the caller (post-commit).
|
||||
foreach ($attachmentFiles as $filename) {
|
||||
$deferredFiles[] = $ticketDir . '/' . basename($filename);
|
||||
}
|
||||
$deferredFiles[] = $ticketDir; // dir removed last, only if empty
|
||||
} else {
|
||||
foreach ($attachmentFiles as $filename) {
|
||||
$file = $ticketDir . '/' . basename($filename);
|
||||
if (file_exists($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
@rmdir($ticketDir); // Remove dir only if empty
|
||||
}
|
||||
@rmdir($ticketDir); // Remove dir only if empty
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class UserModel
|
||||
$user = $result->fetch_assoc();
|
||||
|
||||
$updateStmt = $this->conn->prepare(
|
||||
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||
"UPDATE users SET display_name = ?, email = ?, `groups` = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||
);
|
||||
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
||||
$updateStmt->execute();
|
||||
@@ -100,7 +100,7 @@ class UserModel
|
||||
} else {
|
||||
// Create new user
|
||||
$insertStmt = $this->conn->prepare(
|
||||
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
);
|
||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||
$insertStmt->execute();
|
||||
@@ -300,7 +300,7 @@ class UserModel
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
|
||||
$stmt = $this->conn->prepare("SELECT DISTINCT `groups` FROM users WHERE `groups` IS NOT NULL AND `groups` != ''");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
|
||||
+42
-56
@@ -215,56 +215,58 @@ include __DIR__ . '/layout_header.php';
|
||||
1 => 8, 2 => 24, default => 72
|
||||
};
|
||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
||||
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
||||
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
||||
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
||||
role="alert" aria-live="polite"
|
||||
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
||||
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
||||
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
||||
data-sla-id="<?= $slaId ?>"
|
||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
||||
data-sla-hours="<?= $slaTargetHours ?>"
|
||||
style="margin-bottom:0.75rem">
|
||||
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
||||
<div class="lt-alert-body">
|
||||
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
||||
<div class="lt-alert-msg">
|
||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
||||
<?php if (!$slaBreached) : ?>
|
||||
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
||||
<?php else : ?>
|
||||
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
||||
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
||||
<div class="lt-sla-info">
|
||||
<div class="lt-sla-title">
|
||||
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
||||
<?php if ($slaBreached) : ?>
|
||||
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
||||
<?php endif ?>
|
||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
||||
aria-label="SLA progress <?= $slaPct ?>%">
|
||||
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
||||
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">✕</button>
|
||||
<?php if (!$slaBreached) : ?>
|
||||
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
||||
<?php else : ?>
|
||||
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
||||
<?php endif ?>
|
||||
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||
(function(){
|
||||
var banner = document.getElementById('priorityAlertBanner');
|
||||
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
||||
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
|
||||
var id = banner.dataset.slaId;
|
||||
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
||||
|
||||
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||
banner.hidden = true;
|
||||
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
||||
});
|
||||
|
||||
// Live SLA timers — start after base.js initialises lt
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!banner || banner.classList.contains('dismissed')) return;
|
||||
if (banner.hidden) return;
|
||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||
var deadline = new Date(createdAt + slaMs);
|
||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||
var deadline = new Date(createdAt + slaMs);
|
||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
||||
var progressBar = document.getElementById('slaProgressBar');
|
||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
||||
var fillBar = document.getElementById('slaProgressBar');
|
||||
var progressWrap = document.getElementById('slaProgress');
|
||||
|
||||
function fmtHMS(ms) {
|
||||
@@ -274,35 +276,19 @@ include __DIR__ . '/layout_header.php';
|
||||
}
|
||||
|
||||
function tick() {
|
||||
var now = Date.now();
|
||||
var elapsed = now - createdAt;
|
||||
var now = Date.now();
|
||||
var elapsed = now - createdAt;
|
||||
var remaining = deadline - now;
|
||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||
|
||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||
if (progressBar) progressBar.style.width = pct + '%';
|
||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||
if (fillBar) fillBar.style.width = pct + '%';
|
||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||
|
||||
if (remaining > 0) {
|
||||
// SLA not yet breached
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
|
||||
}
|
||||
if (progressWrap && pct >= 75) {
|
||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
|
||||
}
|
||||
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||
} else {
|
||||
// Breached
|
||||
if (countdownEl && !overrunEl) {
|
||||
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
|
||||
countdownEl.className = 'lt-text-danger';
|
||||
} else if (overrunEl) {
|
||||
overrunEl.textContent = fmtHMS(-remaining);
|
||||
}
|
||||
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
|
||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
|
||||
}
|
||||
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||
title="Command palette (Ctrl+K)"
|
||||
aria-label="Open command palette"
|
||||
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()"
|
||||
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||
@@ -258,6 +257,13 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
});
|
||||
} catch(_) {}
|
||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
|
||||
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
|
||||
var cmdTrigger = document.getElementById('lt-cmd-trigger');
|
||||
if (cmdTrigger) {
|
||||
cmdTrigger.addEventListener('click', function() {
|
||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
||||
});
|
||||
}
|
||||
});
|
||||
// Keyboard shortcut: Ctrl+K / Cmd+K
|
||||
document.addEventListener('keydown', function(e) {
|
||||
|
||||
Reference in New Issue
Block a user