8 Commits

Author SHA1 Message Date
jared 597e1b1eea fix: correct phpcs indentation on SLA banner conditional block
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 11s
Security / PHP Security (semgrep) (push) Successful in 1m13s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
PHP inline conditionals inside HTML context must use 4-space indentation
to satisfy PSR-12 Generic.WhiteSpace.ScopeIndent rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:43:49 -04:00
jared 35a2b66038 refactor: migrate P1/P2 SLA banner to lt-sla-p1/lt-sla-p2 component
Lint / PHP (phpcs PSR-12) (push) Failing after 24s
Lint / JS (eslint) (push) Successful in 11s
Lint / Deploy (push) Has been cancelled
Lint / Notify on failure (push) Has been cancelled
Security / PHP Security (semgrep) (push) Has been cancelled
Replaces the lt-alert workaround with the new purpose-built SLA banner
component now in base.css:
- lt-sla-p1 (pulsing red) / lt-sla-p2 (static amber) wrapper classes
- Structured subcomponents: lt-sla-icon, lt-sla-info, lt-sla-title,
  lt-sla-bar + lt-sla-fill (gradient fill), lt-sla-meta, lt-sla-dismiss
- Dismiss now uses banner.hidden + sessionStorage key lt_sla_dismissed_<id>
  (aligns with web_template pattern; previous code used classList 'dismissed')
- Elapsed/remaining/breach state driven by same tick() interval, now updating
  lt-sla-fill width instead of a separate lt-progress bar inside lt-alert-msg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:40:15 -04:00
jared b7aea8c683 sync: pull progress gradient fills and SLA banner from web_template v1.2
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Successful in 1m12s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Progress bars now use linear-gradient fills for a more dramatic terminal
readout appearance (matches web_template 39862fa):
- Default (orange), --cyan, --green, --red variants all upgraded from flat
  accent colors to directional gradients with highlight endpoints

SLA banner component (lt-sla-p1 / lt-sla-p2) added to base.css, replacing
the lt-alert workaround previously used for P1/P2 SLA display:
- lt-sla-p1: pulsing red banner (animation: lt-sla-pulse 2s)
- lt-sla-p2: static amber banner
- Subcomponents: icon, info, title, bar, fill, meta, dismiss
- Both fills use gradients for visual consistency (P2 amber→#ffd740)
- lt-sla-dismiss includes transition + :focus-visible ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:29:57 -04:00
jared d23bbc4b26 docs: fix CI/CD section and add security badge
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Security / PHP Security (semgrep) (push) Successful in 2m7s
- Add security.yml badge to header
- Replace stale 'npm audit' description with actual semgrep config
- Add deploy tagging and notify-failure rows that were missing
- Fix ESLint config location note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:04:28 -04:00
jared 132098bee3 Exclude two more semgrep false-positive rules from security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Successful in 1m18s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- tainted-filename: filenames in upload_attachment.php and user_avatar.php
  are derived exclusively from (int)-cast integers; no user string reaches
  the filesystem path. Semgrep's taint engine tracks all use-sites of the
  variable, producing findings on every file_exists/readfile/unlink call.
- tainted-callable: index.php audit-log query passes \$sql to prepare();
  \$sql is assembled from hardcoded SQL fragments with ? placeholders and
  explicit (int) LIMIT/OFFSET casts. User values are bound via bind_param,
  never interpolated. Semgrep cannot see through the WHERE-builder logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:51:02 -04:00
jared 3a4a13db7b Fix semgrep security findings to pass CI security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 14s
Security / PHP Security (semgrep) (push) Failing after 1m27s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- index.php: replace SQL string interpolation with concatenation + explicit
  (int) casts for LIMIT/OFFSET; add nosemgrep for tainted-sql false positive
  (WHERE clause built from hardcoded fragments with bound params only)
- api/upload_attachment.php: add realpath() path-traversal guard after mkdir
- api/user_avatar.php: make (int) cast explicit at cache-path construction;
  add nosemgrep for tainted-filename false positive (integer-only input)
- assets/js/ticket.js: add nosemgrep for insertAdjacentHTML — all dynamic
  content already escaped via lt.escHtml() before insertion
- .gitea/workflows/security.yml: exclude echoed-request rule globally —
  all echo in API context is json_encode() output, not HTML; htmlentities()
  fix semgrep suggests would corrupt JSON responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:42:47 -04:00
jared 6b2d8e4d03 Fix remaining spam issues and phpcs merge conflict marker
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m41s
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Spam fixes:
- Add ZFS pool category to hash with subtypes (pool_state, pool_usage,
  pool_errors) so DEGRADED and usage-high on same pool get separate tickets
- Strip volatile percentages from LXC/ZFS usage titles ("usage high: 80.1%"
  → "usage high") and OSD counts from BlueStore slow-ops titles
  ("2 OSD(s) experiencing" → "OSD(s) experiencing") in hwmonDaemon.py

phpcs fix:
- Remove leftover merge conflict marker (<<<<<<< HEAD / >>>>>>>)
  in create_ticket_api.php which caused phpcs to fail on bitshift
  operator spacing

DB cleanup:
- Deleted 107 spam comments and 107 audit entries from tickets
  357934698 (ZFS pool), 673679581 (BlueStore), 925498317 (LXC storage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:28:59 -04:00
jared 7fb60a365e Suppress title-only update comments to stop hourly comment spam
Lint / PHP (phpcs PSR-12) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Failing after 1m37s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
Comments on worsening condition now only fire on priority escalation.
Title and description updates are silent — title changes (e.g. rising
Power_On_Hours counters) were generating a comment on every hourly run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:16:41 -04:00
9 changed files with 172 additions and 92 deletions
+6 -1
View File
@@ -22,4 +22,9 @@ jobs:
pip3 install semgrep pip3 install semgrep
- name: Run semgrep - name: Run semgrep
run: semgrep --config=p/php --config=p/owasp-top-ten --error . run: |
semgrep --config=p/php --config=p/owasp-top-ten --error \
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
.
+5 -3
View File
@@ -1,6 +1,7 @@
# Tinker Tickets # Tinker Tickets
[![Lint](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml) [![Lint](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
[![Security](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/security.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic. A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
@@ -569,12 +570,13 @@ Key conventions and gotchas for working with this codebase:
|---|---|---| |---|---|---|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR | | `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR | | `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
| `security.yml` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — | | `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass | | `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge. Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` per directory. Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
## License ## License
+6 -1
View File
@@ -144,13 +144,18 @@ if (!is_dir($uploadDir)) {
} }
} }
// Create ticket subdirectory // Create ticket subdirectory — ticketId is validated as digits-only above
$ticketDir = $uploadDir . '/' . $ticketId; $ticketDir = $uploadDir . '/' . $ticketId;
if (!is_dir($ticketDir)) { if (!is_dir($ticketDir)) {
if (!mkdir($ticketDir, 0755, true)) { if (!mkdir($ticketDir, 0755, true)) {
ResponseHelper::serverError('Failed to create ticket upload directory'); ResponseHelper::serverError('Failed to create ticket upload directory');
} }
} }
// Confirm resolved path stays within the upload root (defence-in-depth)
$resolvedTicketDir = realpath($ticketDir);
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
ResponseHelper::error('Invalid upload path');
}
// Derive extension from validated MIME type (never from user-supplied filename) // Derive extension from validated MIME type (never from user-supplied filename)
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain) // This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
+5 -3
View File
@@ -56,8 +56,11 @@ if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true); mkdir($cacheDir, 0755, true);
} }
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg'; // Build cache paths from the validated integer $userId — no user-supplied strings used
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600); $safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Serve from cache if fresh // Serve from cache if fresh
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) { if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
@@ -69,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
} }
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires // A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) { if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404); http_response_code(404);
exit; exit;
+81 -5
View File
@@ -2458,7 +2458,7 @@ select option:checked {
} }
.lt-progress-bar { .lt-progress-bar {
height: 100%; height: 100%;
background: var(--accent-orange); background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
box-shadow: var(--glow-orange); box-shadow: var(--glow-orange);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
@@ -2471,9 +2471,9 @@ select option:checked {
height: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4)); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
} }
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); } .lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); } .lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); } .lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
.lt-progress--striped .lt-progress-bar { .lt-progress--striped .lt-progress-bar {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
45deg, transparent, transparent 4px, 45deg, transparent, transparent 4px,
@@ -4479,7 +4479,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
61. TIMELINE / ACTIVITY FEED 61. SLA BANNER
----------------------------------------------------------------
lt-sla-p1 — pulsing red banner for critical SLA breach
lt-sla-p2 — static amber banner for high-priority SLA warning
---------------------------------------------------------------- */
.lt-sla-p1,
.lt-sla-p2 {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border: 1px solid;
font-family: var(--font-mono);
}
.lt-sla-p1 {
border-color: rgba(255,45,85,0.4);
background: rgba(255,45,85,0.08);
animation: lt-sla-pulse 2s infinite;
}
.lt-sla-p2 {
border-color: rgba(255,179,0,0.4);
background: rgba(255,179,0,0.08);
}
@keyframes lt-sla-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
}
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
.lt-sla-info { flex: 1; min-width: 0; }
.lt-sla-title {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 4px;
}
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
.lt-sla-bar {
height: 5px;
background: rgba(255,255,255,0.08);
position: relative;
overflow: hidden;
}
.lt-sla-fill {
height: 100%;
width: 0%;
transition: width 0.4s ease;
}
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
.lt-sla-meta {
font-size: 0.60rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.10em;
flex-shrink: 0;
}
.lt-sla-dismiss {
font-size: 0.70rem;
color: var(--text-dim);
cursor: pointer;
background: none;
border: none;
flex-shrink: 0;
padding: 0 0.25rem;
font-family: var(--font-mono);
transition: color 0.15s ease;
}
.lt-sla-dismiss:hover { color: var(--text-secondary); }
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
/* ----------------------------------------------------------------
62. TIMELINE / ACTIVITY FEED
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
.lt-timeline { .lt-timeline {
display: flex; display: flex;
+1 -1
View File
@@ -735,7 +735,7 @@ function renderDependencies(dependencies) {
// Insert blocker alert above the frame if not already there // Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel'); const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) { if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml); panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
} }
} }
+19 -19
View File
@@ -129,6 +129,21 @@ function generateTicketHash($data)
if (stripos($title, 'SMART issues') !== false) { if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart'; $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) { } elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage'; $issueCategory = 'storage';
// Include the LXC container ID so each container gets its own ticket // Include the LXC container ID so each container gets its own ticket
@@ -228,12 +243,7 @@ if ($existing) {
if ($existingStatus !== 'Closed') { if ($existingStatus !== 'Closed') {
// Ticket is still active — update title, escalate priority, and refresh // Ticket is still active — update title, escalate priority, and refresh
<<<<<<< HEAD
// the description with the latest sensor data if the new report is more severe
// (lower priority number = higher severity).
=======
// description with latest sensor data. // description with latest sensor data.
>>>>>>> development
$changes = []; $changes = [];
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?"; $updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
$bindTypes = "i"; $bindTypes = "i";
@@ -271,20 +281,10 @@ if ($existing) {
$updStmt->execute(); $updStmt->execute();
$updStmt->close(); $updStmt->close();
// Only add a comment when something meaningful changed (not just a description refresh) // Only post a comment on priority escalation — title and description updates
$meaningfulChanges = array_diff_key($changes, ['description_refreshed' => true]); // are silent (title changes like rising counters would spam a comment every run)
if (!empty($meaningfulChanges)) { if (isset($changes['priority'])) {
$changeLines = []; $commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
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']}";
}
// Wrap description in a fenced code block so ASCII art / box-drawing
// characters render correctly instead of collapsing into a paragraph blob
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
implode("\n", $changeLines) . "\n\nLatest report:\n\n```\n" . $description . "\n```";
$commentStmt = $conn->prepare( $commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
); );
+7 -3
View File
@@ -278,7 +278,10 @@ switch (true) {
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : ''; $where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where"; // $where contains only hardcoded SQL fragments with ? placeholders — user values
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
if (!empty($params)) { if (!empty($params)) {
$stmt = $conn->prepare($countSql); $stmt = $conn->prepare($countSql);
$stmt->bind_param($types, ...$params); $stmt->bind_param($types, ...$params);
@@ -290,12 +293,13 @@ switch (true) {
$totalLogs = $countResult->fetch_assoc()['total']; $totalLogs = $countResult->fetch_assoc()['total'];
$totalPages = ceil($totalLogs / $perPage); $totalPages = ceil($totalLogs / $perPage);
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$sql = "SELECT al.*, u.display_name, u.username $sql = "SELECT al.*, u.display_name, u.username
FROM audit_log al FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id LEFT JOIN users u ON al.user_id = u.user_id
$where " . $where . "
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT $perPage OFFSET $offset"; LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
if (!empty($params)) { if (!empty($params)) {
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
+42 -56
View File
@@ -215,56 +215,58 @@ include __DIR__ . '/layout_header.php';
1 => 8, 2 => 24, default => 72 1 => 8, 2 => 24, default => 72
}; };
$elapsedSeconds = time() - strtotime($ticket['created_at']); $elapsedSeconds = time() - strtotime($ticket['created_at']);
$elapsedHours = round($elapsedSeconds / 3600, 1);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100)); $slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600); $slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning'; $slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]'; $slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket'; $slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green'); $slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
?> ?>
<!-- Priority alert banner — P1/P2 only, dismissible per session --> <!-- SLA banner — P1/P2 only, dismissible per session -->
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner" <div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
role="alert" aria-live="polite" data-sla-id="<?= $slaId ?>"
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>" data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>" data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem"> style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span> <span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
<div class="lt-alert-body"> <div class="lt-sla-info">
<div class="lt-alert-title"><?= $alertLabel ?></div> <div class="lt-sla-title">
<div class="lt-alert-msg"> <?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
SLA target: <strong><?= $slaTargetHours ?>h</strong> &mdash; <?php if ($slaBreached) : ?>
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong> &nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
<?php if (!$slaBreached) : ?>
&mdash; Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
<?php else : ?>
&mdash; <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
<?php endif ?> <?php endif ?>
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem" </div>
aria-label="SLA progress <?= $slaPct ?>%"> <div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div> <div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div> </div>
</div> </div>
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">&#x2715;</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">&#x2715;</button>
</div> </div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>"> <script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){ (function(){
var banner = document.getElementById('priorityAlertBanner'); var banner = document.getElementById('priorityAlertBanner');
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>'; var id = banner.dataset.slaId;
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {} 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() { document.addEventListener('DOMContentLoaded', function() {
if (!banner || banner.classList.contains('dismissed')) return; if (banner.hidden) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000; var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000; var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs); var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer'); var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer'); var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer'); var overrunEl = document.getElementById('slaOverrunTimer');
var progressBar = document.getElementById('slaProgressBar'); var fillBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress'); var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) { function fmtHMS(ms) {
@@ -274,35 +276,19 @@ include __DIR__ . '/layout_header.php';
} }
function tick() { function tick() {
var now = Date.now(); var now = Date.now();
var elapsed = now - createdAt; var elapsed = now - createdAt;
var remaining = deadline - now; 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 (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (progressBar) progressBar.style.width = pct + '%'; if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%'); if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) { if (remaining > 0) {
// SLA not yet breached if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
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');
}
} else { } else {
// Breached if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
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';
}
} }
} }