Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597e1b1eea | |||
| 35a2b66038 | |||
| b7aea8c683 | |||
| d23bbc4b26 |
@@ -1,6 +1,7 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
|||||||
+81
-5
@@ -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;
|
||||||
|
|||||||
+42
-56
@@ -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> —
|
<?php if ($slaBreached) : ?>
|
||||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
||||||
<?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>
|
|
||||||
<?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">✕</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>
|
</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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user