Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin-status.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-status.tsxBlame855 lines · 3 contributors
cf03ff5Test User1/**
2 * BLOCK S4 — Site-admin synthetic-monitor dashboard.
3 *
4 * GET /admin/status — red/green dashboard (SSE-live)
5 * POST /admin/status/run — run the suite synchronously
6 *
7 * Both gated behind `isSiteAdmin`. The public `/status` page (see
8 * `src/routes/status.tsx`) stays open to everyone; this is the detailed
9 * 14-row health table for the owner.
c9188afClaude10 *
11 * Visual recipe (2026 polish — mirrors admin-integrations / admin-ops /
12 * admin-deploys-page):
13 * - Gradient hairline strip across the top of the hero (purple→cyan, 2px)
14 * - Soft radial orb in the corner of the hero
15 * - Eyebrow with pill icon + actor name
16 * - Display headline with gradient-text on the verb ("Live now." /
17 * "Falling.")
18 * - Live-update banner with pulsing green dot + SSE event counter
19 * - Real-time activity feed: rows with tabular-nums timestamps, mono
20 * action verb with subtle accent, target check name
21 *
22 * Scoped CSS — every class prefixed `.status-` so this surface can't
23 * bleed into other admin pages.
cf03ff5Test User24 */
c9188afClaude25/* eslint-disable @typescript-eslint/no-explicit-any */
cf03ff5Test User26
27import { Hono } from "hono";
28import { Layout } from "../views/layout";
f4a1547ccantynz-alt29import { AdminShell } from "../views/admin-shell";
cf03ff5Test User30import { softAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { isSiteAdmin } from "../lib/admin";
33import {
34 latestStatusByCheck,
35 recentRedChecks,
36 runSyntheticChecks,
37 persistChecks,
38 SSE_TOPIC,
39 SYNTHETIC_CHECKS,
40 type SyntheticCheckResult,
41} from "../lib/synthetic-monitor";
42
43const adminStatus = new Hono<AuthEnv>();
44adminStatus.use("*", softAuth);
45
46async function gate(c: any): Promise<{ user: any } | Response> {
47 const user = c.get("user");
48 if (!user) return c.redirect("/login?next=/admin/status");
49 if (!(await isSiteAdmin(user.id))) {
50 return c.html(
51 <Layout title="Forbidden" user={user}>
c9188afClaude52 <div class="status-403">
cf03ff5Test User53 <h2>403 — Not a site admin</h2>
54 <p>You don't have permission to view this page.</p>
55 </div>
c9188afClaude56 <style dangerouslySetInnerHTML={{ __html: STATUS_CSS }} />
cf03ff5Test User57 </Layout>,
58 403
59 );
60 }
61 return { user };
62}
63
64function fmtAgo(checkedAt: Date | undefined): string {
65 if (!checkedAt) return "never";
66 const diffMs = Date.now() - checkedAt.getTime();
67 if (diffMs < 0) return "just now";
68 const s = Math.floor(diffMs / 1000);
69 if (s < 5) return "just now";
70 if (s < 60) return `${s}s ago`;
71 const m = Math.floor(s / 60);
72 if (m < 60) return `${m}m ago`;
73 const h = Math.floor(m / 60);
74 if (h < 24) return `${h}h ago`;
75 const d = Math.floor(h / 24);
76 return `${d}d ago`;
77}
78
c9188afClaude79function dotClass(status: SyntheticCheckResult["status"] | undefined): string {
80 if (status === "green") return "is-green";
81 if (status === "red") return "is-red";
82 if (status === "yellow") return "is-yellow";
83 return "is-idle";
84}
85
86function IconArrowLeft() {
87 return (
88 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
89 <line x1="19" y1="12" x2="5" y2="12" />
90 <polyline points="12 19 5 12 12 5" />
91 </svg>
92 );
93}
94function IconPulse() {
95 return (
96 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
97 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
98 </svg>
99 );
100}
101function IconPlay() {
102 return (
103 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
104 <polygon points="5 3 19 12 5 21 5 3" />
105 </svg>
106 );
107}
108
cf03ff5Test User109adminStatus.get("/admin/status", async (c) => {
110 const g = await gate(c);
111 if (g instanceof Response) return g;
112 const { user } = g;
113
114 const latest = await latestStatusByCheck();
115 const recent = await recentRedChecks(24, 25);
116
117 const allGreen = SYNTHETIC_CHECKS.every(
118 (spec) => latest[spec.name]?.status === "green"
119 );
c9188afClaude120 const redCount = SYNTHETIC_CHECKS.filter(
121 (spec) => latest[spec.name]?.status === "red"
122 ).length;
cf03ff5Test User123
124 // Find the most-recent checkedAt across all rows so we can render the
125 // "last run Xs ago" badge.
126 let lastRunAt: Date | null = null;
127 for (const spec of SYNTHETIC_CHECKS) {
128 const row = latest[spec.name];
129 if (!row) continue;
130 if (!lastRunAt || row.checkedAt > lastRunAt) lastRunAt = row.checkedAt;
131 }
132
c9188afClaude133 // Headline state — green/falling/idle for the gradient-text variant.
134 const headlineState: "green" | "falling" | "idle" = !lastRunAt
135 ? "idle"
136 : allGreen
137 ? "green"
138 : "falling";
139 const headline =
140 headlineState === "idle"
141 ? "Idle."
142 : headlineState === "green"
143 ? "Live now."
144 : "Falling.";
145
cf03ff5Test User146 return c.html(
f4a1547ccantynz-alt147 <AdminShell active="status" title="Synthetic monitor" user={user}>
c9188afClaude148 <div class="status-wrap">
149 {/* ─── Hero ─── */}
150 <section class="status-hero">
151 <div class="status-hero-orb" aria-hidden="true" />
152 <div class="status-hero-inner">
153 <div class="status-hero-top">
154 <div class="status-hero-text">
155 <div class="status-eyebrow">
156 <span class="status-eyebrow-pill" aria-hidden="true">
157 <IconPulse />
158 </span>
159 Synthetic monitor · Site admin · <span class="status-who">{user.username}</span>
160 </div>
161 <h1 class="status-title">
162 <span
163 class={
164 "status-title-grad " +
165 (headlineState === "falling"
166 ? "is-falling"
167 : headlineState === "idle"
168 ? "is-idle"
169 : "is-green")
170 }
cf03ff5Test User171 >
c9188afClaude172 {headline}
173 </span>
174 </h1>
175 <p class="status-sub">
176 {allGreen
177 ? "All synthetic checks are green. Runs every autopilot tick."
178 : `${redCount} check${redCount === 1 ? "" : "s"} red — see the table below for the failing rows.`}{" "}
179 Last run{" "}
180 <span data-last-run-at class="status-tabular">
181 {fmtAgo(lastRunAt ?? undefined)}
182 </span>
183 .
184 </p>
185 </div>
186 <a href="/admin" class="status-hero-back">
187 <IconArrowLeft />
188 Back to admin
189 </a>
190 </div>
191 </div>
192 </section>
cf03ff5Test User193
c9188afClaude194 {/* ─── Live SSE banner ─── */}
195 <div
196 class="status-live"
197 role="status"
198 aria-live="polite"
199 data-sse-banner
cf03ff5Test User200 >
c9188afClaude201 <span class="status-live-dot" aria-hidden="true" />
202 <span class="status-live-label" data-sse-state>
203 SSE connecting…
204 </span>
205 <span class="status-live-sep" aria-hidden="true">·</span>
206 <span class="status-live-rate">
207 <span class="status-tabular" data-sse-rate>0</span> events / min
208 </span>
209 <span class="status-live-sep" aria-hidden="true">·</span>
210 <span class="status-live-topic">
211 topic <code>{SSE_TOPIC}</code>
212 </span>
213 </div>
214
215 {/* ─── Synthetic-check table ─── */}
216 <section class="status-section">
217 <header class="status-section-head">
218 <div class="status-section-head-text">
219 <h3 class="status-section-title">Synthetic checks</h3>
220 <p class="status-section-sub">
221 {SYNTHETIC_CHECKS.length} probes — every row patches in place
222 as the SSE stream fires.
223 </p>
224 </div>
225 <form action="/admin/status/run" method="post" class="status-runform">
226 <button type="submit" class="status-btn status-btn-primary">
227 <IconPlay />
228 Run all checks now
229 </button>
230 </form>
231 </header>
232 <div class="status-section-body status-section-body--flush">
233 <table class="status-table" id="synthetic-table">
234 <thead>
235 <tr>
236 <th class="status-col-dot"></th>
237 <th class="status-col-name">Check</th>
238 <th class="status-col-num">Status</th>
239 <th class="status-col-num">Duration</th>
240 <th class="status-col-when">Last run</th>
241 </tr>
242 </thead>
243 <tbody>
244 {SYNTHETIC_CHECKS.map((spec) => {
245 const row = latest[spec.name];
246 return (
247 <tr data-check-name={spec.name}>
248 <td class="status-col-dot" data-cell="dot">
249 <span
250 class={"status-dot " + dotClass(row?.status)}
251 aria-label={row?.status ?? "idle"}
252 />
253 </td>
254 <td class="status-col-name">
255 <code class="status-action">{spec.name}</code>
256 {row?.error ? (
257 <div class="status-error" data-cell="error">
258 {row.error}
259 </div>
260 ) : null}
261 </td>
262 <td class="status-col-num status-tabular" data-cell="status-code">
263 {row?.statusCode ?? "—"}
264 </td>
265 <td class="status-col-num status-tabular" data-cell="duration">
266 {row ? `${row.durationMs}ms` : "—"}
267 </td>
268 <td class="status-col-when status-tabular" data-cell="ago">
269 {fmtAgo(row?.checkedAt)}
270 </td>
271 </tr>
272 );
273 })}
274 </tbody>
275 </table>
276 </div>
277 </section>
278
279 {/* ─── Recent activity feed (red checks in last 24h) ─── */}
280 <section class="status-section">
281 <header class="status-section-head">
282 <div class="status-section-head-text">
283 <h3 class="status-section-title">Recent activity</h3>
284 <p class="status-section-sub">
285 Red checks in the last 24 hours — most recent first.
286 </p>
287 </div>
288 </header>
289 <div class="status-section-body status-section-body--flush">
290 {recent.length === 0 ? (
291 <div class="status-empty">
292 No red checks in the last 24 hours.
cf03ff5Test User293 </div>
c9188afClaude294 ) : (
295 <ol class="status-feed" aria-label="Recent red checks">
296 {recent.map((r) => (
297 <li class="status-feed-row">
298 <span
299 class="status-feed-when status-tabular"
300 title={r.checkedAt.toISOString()}
301 >
302 {fmtAgo(r.checkedAt)}
303 </span>
304 <span class="status-feed-action">
305 <span class="status-dot is-red" aria-hidden="true" />
306 <code>red</code>
307 </span>
308 <a
309 class="status-feed-target"
310 href={`#check-${r.name}`}
311 onclick={`var t=document.querySelector('tr[data-check-name="${r.name}"]');if(t){t.scrollIntoView({behavior:'smooth',block:'center'});t.classList.add('status-row-flash');setTimeout(function(){t.classList.remove('status-row-flash');},1600);}`}
312 >
313 {r.name}
314 </a>
315 <span class="status-feed-msg">
316 {r.error || "(no error message)"}
317 </span>
318 </li>
319 ))}
320 </ol>
321 )}
cf03ff5Test User322 </div>
c9188afClaude323 </section>
cf03ff5Test User324
c9188afClaude325 {/* Live update via SSE. Each event is a SyntheticCheckResult; we
326 patch the matching <tr data-check-name=...> in place. The
327 banner counts events received in the last 60s. */}
cf03ff5Test User328 <script
329 dangerouslySetInnerHTML={{
330 __html: `
331(function() {
c9188afClaude332 var bannerLabel = document.querySelector('[data-sse-state]');
333 var bannerRate = document.querySelector('[data-sse-rate]');
334 var bannerEl = document.querySelector('[data-sse-banner]');
335 var eventTimes = [];
336 function setBanner(state, cls) {
337 if (bannerLabel) bannerLabel.textContent = state;
338 if (bannerEl) {
339 bannerEl.classList.remove('is-on','is-off','is-connecting');
340 bannerEl.classList.add(cls);
341 }
342 }
343 function tickRate() {
344 var cutoff = Date.now() - 60000;
345 while (eventTimes.length && eventTimes[0] < cutoff) eventTimes.shift();
346 if (bannerRate) bannerRate.textContent = String(eventTimes.length);
347 }
348 setBanner('SSE connecting…', 'is-connecting');
349 setInterval(tickRate, 1000);
cf03ff5Test User350 try {
351 var src = new EventSource('/live-events/${SSE_TOPIC}');
c9188afClaude352 src.onopen = function(){ setBanner('SSE connected', 'is-on'); };
353 src.onerror = function(){ setBanner('SSE reconnecting…', 'is-connecting'); };
cf03ff5Test User354 src.addEventListener('check', function(ev) {
c9188afClaude355 eventTimes.push(Date.now()); tickRate();
cf03ff5Test User356 var data;
357 try { data = JSON.parse(ev.data); } catch (e) { return; }
358 var row = document.querySelector('tr[data-check-name="' + data.name + '"]');
359 if (!row) return;
360 var dot = row.querySelector('[data-cell="dot"]');
361 var statusCode = row.querySelector('[data-cell="status-code"]');
362 var duration = row.querySelector('[data-cell="duration"]');
363 var ago = row.querySelector('[data-cell="ago"]');
c9188afClaude364 if (dot) {
365 var span = dot.querySelector('.status-dot');
366 var cls = data.status === 'green' ? 'is-green'
367 : data.status === 'red' ? 'is-red'
368 : data.status === 'yellow'? 'is-yellow' : 'is-idle';
369 if (span) { span.className = 'status-dot ' + cls; }
370 }
371 if (statusCode) statusCode.textContent = data.statusCode != null ? String(data.statusCode) : '—';
cf03ff5Test User372 if (duration) duration.textContent = data.durationMs + 'ms';
373 if (ago) ago.textContent = 'just now';
374 var lastRun = document.querySelector('[data-last-run-at]');
375 if (lastRun) lastRun.textContent = 'just now';
c9188afClaude376 row.classList.add('status-row-flash');
377 setTimeout(function(){ row.classList.remove('status-row-flash'); }, 1100);
cf03ff5Test User378 });
c9188afClaude379 } catch (e) {
380 setBanner('SSE unavailable', 'is-off');
381 }
cf03ff5Test User382})();
383`,
384 }}
385 />
386 </div>
c9188afClaude387 <style dangerouslySetInnerHTML={{ __html: STATUS_CSS }} />
f4a1547ccantynz-alt388 </AdminShell>
cf03ff5Test User389 );
390});
391
392adminStatus.post("/admin/status/run", async (c) => {
393 const g = await gate(c);
394 if (g instanceof Response) return g;
395
396 // Fire-and-forget the run so we don't block the redirect on a slow
397 // network. Persist + SSE happens inside the helper.
398 void (async () => {
399 try {
400 const results = await runSyntheticChecks();
401 await persistChecks(results);
402 } catch (err) {
403 console.error("[admin-status] manual run failed:", err);
404 }
405 })();
406
407 return c.redirect("/admin/status");
408});
409
c9188afClaude410// ---------------------------------------------------------------------------
411// Scoped CSS — every class prefixed `.status-` so the synthetic-monitor
412// surface can't bleed into other admin pages. Mirrors the gradient-hairline
413// hero + section-card motif from admin-ops / admin-deploys-page.
414// ---------------------------------------------------------------------------
415
416const STATUS_CSS = `
417 .status-wrap {
418 max-width: 1100px;
419 margin: 0 auto;
420 padding: var(--space-6) var(--space-4) var(--space-12);
421 }
422 .status-tabular { font-variant-numeric: tabular-nums; }
423
424 /* ─── Hero ─── */
425 .status-hero {
426 position: relative;
427 margin-bottom: var(--space-5);
428 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
429 background: var(--bg-elevated);
430 border: 1px solid var(--border);
431 border-radius: 18px;
432 overflow: hidden;
433 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
434 }
435 .status-hero::before {
436 content: '';
437 position: absolute;
438 top: 0; left: 0; right: 0;
439 height: 2px;
6fd5915Claude440 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
c9188afClaude441 opacity: 0.75;
442 pointer-events: none;
443 }
444 .status-hero-orb {
445 position: absolute;
446 inset: -30% -10% auto auto;
447 width: 460px; height: 460px;
6fd5915Claude448 background: radial-gradient(circle, rgba(52,211,153,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
c9188afClaude449 filter: blur(80px);
450 opacity: 0.7;
451 pointer-events: none;
452 z-index: 0;
453 }
454 .status-hero-inner { position: relative; z-index: 1; }
455 .status-hero-top {
456 display: flex;
457 align-items: flex-start;
458 justify-content: space-between;
459 gap: var(--space-4);
460 flex-wrap: wrap;
461 }
462 .status-hero-text { flex: 1; min-width: 280px; }
463 .status-eyebrow {
464 display: inline-flex;
465 align-items: center;
466 gap: 8px;
467 font-size: 12px;
468 color: var(--text-muted);
469 margin-bottom: 14px;
470 letter-spacing: 0.02em;
471 }
472 .status-eyebrow-pill {
473 display: inline-flex;
474 align-items: center;
475 justify-content: center;
476 width: 18px; height: 18px;
477 border-radius: 6px;
478 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt479 color: var(--green);
c9188afClaude480 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.35);
481 }
482 .status-who { color: var(--accent); font-weight: 600; }
483 .status-title {
484 font-family: var(--font-display);
485 font-size: clamp(28px, 4vw, 40px);
486 font-weight: 800;
487 letter-spacing: -0.028em;
488 line-height: 1.05;
489 margin: 0 0 var(--space-2);
490 color: var(--text-strong);
491 }
492 .status-title-grad {
6fd5915Claude493 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #5f8fa0 100%);
c9188afClaude494 -webkit-background-clip: text;
495 background-clip: text;
496 -webkit-text-fill-color: transparent;
497 color: transparent;
498 }
499 .status-title-grad.is-falling {
500 background-image: linear-gradient(135deg, #fca5a5 0%, #f87171 50%, #ef4444 100%);
501 }
502 .status-title-grad.is-idle {
6fd5915Claude503 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
c9188afClaude504 }
505 .status-sub {
506 font-size: 14.5px;
507 color: var(--text-muted);
508 margin: 0;
509 line-height: 1.55;
510 max-width: 640px;
511 }
512 .status-hero-back {
513 display: inline-flex;
514 align-items: center;
515 gap: 6px;
516 padding: 7px 12px;
517 font-size: 12.5px;
518 color: var(--text-muted);
519 background: rgba(255,255,255,0.02);
520 border: 1px solid var(--border);
521 border-radius: 8px;
522 text-decoration: none;
523 font-weight: 500;
524 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
525 flex-shrink: 0;
526 }
527 .status-hero-back:hover {
528 border-color: var(--border-strong);
529 color: var(--text-strong);
530 background: rgba(255,255,255,0.04);
531 text-decoration: none;
532 }
533
534 /* ─── Live SSE banner ─── */
535 .status-live {
536 display: flex;
537 align-items: center;
538 gap: 10px;
539 padding: 10px 14px;
540 margin-bottom: var(--space-4);
541 background: rgba(52,211,153,0.05);
542 border: 1px solid rgba(52,211,153,0.22);
543 border-radius: 10px;
544 font-size: 12.5px;
545 color: var(--text);
546 flex-wrap: wrap;
547 }
548 .status-live.is-connecting {
549 background: rgba(251,191,36,0.05);
550 border-color: rgba(251,191,36,0.22);
551 color: #fde68a;
552 }
553 .status-live.is-off {
554 background: rgba(248,113,113,0.06);
555 border-color: rgba(248,113,113,0.25);
556 color: #fecaca;
557 }
558 .status-live-dot {
559 width: 8px; height: 8px;
560 border-radius: 9999px;
e589f77ccantynz-alt561 background: var(--green);
c9188afClaude562 box-shadow: 0 0 0 3px rgba(52,211,153,0.20);
563 animation: status-pulse 1.4s ease-in-out infinite;
564 flex-shrink: 0;
565 }
566 .status-live.is-connecting .status-live-dot {
567 background: #fbbf24;
568 box-shadow: 0 0 0 3px rgba(251,191,36,0.22);
569 }
570 .status-live.is-off .status-live-dot {
e589f77ccantynz-alt571 background: var(--red);
c9188afClaude572 box-shadow: 0 0 0 3px rgba(248,113,113,0.22);
573 animation: none;
574 }
575 @keyframes status-pulse {
576 0%, 100% { opacity: 1; transform: scale(1); }
577 50% { opacity: 0.55; transform: scale(0.85); }
578 }
579 @media (prefers-reduced-motion: reduce) {
580 .status-live-dot { animation: none; }
581 }
582 .status-live-label {
583 font-family: var(--font-mono);
584 font-size: 12px;
585 color: inherit;
586 font-weight: 600;
587 }
588 .status-live-rate { color: inherit; }
589 .status-live-topic {
590 color: var(--text-muted);
591 margin-left: auto;
592 font-size: 11.5px;
593 }
594 .status-live-topic code {
595 font-family: var(--font-mono);
596 font-size: 11.5px;
597 background: rgba(255,255,255,0.04);
598 border: 1px solid var(--border);
599 padding: 1px 6px;
600 border-radius: 4px;
601 color: var(--text);
602 }
603 .status-live-sep { color: var(--text-muted); opacity: 0.45; }
604
605 /* ─── Section cards ─── */
606 .status-section {
607 margin-bottom: var(--space-5);
608 background: var(--bg-elevated);
609 border: 1px solid var(--border);
610 border-radius: 14px;
611 overflow: hidden;
612 }
613 .status-section-head {
614 padding: var(--space-4) var(--space-5);
615 border-bottom: 1px solid var(--border);
616 display: flex;
617 align-items: flex-start;
618 justify-content: space-between;
619 gap: var(--space-3);
620 flex-wrap: wrap;
621 }
622 .status-section-head-text { flex: 1; min-width: 240px; }
623 .status-section-title {
624 margin: 0;
625 font-family: var(--font-display);
626 font-size: 17px;
627 font-weight: 700;
628 letter-spacing: -0.018em;
629 color: var(--text-strong);
630 }
631 .status-section-sub {
632 margin: 4px 0 0;
633 font-size: 12.5px;
634 color: var(--text-muted);
635 }
636 .status-section-body { padding: var(--space-4) var(--space-5); }
637 .status-section-body--flush { padding: 0; }
638
639 .status-runform { margin: 0; }
640 .status-btn {
641 display: inline-flex;
642 align-items: center;
643 gap: 6px;
644 padding: 9px 16px;
645 border-radius: 10px;
646 font-size: 13px;
647 font-weight: 600;
648 text-decoration: none;
649 border: 1px solid transparent;
650 cursor: pointer;
651 font: inherit;
652 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
653 line-height: 1;
654 }
655 .status-btn-primary {
6fd5915Claude656 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
c9188afClaude657 color: #ffffff;
6fd5915Claude658 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
c9188afClaude659 }
660 .status-btn-primary:hover {
661 transform: translateY(-1px);
6fd5915Claude662 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
c9188afClaude663 }
664
665 /* ─── Synthetic table ─── */
666 .status-table {
667 width: 100%;
668 border-collapse: collapse;
669 font-size: 13px;
670 }
671 .status-table thead th {
672 padding: 10px 14px;
673 text-align: left;
674 font-family: var(--font-mono);
675 font-size: 10.5px;
676 text-transform: uppercase;
677 letter-spacing: 0.14em;
678 color: var(--text-muted);
679 font-weight: 700;
680 background: rgba(255,255,255,0.012);
681 border-bottom: 1px solid var(--border);
682 }
683 .status-table tbody td {
684 padding: 10px 14px;
685 border-bottom: 1px solid var(--border);
686 vertical-align: middle;
687 }
688 .status-table tbody tr:last-child td { border-bottom: 0; }
689 .status-table tbody tr {
690 transition: background 200ms ease;
691 }
692 .status-table tbody tr:hover {
693 background: rgba(255,255,255,0.022);
694 }
695 .status-table .status-col-dot { width: 30px; }
696 .status-table .status-col-num { width: 90px; }
697 .status-table .status-col-when { width: 110px; color: var(--text-muted); }
698 .status-row-flash {
6fd5915Claude699 background: rgba(91,110,232,0.10) !important;
c9188afClaude700 animation: status-flash 1.1s ease-out;
701 }
702 @keyframes status-flash {
6fd5915Claude703 0% { background: rgba(91,110,232,0.30); }
704 100% { background: rgba(91,110,232,0.00); }
c9188afClaude705 }
706
707 .status-action {
708 font-family: var(--font-mono);
709 font-size: 12.5px;
710 color: var(--text-strong);
6fd5915Claude711 background: linear-gradient(135deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
712 border: 1px solid rgba(91,110,232,0.20);
c9188afClaude713 padding: 2px 8px;
714 border-radius: 5px;
715 }
716 .status-error {
717 margin-top: 4px;
718 font-family: var(--font-mono);
719 font-size: 11.5px;
e589f77ccantynz-alt720 color: var(--red);
c9188afClaude721 line-height: 1.4;
722 }
723
724 .status-dot {
725 display: inline-block;
726 width: 10px; height: 10px;
727 border-radius: 9999px;
728 background: var(--text-muted);
729 box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
730 vertical-align: middle;
731 }
732 .status-dot.is-green {
e589f77ccantynz-alt733 background: var(--green);
c9188afClaude734 box-shadow: 0 0 0 3px rgba(52,211,153,0.18), 0 0 8px rgba(52,211,153,0.40);
735 }
736 .status-dot.is-red {
e589f77ccantynz-alt737 background: var(--red);
c9188afClaude738 box-shadow: 0 0 0 3px rgba(248,113,113,0.20), 0 0 10px rgba(248,113,113,0.45);
739 animation: status-pulse 1.6s ease-in-out infinite;
740 }
741 .status-dot.is-yellow {
742 background: #fbbf24;
743 box-shadow: 0 0 0 3px rgba(251,191,36,0.22);
744 }
745 .status-dot.is-idle { background: var(--text-muted); }
746 @media (prefers-reduced-motion: reduce) {
747 .status-dot.is-red { animation: none; }
748 }
749
750 /* ─── Feed ─── */
751 .status-empty {
752 padding: 22px 16px;
753 text-align: center;
754 color: var(--text-muted);
755 font-size: 13px;
756 }
757 .status-feed {
758 list-style: none;
759 margin: 0;
760 padding: 0;
761 }
762 .status-feed-row {
763 display: grid;
764 grid-template-columns: 96px 84px minmax(140px, 220px) 1fr;
765 gap: 14px;
766 align-items: center;
767 padding: 10px 14px;
768 border-bottom: 1px solid var(--border);
769 font-size: 13px;
770 }
771 .status-feed-row:last-child { border-bottom: 0; }
772 .status-feed-row:hover { background: rgba(255,255,255,0.022); }
773 .status-feed-when {
774 font-family: var(--font-mono);
775 font-size: 11.5px;
776 color: var(--text-muted);
777 }
778 .status-feed-action {
779 display: inline-flex;
780 align-items: center;
781 gap: 6px;
782 font-size: 12px;
e589f77ccantynz-alt783 color: var(--red);
c9188afClaude784 }
785 .status-feed-action code {
786 font-family: var(--font-mono);
787 font-size: 11.5px;
788 background: rgba(248,113,113,0.08);
789 border: 1px solid rgba(248,113,113,0.28);
790 padding: 1px 6px;
791 border-radius: 4px;
e589f77ccantynz-alt792 color: var(--red);
c9188afClaude793 text-transform: uppercase;
794 letter-spacing: 0.06em;
795 font-weight: 700;
796 }
797 .status-feed-target {
798 font-family: var(--font-mono);
799 font-size: 12.5px;
800 color: var(--text-strong);
801 background: rgba(255,255,255,0.04);
802 border: 1px solid var(--border);
803 padding: 2px 8px;
804 border-radius: 5px;
805 text-decoration: none;
806 overflow: hidden;
807 text-overflow: ellipsis;
808 white-space: nowrap;
809 }
810 .status-feed-target:hover {
6fd5915Claude811 background: rgba(91,110,232,0.10);
812 border-color: rgba(91,110,232,0.40);
c9188afClaude813 color: var(--text-strong);
814 text-decoration: none;
815 }
816 .status-feed-msg {
817 color: var(--text-muted);
818 font-size: 12.5px;
819 overflow: hidden;
820 text-overflow: ellipsis;
821 white-space: nowrap;
822 }
823
824 /* ─── 403 fallback ─── */
825 .status-403 {
826 max-width: 540px;
827 margin: var(--space-12) auto;
828 padding: var(--space-6);
829 text-align: center;
830 background: var(--bg-elevated);
831 border: 1px solid var(--border);
832 border-radius: 16px;
833 }
834 .status-403 h2 {
835 font-family: var(--font-display);
836 font-size: 22px;
837 margin: 0 0 8px;
838 color: var(--text-strong);
839 }
840 .status-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
841
842 @media (max-width: 720px) {
843 .status-wrap { padding: var(--space-4) var(--space-3) var(--space-8); }
844 .status-feed-row {
845 grid-template-columns: 96px 84px 1fr;
846 grid-template-areas:
847 'when action target'
848 'when action msg';
849 }
850 .status-feed-msg { grid-column: 3 / 4; }
851 .status-live-topic { margin-left: 0; }
852 }
853`;
854
cf03ff5Test User855export default adminStatus;