feat: add dashboard page
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
# План: SLA Dashboard для кейса #4549142
|
||||||
|
|
||||||
|
## Описание для человека (что увидишь на странице)
|
||||||
|
|
||||||
|
**Общий вид:**
|
||||||
|
Страница выглядит как настоящий мониторинговый дашборд в стиле Grafana — тёмная тема, сетка панелей с тонкими рамками, данные обновляются в реальном времени. Всё на одном экране, без скролла на десктопе (на мобиле — вертикальная раскладка).
|
||||||
|
|
||||||
|
**Шапка (top bar):**
|
||||||
|
|
||||||
|
- Слева: название дашборда + номер тикета (`SLA MONITOR / #4549142`)
|
||||||
|
- Справа: текущее время (тикающие часы), статус-индикатор (зелёный/жёлтый/красный — зависит от времени ожидания)
|
||||||
|
- Навигация: ссылка «← BACK TO TIMER» на главную
|
||||||
|
|
||||||
|
**Ряд ключевых метрик (4 big number панели):**
|
||||||
|
|
||||||
|
1. **TIME ELAPSED** — сколько дней/часов/минут прошло (то же, что флип-часы, но в цифровом виде)
|
||||||
|
2. **LAST ACTIVITY** — сколько дней/часов назад было последнее событие
|
||||||
|
3. **SLA STATUS** — `BREACHED` (красный), так как SLA давно нарушен
|
||||||
|
4. **COMMENTS** — количество комментариев
|
||||||
|
|
||||||
|
**Центральная область — основной график:**
|
||||||
|
|
||||||
|
- Линейный график `ACTIVITY TIMELINE` — по оси X время (дни с момента создания), по оси Y — количество событий (комментарии, изменения лейблов, упоминания). Визуально — горизонтальная полоса с «вспышками» активности.
|
||||||
|
|
||||||
|
**Нижняя часть — 3 панели в ряд:**
|
||||||
|
|
||||||
|
1. **COMMENTS LOG** — список последних комментариев (автор, текст, дата), стилизованный как терминал/лог
|
||||||
|
2. **STATUS TIMELINE** — вертикальная лента событий: создание → лейблы → комментарии → текущий статус
|
||||||
|
3. **CASE DETAILS** — таблица-карточка: автор, assignee, labels, created, updated, milestone
|
||||||
|
|
||||||
|
**Футер:**
|
||||||
|
|
||||||
|
- `DATA SOURCE: GITHUB API | LAST UPDATED: <timestamp> | AUTO-REFRESH: 60s`
|
||||||
|
- Ссылка на сам тикет (откроется в новой вкладке)
|
||||||
|
|
||||||
|
**Поведение:**
|
||||||
|
|
||||||
|
- Все данные подгружаются из GitHub API (fetch на клиенте при загрузке)
|
||||||
|
- График обновляется раз в 60 секунд
|
||||||
|
- Анимация появления панелей (fade-in)
|
||||||
|
- На мобиле — панели стоят друг под другом
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Промпт для AI-генерации страницы
|
||||||
|
|
||||||
|
### SYSTEM / CONTEXT
|
||||||
|
|
||||||
|
Ты — фронтенд-разработчик. У тебя есть Astro-проект (v7, статичный сайт) с одной страницей — флип-часы. Стек: Astro + vanilla CSS + vanilla TypeScript. Никаких фреймворков (React/Vue/Svelte), никаких CSS-фреймворков (Tailwind). Единственный npm-пакет — astro. Шрифты подключены через Google Fonts `<link>`: Inter, Cormorant Garamond, IBM Plex Mono.
|
||||||
|
|
||||||
|
Тема проекта (design tokens в `global.css`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
--background: #090a0a;
|
||||||
|
--foreground: #e9e4d9;
|
||||||
|
--gold: #b99a62;
|
||||||
|
--font-inter: "Inter", sans-serif;
|
||||||
|
--font-cormorant: "Cormorant Garamond", Georgia, serif;
|
||||||
|
--font-mono-local: "IBM Plex Mono", monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ЗАДАЧА
|
||||||
|
|
||||||
|
Создай новую страницу `src/pages/dashboard.astro` — «панель мониторинга SLA» для одного GitHub-тикета. Страница должна выглядеть как реалистичный Grafana/Monitoring дашборд (dark theme, grid панелей, данные в реальном времени).
|
||||||
|
|
||||||
|
### ТРЕБОВАНИЯ К ВИЗУАЛУ
|
||||||
|
|
||||||
|
1. **Общая стилистика:** Тёмная тема (#090a0a фон), панели с тонкими рамками (1px, rgba(255,255,255,0.06)), скругление 4px. Цвета акцентов: gold (#b99a62) для положительных значений, красный (#e74c3c) для BREACHED/критических, оранжовый (#f39c12) для предупреждений, зелёный (#2ecc71) для OK. Шрифт данных — IBM Plex Mono, заголовки панелей — Inter caps 9-10px с letter-spacing 0.16em (как микро-лейблы на существующей странице).
|
||||||
|
|
||||||
|
2. **Layout:** CSS Grid, основная раскладка — `grid-template-areas`:
|
||||||
|
- Top bar (1 строка)
|
||||||
|
- Metrics row (4 колонки: 4 big number панели)
|
||||||
|
- Main chart (1 панель на всю ширину)
|
||||||
|
- Bottom row (3 колонки: Comments, Status Timeline, Case Details)
|
||||||
|
- На мобиле (max-width: 768px) — всё в одну колонку
|
||||||
|
|
||||||
|
3. **Навигация:** Header содержит ссылку `← BACK TO TIMER` (ссылается на `BASE_URL`), номер тикета, и живые часы (текущее время, обновляется каждую секунду через JS).
|
||||||
|
|
||||||
|
4. **Панели:**
|
||||||
|
|
||||||
|
a) **Big Number Panels (4 штуки):**
|
||||||
|
- Каждая: верхний лейбл (mono 9px caps), главное число (IBM Plex Mono 32-40px, жирный), подпись (mono 9px, muted)
|
||||||
|
- `TIME ELAPSED` — XXd XXh XXm (рассчитывается из даты создания тикета)
|
||||||
|
- `LAST ACTIVITY` — сколько дней/часов назад было последнее событие
|
||||||
|
- `SLA STATUS` — `BREACHED` (красный) с мигающим индикатором (CSS анимация)
|
||||||
|
- `COMMENTS` — количество комментариев
|
||||||
|
|
||||||
|
b) **Activity Timeline Chart:**
|
||||||
|
- Используй **Chart.js** (подключи через CDN `<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>`)
|
||||||
|
- Line chart, dataset: дни по оси X (от 1 до N), количество событий по Y
|
||||||
|
- Стиль: тонкая линия gold, area fill с gradient (gold → transparent), без grid lines на фоне, axis labels в mono 9px
|
||||||
|
- Заголовок панели: `ACTIVITY TIMELINE` (mono 9px caps, letter-spacing 0.16em, muted)
|
||||||
|
|
||||||
|
c) **Comments Log:**
|
||||||
|
- Стилизован под терминал/лог: моноширинный шрифт, фон #0d0e0e, зелёный текст (#2ecc71) для автора, gold для даты, #e9e4d9 для тела комментария
|
||||||
|
- Каждая строка: `[DD.MM.YYYY HH:MM] AUTHOR: comment text (обрезанный до 2 строк, с "..." )`
|
||||||
|
- Скролл контейнер с фиксированной высотой (300px)
|
||||||
|
- Заголовок: `COMMENTS LOG`
|
||||||
|
|
||||||
|
d) **Status Timeline:**
|
||||||
|
- Вертикальная лента: кружки-маркеры (gold) с линией-стержнем между ними
|
||||||
|
- Каждое событие: дата + описание (e.g., "Issue opened", "Label: bug added", "Comment by user")
|
||||||
|
- Последний маркер — красный, мигающий (текущий статус — без ответа)
|
||||||
|
- Скролл контейнер
|
||||||
|
- Заголовок: `STATUS TIMELINE`
|
||||||
|
|
||||||
|
e) **Case Details:**
|
||||||
|
- Таблица: `KEY | VALUE` в двух колонках
|
||||||
|
- Строки: Created, Updated, Author, Assignee, Labels, Milestone, Repository
|
||||||
|
- Значения в mono, ключи в muted caps
|
||||||
|
- Заголовок: `CASE DETAILS`
|
||||||
|
|
||||||
|
5. **Футер:** `DATA SOURCE: GITHUB API | LAST UPDATED: <timestamp> | AUTO-REFRESH: 60s` — mono 9px, muted, по центру
|
||||||
|
|
||||||
|
### ДАННЫЕ
|
||||||
|
|
||||||
|
- Номер тикета: `4549142`
|
||||||
|
- Owner/reko: `OWNER/REPO` (placeholder — заменить на реальный)
|
||||||
|
- GitHub API endpoint: `https://api.github.com/repos/{owner}/{repo}/issues/{number}` и `/issues/{number}/comments`
|
||||||
|
- Все данные загружаются client-side через `fetch` в `<script>`
|
||||||
|
- Первая загрузка при открытии страницы + обновление раз в 60 секунд
|
||||||
|
- Пока данные грузятся — показывай скелетоны/плейсхолдеры (серые пульсирующие блоки, CSS animation)
|
||||||
|
|
||||||
|
### ФАЙЛЫ ДЛЯ ИЗМЕНЕНИЯ
|
||||||
|
|
||||||
|
1. `src/pages/dashboard.astro` — новая страница (основной файл, HTML + CSS + JS)
|
||||||
|
2. `src/pages/index.astro` — добавить ссылку `DASHBOARD →` в хедер
|
||||||
|
3. `src/styles/global.css` — не трогать (все стили dashboard в `<style>` теге страницы)
|
||||||
|
|
||||||
|
### ВАЖНО
|
||||||
|
|
||||||
|
- Не устанавливай npm-пакеты. Chart.js — через CDN.
|
||||||
|
- Сохрани design tokens проекта (цвета, шрифты)
|
||||||
|
- CSS должен быть в `<style>` теге страницы (Astro-способ), не в global.css
|
||||||
|
- Страница должна graceful degradation: если JS отключён, показать заголовок и статичные данные
|
||||||
|
- Не используй эмодзи
|
||||||
|
- Код должен быть чистым, без комментариев
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
valueId: string;
|
||||||
|
value: string;
|
||||||
|
sub: string;
|
||||||
|
subId?: string;
|
||||||
|
accent: "gold" | "red";
|
||||||
|
stagger: string;
|
||||||
|
danger?: boolean;
|
||||||
|
valueClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, valueId, value, sub, subId, accent, stagger, danger = false, valueClass } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class:list={["panel", "metric-panel", `panel-stagger-${stagger}`]}>
|
||||||
|
<span class:list={["metric-rule", `metric-rule-${accent}`]} aria-hidden="true"></span>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="metric-label">{label}</div>
|
||||||
|
<div class:list={["metric-value", valueClass, danger && "metric-value-danger"]} id={valueId}>{value}</div>
|
||||||
|
<div class="metric-sub" id={subId}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
meta?: string;
|
||||||
|
metaId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, meta, metaId } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">{title}</span>
|
||||||
|
<span class="panel-head-right">
|
||||||
|
{meta && <span class="panel-meta" id={metaId}>{meta}</span>}
|
||||||
|
<span class="panel-menu" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const CREATED = new Date("2026-07-09T20:00:00+05:00");
|
||||||
|
export const AUTHOR = "Ku6epXBOCTuK";
|
||||||
|
|
||||||
|
export interface CaseEvent {
|
||||||
|
at: Date;
|
||||||
|
kind: "system" | "author";
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENTS: CaseEvent[] = [
|
||||||
|
{ at: new Date("2026-07-09T20:00:05+05:00"), kind: "author", label: "TICKET CREATED", text: "Ticket #4549142 submitted." },
|
||||||
|
{ at: new Date("2026-07-09T20:00:05+05:00"), kind: "system", label: "AUTO-ACK SENT", text: "Instant automated confirmation of receipt sent by a bot." },
|
||||||
|
{ at: new Date("2026-09-07T00:54:00+05:00"), kind: "author", label: "CONTACT AGAIN", text: "Wrote to the ticket again today requesting an update." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CREATED_MS = CREATED.getTime();
|
||||||
|
export const LAST_EVENT = EVENTS[EVENTS.length - 1];
|
||||||
|
export const LAST_EVENT_MS = LAST_EVENT.at.getTime();
|
||||||
|
export const LAST_AUTHOR_EVENT = [...EVENTS].reverse().find((e) => e.kind === "author")!;
|
||||||
|
export const LAST_AUTHOR_MS = LAST_AUTHOR_EVENT.at.getTime();
|
||||||
|
|
||||||
|
export function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDate(d: Date): string {
|
||||||
|
return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}.${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtClock(d: Date): string {
|
||||||
|
return d.toLocaleTimeString("en-GB", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtStamp(d: Date): string {
|
||||||
|
return d.toLocaleString("en-GB", { hour12: false, day: "2-digit", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtElapsed(now: number): string {
|
||||||
|
const ms = Math.max(0, now - CREATED_MS);
|
||||||
|
const d = Math.floor(ms / 86400000);
|
||||||
|
const h = Math.floor((ms % 86400000) / 3600000);
|
||||||
|
const m = Math.floor((ms % 3600000) / 60000);
|
||||||
|
return `${pad2(d)}d ${pad2(h)}h ${pad2(m)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDaysHours(now: number, ref: number): string {
|
||||||
|
const ms = Math.max(0, now - ref);
|
||||||
|
const d = Math.floor(ms / 86400000);
|
||||||
|
const h = Math.floor((ms % 86400000) / 3600000);
|
||||||
|
return `${pad2(d)}d ${pad2(h)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysFloat(now: number, ref: number): number {
|
||||||
|
return (now - ref) / 86400000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysCeil(now: number): number {
|
||||||
|
return Math.max(1, Math.ceil((now - CREATED_MS) / 86400000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayIndex(atMs: number): number {
|
||||||
|
return Math.floor((atMs - CREATED_MS) / 86400000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { CREATED_MS, EVENTS, daysCeil, dayIndex, daysFloat } from "../data/caseData";
|
||||||
|
|
||||||
|
const FONT = "IBM Plex Mono";
|
||||||
|
const C = {
|
||||||
|
gold: "#b99a62",
|
||||||
|
red: "#e74c3c",
|
||||||
|
redP90: "#b5342a",
|
||||||
|
redP99: "#8e2f22",
|
||||||
|
track: "#1e1f1c",
|
||||||
|
muted: "#4a4a45",
|
||||||
|
axis: "#5a5952",
|
||||||
|
grid: "rgba(255,255,255,0.05)",
|
||||||
|
border: "rgba(255,255,255,0.08)",
|
||||||
|
legend: "#8b8a83",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartCtor = new (ctx: CanvasRenderingContext2D, config: object) => unknown;
|
||||||
|
|
||||||
|
function getCtor(): ChartCtor | null {
|
||||||
|
const ChartClass = (window as unknown as { Chart?: unknown }).Chart;
|
||||||
|
return ChartClass ? (ChartClass as ChartCtor) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: { destroy(): void }[] = [];
|
||||||
|
|
||||||
|
function dispose(): void {
|
||||||
|
for (const instance of instances) instance.destroy();
|
||||||
|
instances = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltip(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
backgroundColor: "#0d0e0e",
|
||||||
|
borderColor: "rgba(255,255,255,0.12)",
|
||||||
|
borderWidth: 1,
|
||||||
|
bodyColor: "#e9e4d9",
|
||||||
|
titleColor: C.legend,
|
||||||
|
bodyFont: { family: FONT, size: 10 },
|
||||||
|
titleFont: { family: FONT, size: 9 },
|
||||||
|
padding: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRate(canvas: HTMLCanvasElement): void {
|
||||||
|
const ctor = getCtor();
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctor || !ctx) return;
|
||||||
|
instances.push(
|
||||||
|
new ctor(ctx, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: {
|
||||||
|
labels: ["HUMAN REPLIES"],
|
||||||
|
datasets: [{ data: [0, 100], backgroundColor: [C.red, C.track], borderWidth: 0 }],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: "74%",
|
||||||
|
rotation: -90,
|
||||||
|
circumference: 180,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPercentiles(canvas: HTMLCanvasElement, now: number): void {
|
||||||
|
const ctor = getCtor();
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctor || !ctx) return;
|
||||||
|
const wait = Number(daysFloat(now, CREATED_MS).toFixed(1));
|
||||||
|
const datasets = [
|
||||||
|
{ label: "AVG", data: [wait, wait], borderColor: C.gold, borderWidth: 1, borderDash: [10, 6], pointRadius: 0, tension: 0 },
|
||||||
|
{ label: "P50", data: [wait, wait], borderColor: C.red, borderWidth: 1.5, pointRadius: 0, tension: 0 },
|
||||||
|
{ label: "P90", data: [wait, wait], borderColor: C.redP90, borderWidth: 1.5, borderDash: [5, 4], pointRadius: 0, tension: 0 },
|
||||||
|
{ label: "P99", data: [wait, wait], borderColor: C.redP99, borderWidth: 1.5, borderDash: [2, 4], pointRadius: 0, tension: 0 },
|
||||||
|
];
|
||||||
|
instances.push(
|
||||||
|
new ctor(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: { labels: ["START", "TODAY"], datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: "index", intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
|
||||||
|
tooltip: {
|
||||||
|
...tooltip(),
|
||||||
|
callbacks: {
|
||||||
|
label: (item) => ` ${item.dataset.label || ""}: ${wait}d (SINGLE SAMPLE)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: C.axis, font: { family: FONT, size: 9 }, maxRotation: 0 }, grid: { color: C.grid }, border: { color: C.border } },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
|
||||||
|
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
|
||||||
|
grid: { color: C.grid },
|
||||||
|
border: { color: C.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPerDay(canvas: HTMLCanvasElement, now: number): void {
|
||||||
|
const ctor = getCtor();
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctor || !ctx) return;
|
||||||
|
const days = daysCeil(now);
|
||||||
|
const mine: number[] = new Array(days).fill(0);
|
||||||
|
const bot: number[] = new Array(days).fill(0);
|
||||||
|
const human: number[] = new Array(days).fill(0);
|
||||||
|
for (const e of EVENTS) {
|
||||||
|
const idx = dayIndex(e.at.getTime());
|
||||||
|
if (idx < 0 || idx >= days) continue;
|
||||||
|
if (e.kind === "author") mine[idx]++;
|
||||||
|
else bot[idx]++;
|
||||||
|
}
|
||||||
|
const labels = Array.from({ length: days }, (_, i) => `D${i}`);
|
||||||
|
const axis = {
|
||||||
|
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
|
||||||
|
grid: { color: C.grid },
|
||||||
|
border: { color: C.border },
|
||||||
|
};
|
||||||
|
instances.push(
|
||||||
|
new ctor(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: "MY MESSAGES", data: mine, backgroundColor: C.gold, borderColor: C.gold, borderWidth: 1 },
|
||||||
|
{ label: "BOT REACTIONS", data: bot, backgroundColor: C.muted, borderColor: C.muted, borderWidth: 1 },
|
||||||
|
{ label: "HUMAN REPLIES", data: human, backgroundColor: C.red, borderColor: C.red, borderWidth: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: "index", intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
|
||||||
|
tooltip: tooltip(),
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: axis,
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: "REPLIES", color: C.axis, font: { family: FONT, size: 8 } },
|
||||||
|
ticks: { color: C.axis, font: { family: FONT, size: 9 }, precision: 0 },
|
||||||
|
grid: { color: C.grid },
|
||||||
|
border: { color: C.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWait(canvas: HTMLCanvasElement, now: number): void {
|
||||||
|
const ctor = getCtor();
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctor || !ctx) return;
|
||||||
|
const days = daysCeil(now);
|
||||||
|
const data = Array.from({ length: days }, (_, i) => i);
|
||||||
|
const labels = Array.from({ length: days }, (_, i) => `D${i}`);
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, 200);
|
||||||
|
grad.addColorStop(0, "rgba(231,76,60,0.28)");
|
||||||
|
grad.addColorStop(1, "rgba(231,76,60,0)");
|
||||||
|
instances.push(
|
||||||
|
new ctor(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "DAYS WAITED",
|
||||||
|
data,
|
||||||
|
borderColor: C.red,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
backgroundColor: grad,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointBackgroundColor: C.red,
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: "index", intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
|
||||||
|
tooltip: tooltip(),
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
|
||||||
|
grid: { color: C.grid },
|
||||||
|
border: { color: C.border },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
|
||||||
|
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
|
||||||
|
grid: { color: C.grid },
|
||||||
|
border: { color: C.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCharts(now: number): void {
|
||||||
|
dispose();
|
||||||
|
const rate = document.querySelector<HTMLCanvasElement>("#chart-rate");
|
||||||
|
const percentiles = document.querySelector<HTMLCanvasElement>("#chart-percentiles");
|
||||||
|
const perDay = document.querySelector<HTMLCanvasElement>("#chart-per-day");
|
||||||
|
const wait = document.querySelector<HTMLCanvasElement>("#chart-wait");
|
||||||
|
if (rate) buildRate(rate);
|
||||||
|
if (percentiles) buildPercentiles(percentiles, now);
|
||||||
|
if (perDay) buildPerDay(perDay, now);
|
||||||
|
if (wait) buildWait(wait, now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import Panel from "../components/Panel.astro";
|
||||||
|
import MetricPanel from "../components/MetricPanel.astro";
|
||||||
|
|
||||||
|
const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
title="SLA Monitor — Ticket #4549142"
|
||||||
|
description="Real-time SLA monitoring panel for GitHub support ticket #4549142."
|
||||||
|
>
|
||||||
|
<main class="dash-page">
|
||||||
|
<header class="dash-header">
|
||||||
|
<div class="dash-header-left">
|
||||||
|
<a class="back-link" href={`${base}/`}>← BACK TO TIMER</a>
|
||||||
|
<span class="header-sep" aria-hidden="true"></span>
|
||||||
|
<span class="dash-name">SLA MONITOR</span>
|
||||||
|
<span class="ticket-chip">#4549142</span>
|
||||||
|
</div>
|
||||||
|
<div class="dash-header-right">
|
||||||
|
<span class="status-badge"><span class="badge-dot"></span>NO RESPONSE</span>
|
||||||
|
<span class="live-clock" id="live-clock">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="metrics-row" aria-label="Key metrics">
|
||||||
|
<MetricPanel label="TIME ELAPSED" valueId="metric-elapsed" value="0d 00h 00m" sub="SINCE --" subId="metric-since" accent="gold" stagger="1" />
|
||||||
|
<MetricPanel label="LAST ACTIVITY" valueId="metric-last-activity" value="--" sub="EVENT: CONTACT AGAIN" accent="gold" stagger="2" />
|
||||||
|
<MetricPanel label="RESPONSE WAIT" valueId="metric-response-wait" value="0d 00h" sub="LAST REPLY: NONE" accent="red" stagger="3" danger />
|
||||||
|
<MetricPanel label="SUPPORT COMMENTS" valueId="metric-comments" value="0" sub="0 FROM SUPPORT" accent="gold" stagger="4" danger />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="charts-grid" aria-label="Response analytics">
|
||||||
|
<Panel title="RESPONSE RATE" meta="0%">
|
||||||
|
<div class="gauge-wrap">
|
||||||
|
<canvas id="chart-rate" aria-label="Human response rate gauge"></canvas>
|
||||||
|
<div class="gauge-center"><span>0%</span><small>HUMAN REPLIES</small></div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="RESPONSE PERCENTILES" meta="SINGLE SAMPLE">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="chart-percentiles" aria-label="Flat percentiles of time to human response"></canvas>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="RESPONSES PER DAY" meta="SINCE --" metaId="chart-since">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="chart-per-day" aria-label="Replies per day since ticket creation"></canvas>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="CUMULATIVE WAIT" meta="LIVE">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="chart-wait" aria-label="Cumulative days without human response"></canvas>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bottom-row" aria-label="Case details breakdown">
|
||||||
|
<Panel title="COMMENTS LOG">
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="log-list" id="comments-log"></div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="STATUS TIMELINE">
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="timeline-list" id="status-timeline"></div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="CASE DETAILS">
|
||||||
|
<div class="panel-body">
|
||||||
|
<table class="details-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="details-key">TICKET</td><td class="details-value">#4549142</td></tr>
|
||||||
|
<tr><td class="details-key">AUTHOR</td><td class="details-value">Ku6epXBOCTuK</td></tr>
|
||||||
|
<tr><td class="details-key">CONTEXT</td><td class="details-value details-value-gold">FREE / UNSLAVED</td></tr>
|
||||||
|
<tr><td class="details-key">ASSIGNEE</td><td class="details-value">—</td></tr>
|
||||||
|
<tr><td class="details-key">MILESTONE</td><td class="details-value">—</td></tr>
|
||||||
|
<tr><td class="details-key">STATUS</td><td class="details-value details-value-danger">OPEN / UNANSWERED</td></tr>
|
||||||
|
<tr><td class="details-key">CREATED</td><td class="details-value" id="details-created">--</td></tr>
|
||||||
|
<tr><td class="details-key">LAST COMMENT</td><td class="details-value" id="details-last-comment">--</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="dash-footer">
|
||||||
|
<div class="dash-footer-inner">
|
||||||
|
<span>DATA SOURCE: ISSUE TRACKER</span>
|
||||||
|
<span>TICKET #4549142</span>
|
||||||
|
<span>LAST UPDATED: <span id="last-updated">--</span></span>
|
||||||
|
<span>REFRESH: 60S</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script is:inline src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
AUTHOR,
|
||||||
|
EVENTS,
|
||||||
|
CREATED,
|
||||||
|
LAST_AUTHOR_MS,
|
||||||
|
LAST_AUTHOR_EVENT,
|
||||||
|
fmtClock,
|
||||||
|
fmtStamp,
|
||||||
|
fmtDate,
|
||||||
|
fmtElapsed,
|
||||||
|
fmtDaysHours,
|
||||||
|
} from "../data/caseData";
|
||||||
|
import { renderCharts } from "../lib/dashboardCharts";
|
||||||
|
|
||||||
|
function renderLog(): void {
|
||||||
|
const el = document.querySelector<HTMLElement>("#comments-log");
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML =
|
||||||
|
EVENTS.map((e) => {
|
||||||
|
const cls = e.kind === "system" ? "log-author log-author-system" : "log-author";
|
||||||
|
const name = e.kind === "system" ? "SYSTEM" : AUTHOR;
|
||||||
|
return `<div class="log-line"><span class="log-time">[${fmtDate(e.at)}]</span><span class="${cls}">${name}</span><span class="log-text">${e.text}</span></div>`;
|
||||||
|
}).join("") +
|
||||||
|
`<div class="log-line log-line-pending"><span class="log-time">[NOW]</span><span class="log-author log-author-system">SYSTEM</span><span class="log-text">AWAITING RESPONSE — NO HUMAN REPLY RECORDED</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(): void {
|
||||||
|
const el = document.querySelector<HTMLElement>("#status-timeline");
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML =
|
||||||
|
EVENTS.map((e) => {
|
||||||
|
const dot = e.kind === "system" ? "timeline-dot" : "timeline-dot timeline-dot-muted";
|
||||||
|
const name = e.kind === "system" ? "SYSTEM" : AUTHOR;
|
||||||
|
return `<div class="timeline-item"><span class="${dot}"></span><span class="timeline-date">${fmtDate(e.at)}</span><span class="timeline-label">${e.label} <span class="timeline-by">· ${name}</span></span></div>`;
|
||||||
|
}).join("") +
|
||||||
|
`<div class="timeline-item"><span class="timeline-dot timeline-dot-danger"></span><span class="timeline-date">NOW</span><span class="timeline-label timeline-label-danger">AWAITING RESPONSE</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetrics(now: number): void {
|
||||||
|
const elapsed = document.querySelector<HTMLElement>("#metric-elapsed");
|
||||||
|
const lastActivity = document.querySelector<HTMLElement>("#metric-last-activity");
|
||||||
|
const responseWait = document.querySelector<HTMLElement>("#metric-response-wait");
|
||||||
|
if (elapsed) elapsed.textContent = fmtElapsed(now);
|
||||||
|
if (lastActivity) lastActivity.textContent = fmtDaysHours(now, LAST_AUTHOR_MS);
|
||||||
|
if (responseWait) responseWait.textContent = fmtDaysHours(now, LAST_AUTHOR_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(): void {
|
||||||
|
const clock = document.querySelector<HTMLElement>("#live-clock");
|
||||||
|
if (clock) clock.textContent = fmtClock(new Date());
|
||||||
|
updateMetrics(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
const updated = document.querySelector<HTMLElement>("#last-updated");
|
||||||
|
if (updated) updated.textContent = fmtStamp(new Date());
|
||||||
|
renderCharts(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsLastComment = document.querySelector<HTMLElement>("#details-last-comment");
|
||||||
|
if (detailsLastComment) detailsLastComment.textContent = fmtDate(LAST_AUTHOR_EVENT.at);
|
||||||
|
|
||||||
|
const detailsCreated = document.querySelector<HTMLElement>("#details-created");
|
||||||
|
if (detailsCreated) detailsCreated.textContent = fmtDate(CREATED);
|
||||||
|
|
||||||
|
const metricSince = document.querySelector<HTMLElement>("#metric-since");
|
||||||
|
if (metricSince) metricSince.textContent = `SINCE ${fmtDate(CREATED)}`;
|
||||||
|
|
||||||
|
const chartSince = document.querySelector<HTMLElement>("#chart-since");
|
||||||
|
if (chartSince) chartSince.textContent = `SINCE ${fmtDate(CREATED)}`;
|
||||||
|
|
||||||
|
renderLog();
|
||||||
|
renderTimeline();
|
||||||
|
tick();
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
window.setInterval(tick, 1000);
|
||||||
|
window.setInterval(refresh, 60000);
|
||||||
|
</script>
|
||||||
|
</Layout>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
|
||||||
|
const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
||||||
const START_DATE = new Date("2026-07-09T20:00:00+05:00");
|
const START_DATE = new Date("2026-07-09T20:00:00+05:00");
|
||||||
|
|
||||||
function getElapsedTime(): { days: number; hours: number; minutes: number; seconds: number } {
|
function getElapsedTime(): { days: number; hours: number; minutes: number; seconds: number } {
|
||||||
@@ -43,7 +44,10 @@ const startLabel = START_DATE.toLocaleDateString("en-US", {
|
|||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="brand-mark"><span class="brand-dot"></span> GITHUB TICKET ID: 4549142</div>
|
<div class="brand-mark"><span class="brand-dot"></span> GITHUB TICKET ID: 4549142</div>
|
||||||
<div class="header-status"><span class="status-pulse"></span> STATUS: AWAITING RESPONSE</div>
|
<div class="header-actions">
|
||||||
|
<a class="nav-link" href={`${base}/dashboard`}>DASHBOARD →</a>
|
||||||
|
<div class="header-status"><span class="status-pulse"></span> STATUS: AWAITING RESPONSE</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero-content" aria-labelledby="page-title">
|
<section class="hero-content" aria-labelledby="page-title">
|
||||||
|
|||||||
+86
-1
@@ -20,6 +20,9 @@ body { margin: 0; background: var(--background); color: var(--foreground); }
|
|||||||
}
|
}
|
||||||
.clock-page::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at 50% 45%, transparent 22%, rgba(0,0,0,.55) 100%); }
|
.clock-page::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at 50% 45%, transparent 22%, rgba(0,0,0,.55) 100%); }
|
||||||
.site-header, .site-footer { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; color: #77766f; font: 10px var(--font-mono-local); letter-spacing: .19em; }
|
.site-header, .site-footer { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; color: #77766f; font: 10px var(--font-mono-local); letter-spacing: .19em; }
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.nav-link { color: #b1aca1; text-decoration: none; border-bottom: 1px dotted rgba(185,154,98,.5); padding-bottom: 2px; transition: color .2s, border-color .2s; }
|
||||||
|
.nav-link:hover { color: var(--gold); border-bottom-color: var(--gold); }
|
||||||
.brand-mark { display: flex; align-items: center; gap: 9px; color: #b1aca1; }
|
.brand-mark { display: flex; align-items: center; gap: 9px; color: #b1aca1; }
|
||||||
.brand-dot, .status-pulse { width: 5px; height: 5px; display: inline-block; border-radius: 50%; background: var(--gold); box-shadow: 0 0 10px rgba(185,154,98,.6); }
|
.brand-dot, .status-pulse { width: 5px; height: 5px; display: inline-block; border-radius: 50%; background: var(--gold); box-shadow: 0 0 10px rgba(185,154,98,.6); }
|
||||||
.header-status { display: flex; gap: 9px; align-items: center; }
|
.header-status { display: flex; gap: 9px; align-items: center; }
|
||||||
@@ -48,5 +51,87 @@ h1 em { color: var(--gold); font-style: italic; }
|
|||||||
@keyframes flip-top { 0% { transform: rotateX(0); } 100% { transform: rotateX(-90deg); } }
|
@keyframes flip-top { 0% { transform: rotateX(0); } 100% { transform: rotateX(-90deg); } }
|
||||||
@keyframes flip-bottom { 0% { transform: rotateX(90deg); } 100% { transform: rotateX(0); } }
|
@keyframes flip-bottom { 0% { transform: rotateX(90deg); } 100% { transform: rotateX(0); } }
|
||||||
.site-footer { margin-top: auto; padding-top: 22px; color: #65655f; }.footer-rule { flex: 1; height: 1px; margin: 0 22px; background: #292a27; }
|
.site-footer { margin-top: auto; padding-top: 22px; color: #65655f; }.footer-rule { flex: 1; height: 1px; margin: 0 22px; background: #292a27; }
|
||||||
@media (max-width: 640px) { .clock-page { padding: 20px 14px 18px; }.site-header { font-size: 8px; }.header-status { max-width: 150px; text-align: right; line-height: 1.6; }.subtitle { margin-bottom: 30px; font-size: 11px; }.clock-frame-top, .clock-frame-bottom { font-size: 7px; letter-spacing: .1em; }.clock-frame-bottom { margin-top: 16px; }.clock-display { gap: 4px; }.unit-divider { font-size: 23px; }.unit-label { font-size: 7px; letter-spacing: .12em; margin-top: 9px; }.flip-digit { width: clamp(35px, 17vw, 64px); height: clamp(50px, 24vw, 88px); font-size: clamp(39px, 20vw, 70px); } }
|
@media (max-width: 640px) { .clock-page { padding: 20px 14px 18px; }.site-header { font-size: 8px; }.header-actions { gap: 10px; }.nav-link { font-size: 8px; }.header-status { max-width: 150px; text-align: right; line-height: 1.6; }.subtitle { margin-bottom: 30px; font-size: 11px; }.clock-frame-top, .clock-frame-bottom { font-size: 7px; letter-spacing: .1em; }.clock-frame-bottom { margin-top: 16px; }.clock-display { gap: 4px; }.unit-divider { font-size: 23px; }.unit-label { font-size: 7px; letter-spacing: .12em; margin-top: 9px; }.flip-digit { width: clamp(35px, 17vw, 64px); height: clamp(50px, 24vw, 88px); font-size: clamp(39px, 20vw, 70px); } }
|
||||||
@media (prefers-reduced-motion: reduce) { .is-flipping .digit-flap { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .is-flipping .digit-flap { animation: none; } }
|
||||||
|
|
||||||
|
/* dashboard: JS-injected markup (no data-astro-cid attribute, must be unscoped) */
|
||||||
|
.chart-fallback { display: flex; align-items: center; justify-content: center; height: 100%; font: 9px var(--font-mono-local); letter-spacing: .18em; color: #5a5952; }
|
||||||
|
.log-line { display: flex; gap: 10px; padding: 5px 2px; border-bottom: 1px dashed rgba(255,255,255,.04); }
|
||||||
|
.log-line:last-child { border-bottom: none; }
|
||||||
|
.log-line-pending { border-top: 1px dashed rgba(231,76,60,.35); }
|
||||||
|
.log-time { color: #9b8357; white-space: nowrap; }
|
||||||
|
.log-author { color: #2ecc71; white-space: nowrap; }
|
||||||
|
.log-author-system { color: var(--gold); }
|
||||||
|
.log-text { color: #c9c4b9; white-space: normal; }
|
||||||
|
.timeline-item { position: relative; padding: 5px 0 16px 22px; }
|
||||||
|
.timeline-item::before { content: ''; position: absolute; left: 4px; top: 14px; bottom: 2px; width: 1px; background: rgba(255,255,255,.09); }
|
||||||
|
.timeline-item:last-child::before { display: none; }
|
||||||
|
.timeline-dot { position: absolute; left: 0; top: 10px; width: 9px; height: 9px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 6px rgba(185,154,98,.4); }
|
||||||
|
.timeline-dot-muted { background: #3a3a36; box-shadow: none; }
|
||||||
|
.timeline-dot-danger { background: #e74c3c; box-shadow: 0 0 8px rgba(231,76,60,.5); animation: pulse 1.6s ease-in-out infinite; }
|
||||||
|
.timeline-date { display: block; font: 10px/1.6 var(--font-mono-local); letter-spacing: .04em; color: #9b8357; margin-bottom: 5px; }
|
||||||
|
.timeline-label { display: block; font: 10px/1.7 var(--font-mono-local); letter-spacing: .02em; color: #d8d3c8; word-break: break-word; }
|
||||||
|
.timeline-by { color: #55554f; }
|
||||||
|
.timeline-label-danger { color: #ff6a5b; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .timeline-dot-danger { animation: none; } }
|
||||||
|
|
||||||
|
/* ===== dashboard ===== */
|
||||||
|
.dash-page { position: relative; min-height: 100svh; padding: 20px clamp(14px, 3vw, 44px) 18px; background-color: #090a0a; background-image: linear-gradient(135deg, rgba(255,255,255,.018) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.018) 50%, rgba(255,255,255,.018) 75%, transparent 75%); background-size: 9px 9px; overflow-x: hidden; }
|
||||||
|
.dash-page::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at 50% 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,.6) 100%); }
|
||||||
|
.dash-header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding-bottom: 14px; border-bottom: 1px solid rgba(185,154,98,.35); margin-bottom: 16px; }
|
||||||
|
.dash-header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.back-link { color: #b1aca1; font: 10px var(--font-mono-local); letter-spacing: .18em; text-decoration: none; white-space: nowrap; transition: color .2s; }
|
||||||
|
.back-link:hover { color: var(--gold); }
|
||||||
|
.header-sep { width: 1px; height: 14px; background: rgba(255,255,255,.15); }
|
||||||
|
.dash-name { font: 600 10px var(--font-mono-local); letter-spacing: .22em; color: #cfcabc; white-space: nowrap; }
|
||||||
|
.ticket-chip { font: 10px var(--font-mono-local); letter-spacing: .12em; color: var(--gold); border: 1px solid rgba(185,154,98,.4); border-radius: 3px; padding: 3px 7px; white-space: nowrap; }
|
||||||
|
.dash-header-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.status-badge { display: inline-flex; align-items: center; gap: 7px; font: 9px var(--font-mono-local); letter-spacing: .18em; padding: 5px 10px; border-radius: 3px; border: 1px solid rgba(243,156,18,.45); color: #f5b043; background: rgba(243,156,18,.07); white-space: nowrap; }
|
||||||
|
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #f39c12; animation: pulse 1.6s ease-in-out infinite; }
|
||||||
|
.live-clock { font: 500 11px var(--font-mono-local); letter-spacing: .14em; color: #b1aca1; white-space: nowrap; }
|
||||||
|
.metrics-row { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }
|
||||||
|
.panel { position: relative; z-index: 1; background: rgba(255,255,255,.012); border: 1px solid rgba(255,255,255,.06); border-radius: 3px; overflow: hidden; animation: panel-in .5s ease both; }
|
||||||
|
.panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.05); background: rgba(255,255,255,.012); }
|
||||||
|
.panel-title { font: 600 9px var(--font-mono-local); letter-spacing: .2em; color: #8b8a83; }
|
||||||
|
.panel-head-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.panel-meta { font: 8.5px var(--font-mono-local); letter-spacing: .12em; color: #4c4c47; }
|
||||||
|
.panel-menu { display: inline-flex; gap: 3px; }
|
||||||
|
.panel-menu i { width: 3px; height: 3px; border-radius: 50%; background: #4a4a45; display: inline-block; }
|
||||||
|
.panel-body { padding: 14px; }
|
||||||
|
.metric-panel { min-width: 0; }
|
||||||
|
.metric-panel .panel-body { padding: 16px 14px 14px; }
|
||||||
|
.metric-rule { position: absolute; top: 0; left: 0; right: 0; height: 2px; }
|
||||||
|
.metric-rule-gold { background: linear-gradient(90deg, rgba(185,154,98,.9), rgba(185,154,98,.15)); }
|
||||||
|
.metric-rule-red { background: linear-gradient(90deg, rgba(231,76,60,.9), rgba(231,76,60,.15)); }
|
||||||
|
.metric-label { font: 600 9px var(--font-mono-local); letter-spacing: .18em; color: #8b8a83; margin-bottom: 10px; }
|
||||||
|
.metric-value { font: 600 clamp(22px, 2.6vw, 34px)/1.1 var(--font-mono-local); color: #f2ede2; letter-spacing: -.03em; white-space: nowrap; }
|
||||||
|
.metric-value-danger { color: #ff6a5b; }
|
||||||
|
.metric-sub { margin-top: 8px; font: 8.5px var(--font-mono-local); letter-spacing: .1em; color: #5a5952; }
|
||||||
|
.charts-grid { position: relative; z-index: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.chart-wrap { position: relative; height: 200px; padding: 6px 4px 2px; }
|
||||||
|
.gauge-wrap { position: relative; height: 170px; }
|
||||||
|
.gauge-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||||||
|
.gauge-center span { font: 600 34px/1 var(--font-mono-local); color: #ff6a5b; }
|
||||||
|
.gauge-center small { margin-top: 6px; font: 8.5px var(--font-mono-local); letter-spacing: .16em; color: #5a5952; }
|
||||||
|
.bottom-row { display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 12px; }
|
||||||
|
.bottom-row .panel { display: flex; flex-direction: column; }
|
||||||
|
.log-list { font: 10px/1.7 var(--font-mono-local); max-height: 280px; overflow-y: auto; }
|
||||||
|
.timeline-list { font: 10px/1.7 var(--font-mono-local); max-height: 280px; overflow-y: auto; padding: 4px 6px 4px 8px; }
|
||||||
|
.details-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.details-table td { padding: 7px 4px; border-bottom: 1px dashed rgba(255,255,255,.05); font-size: 10px; vertical-align: top; font-family: var(--font-mono-local); }
|
||||||
|
.details-table tr:last-child td { border-bottom: none; }
|
||||||
|
.details-key { color: #5a5952; letter-spacing: .14em; font-size: 8.5px; width: 38%; white-space: nowrap; }
|
||||||
|
.details-value { color: #ddd8cd; word-break: break-word; }
|
||||||
|
.details-value-gold { color: var(--gold); }
|
||||||
|
.details-value-danger { color: #ff6a5b; }
|
||||||
|
.dash-footer { position: relative; z-index: 1; margin-top: 16px; padding-top: 14px; border-top: 1px solid #292a27; }
|
||||||
|
.dash-footer-inner { display: flex; flex-wrap: wrap; gap: 10px 18px; justify-content: center; font: 9px var(--font-mono-local); letter-spacing: .14em; color: #55554f; }
|
||||||
|
@keyframes panel-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||||
|
.panel-stagger-1 { animation-delay: .04s; }
|
||||||
|
.panel-stagger-2 { animation-delay: .1s; }
|
||||||
|
.panel-stagger-3 { animation-delay: .16s; }
|
||||||
|
.panel-stagger-4 { animation-delay: .22s; }
|
||||||
|
@media (max-width: 880px) { .metrics-row { grid-template-columns: repeat(2, 1fr); } .charts-grid { grid-template-columns: 1fr; } .bottom-row { grid-template-columns: 1fr; } .log-list, .timeline-list { max-height: 200px; } }
|
||||||
|
@media (max-width: 520px) { .metrics-row { grid-template-columns: 1fr; } .dash-header { flex-direction: column; align-items: flex-start; gap: 10px; } .dash-header-left { flex-wrap: wrap; gap: 8px; } .dash-header-right { width: 100%; justify-content: space-between; } .chart-wrap { height: 170px; } .panel-head-right .panel-meta { display: none; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .panel, .badge-dot, .timeline-dot-danger { animation: none; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user