Compare commits

...
11 Commits
16 changed files with 1638 additions and 84 deletions
-34
View File
@@ -1,34 +0,0 @@
name: Deploy Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest # метка твоего runner'а
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
run: |
npm ci
npm run build
- name: Deploy to /var/www/pages
run: |
if ! command -v rsync &> /dev/null; then
sudo apt-get update && sudo apt-get install -y rsync
fi
TARGET="/var/www/pages/${{ gitea.repository }}"
mkdir -p "$TARGET"
rsync -a --delete dist/ "$TARGET/"
+2
View File
@@ -8,3 +8,5 @@ pnpm-debug.log*
.DS_Store
.env
.env.production
.vercel
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ku6epXBOCTuK
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+137
View File
@@ -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 отключён, показать заголовок и статичные данные
- Не используй эмодзи
- Код должен быть чистым, без комментариев
+44
View File
@@ -0,0 +1,44 @@
# Waiting for GitHub Support — Elapsed Time
Флип-часы, которые считают, сколько времени прошло с момента открытия тикета в поддержке GitHub — без ответа, без прогресса, без SLA.
Проект показывает «SLA от GitHub» на основе реальных данных: стартовая точка зафиксирована как абсолютный момент времени (`2026-07-09T20:00:00+05:00` — тикет открыт 9 июля 2026, 20:00 по UTC+5), и счётчик честно тикает дальше каждую секунду.
Ответа всё нет. Часы работают.
## Что на странице
- Аналоговый флип-таймер в духе вокзальных табло: каждая цифра переворачивается отдельной створкой
- Тёмная «кейсовая» эстетика: CASE → GITHUB TICKET, статус `AWAITING RESPONSE`, подпись `SLA: NOT FOUND`
- Адаптив под мобильные, поддержка `prefers-reduced-motion`
## Стек
- [Astro](https://astro.build) — единственная зависимость
- Статичный HTML + обычный CSS + ~60 строк vanilla TS для флип-анимации
- Шрифты Inter / Cormorant Garamond / IBM Plex Mono через Google Fonts (`<link>`, без npm-пакетов)
## Запуск локально
```bash
npm install
npm run dev # дев-сервер на http://localhost:4321
npm run build # сборка статики в dist/
npm run preview # предпросмотр собранного
```
## Деплой
Автоматический через Gitea Actions: пуш в `main` → раннер собирает проект и rsync'ает `dist/` в `/var/www/pages/<owner>/<repo>` (см. `.gitea/workflows/deploy.yaml`). Отдаётся nginx'ом как статика, HTTPS — certbot.
## Поменять под себя
Всё редактируется в одном файле — `src/pages/index.astro`:
- **Стартовая дата** — константа `START_DATE` и `START_TIME` (обязательно с офсетом таймзоны, например `+05:00`, иначе момент будет зависеть от часового пояса посетителя)
- **Номер тикета** — строка `GITHUB TICKET / #…` в шапке
- **Подписи** — `SLA: NOT FOUND`, `NO RESOLUTION YET`, тексты футера
---
_An exercise in patience. The clock keeps moving._
+7 -1
View File
@@ -1,4 +1,10 @@
// @ts-check
import { loadEnv } from 'vite'
import { defineConfig } from 'astro/config'
export default defineConfig({})
const mode = process.argv.includes('build') ? 'production' : 'development'
const env = loadEnv(mode, process.cwd(), '')
export default defineConfig({
base: process.env.VERCEL ? '/' : (env.PUBLIC_BASE_PATH || '/'),
})
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "github-tracker",
"name": "github-support-sla-monitor",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-tracker",
"name": "github-support-sla-monitor",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "github-tracker",
"name": "github-support-sla-monitor",
"version": "0.1.0",
"description": "Flip-clock tracker of time elapsed since a GitHub support ticket",
"description": "GitHub support ticket sla monitor dashboard and flip-clock timer",
"scripts": {
"dev": "astro dev",
"build": "astro build",
+24
View File
@@ -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>
+20
View File
@@ -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>
+64
View File
@@ -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);
}
+4 -1
View File
@@ -7,6 +7,9 @@ interface Props {
}
const { title, description } = Astro.props
const base = import.meta.env.BASE_URL.replace(/\/+$/, '')
const faviconHref = `${base}/favicon.svg`
---
<!doctype html>
@@ -17,7 +20,7 @@ const { title, description } = Astro.props
<meta name="description" content={description} />
<meta name="theme-color" content="#090a0a" />
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href={faviconHref} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
+252
View File
@@ -0,0 +1,252 @@
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) => ChartLike;
interface ChartLike {
data: { labels?: unknown[]; datasets: { data: number[] }[] };
update(mode?: string): void;
}
function getCtor(): ChartCtor | null {
const ChartClass = (window as unknown as { Chart?: unknown }).Chart;
return ChartClass ? (ChartClass as ChartCtor) : null;
}
let percentiles: ChartLike | null = null;
let perDay: ChartLike | null = null;
let wait: ChartLike | null = null;
let percentilesWait = 0;
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;
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): ChartLike | null {
const ctor = getCtor();
const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return null;
const waitVal = Number(daysFloat(now, CREATED_MS).toFixed(1));
percentilesWait = waitVal;
const datasets = [
{ label: "AVG", data: [waitVal, waitVal], borderColor: C.gold, borderWidth: 1, borderDash: [10, 6], pointRadius: 0, tension: 0 },
{ label: "P50", data: [waitVal, waitVal], borderColor: C.red, borderWidth: 1.5, pointRadius: 0, tension: 0 },
{ label: "P90", data: [waitVal, waitVal], borderColor: C.redP90, borderWidth: 1.5, borderDash: [5, 4], pointRadius: 0, tension: 0 },
{ label: "P99", data: [waitVal, waitVal], borderColor: C.redP99, borderWidth: 1.5, borderDash: [2, 4], pointRadius: 0, tension: 0 },
];
return 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 || ""}: ${percentilesWait}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): ChartLike | null {
const ctor = getCtor();
const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return null;
const axis = {
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
grid: { color: C.grid },
border: { color: C.border },
};
return new ctor(ctx, {
type: "bar",
data: {
labels: [],
datasets: [
{ label: "MY MESSAGES", data: [], backgroundColor: C.gold, borderColor: C.gold, borderWidth: 1 },
{ label: "BOT REACTIONS", data: [], backgroundColor: C.muted, borderColor: C.muted, borderWidth: 1 },
{ label: "HUMAN REPLIES", data: [], 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): ChartLike | null {
const ctor = getCtor();
const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return null;
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)");
return 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 },
},
},
},
});
}
function updatePercentiles(chart: ChartLike, now: number): void {
const waitVal = Number(daysFloat(now, CREATED_MS).toFixed(1));
percentilesWait = waitVal;
for (const ds of chart.data.datasets) ds.data = [waitVal, waitVal];
chart.update();
}
function updatePerDay(chart: ChartLike, now: number): void {
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}`);
chart.data.labels = labels;
for (let i = 0; i < chart.data.datasets.length; i++) {
chart.data.datasets[i].data = [mine, bot, human][i];
}
chart.update();
}
function updateWait(chart: ChartLike, now: number): void {
const days = daysCeil(now);
chart.data.labels = Array.from({ length: days }, (_, i) => `D${i}`);
chart.data.datasets[0].data = Array.from({ length: days }, (_, i) => i);
chart.update();
}
export function initCharts(): void {
const rate = document.querySelector<HTMLCanvasElement>("#chart-rate");
const p = document.querySelector<HTMLCanvasElement>("#chart-percentiles");
const d = document.querySelector<HTMLCanvasElement>("#chart-per-day");
const w = document.querySelector<HTMLCanvasElement>("#chart-wait");
if (rate) buildRate(rate);
percentiles = p ? buildPercentiles(p, Date.now()) : null;
perDay = d ? buildPerDay(d) : null;
wait = w ? buildWait(w) : null;
updateCharts(Date.now());
}
export function updateCharts(now: number): void {
if (percentiles) updatePercentiles(percentiles, now);
if (perDay) updatePerDay(perDay, now);
if (wait) updateWait(wait, now);
}
+195
View File
@@ -0,0 +1,195 @@
---
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="GitHub Support SLA Monitor — Ticket #4549142"
description="Live SLA monitoring dashboard for GitHub support ticket #4549142. Real data, zero human responses."
>
<main class="dash-page">
<header class="dash-header">
<div class="dash-header-left">
<a class="back-link" href={`${base}/`}>&larr; BACK TO TIMER</a>
<span class="header-sep" aria-hidden="true"></span>
<span class="dash-name">GITHUB&nbsp;SUPPORT&nbsp;SLA&nbsp;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>
<aside class="dash-note">
DATA NOTE &mdash; ALL VALUES ARE REAL: <b>1</b> SUPPORT TICKET, VERIFIED MANUALLY BY THE AUTHOR. WITH A SINGLE
CASE THERE IS NO SPREAD, SO PERCENTILES ARE FLAT AND THE RESPONSE RATE READS <b>0%</b>. THE FLATNESS OF THE
CHARTS IS EXPECTED, NOT A BUG.
</aside>
<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">&mdash;</td></tr>
<tr><td class="details-key">MILESTONE</td><td class="details-value">&mdash;</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,
CREATED_MS,
LAST_AUTHOR_MS,
LAST_AUTHOR_EVENT,
fmtClock,
fmtStamp,
fmtDate,
fmtElapsed,
fmtDaysHours,
} from "../data/caseData";
import { initCharts, updateCharts } 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 &mdash; 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">&middot; ${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, CREATED_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());
updateCharts(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();
const lastUpdated = document.querySelector<HTMLElement>("#last-updated");
if (lastUpdated) lastUpdated.textContent = fmtStamp(new Date());
initCharts();
window.setInterval(tick, 1000);
window.setInterval(refresh, 60000);
</script>
</Layout>
+7 -3
View File
@@ -1,6 +1,7 @@
---
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");
function getElapsedTime(): { days: number; hours: number; minutes: number; seconds: number } {
@@ -34,8 +35,8 @@ const startLabel = START_DATE.toLocaleDateString("en-US", {
---
<Layout
title="Waiting for GitHub Support — Elapsed Time"
description="A live record of time elapsed since a GitHub support ticket was opened."
title="GitHub Support SLA Monitor — Waiting for Support"
description="A live record of time elapsed since a GitHub support ticket was opened. No SLA, no promise, no response."
>
<main class="clock-page">
<div class="ambient-line ambient-line-left"></div>
@@ -43,7 +44,10 @@ const startLabel = START_DATE.toLocaleDateString("en-US", {
<header class="site-header">
<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>
<section class="hero-content" aria-labelledby="page-title">
+857 -41
View File
@@ -3,50 +3,866 @@
--background: #090a0a;
--foreground: #e9e4d9;
--gold: #b99a62;
--font-inter: 'Inter', sans-serif;
--font-cormorant: 'Cormorant Garamond', Georgia, serif;
--font-mono-local: 'IBM Plex Mono', monospace;
--font-inter: "Inter", sans-serif;
--font-cormorant: "Cormorant Garamond", Georgia, serif;
--font-mono-local: "IBM Plex Mono", monospace;
}
* { box-sizing: border-box; }
html { background: var(--background); }
body { margin: 0; background: var(--background); color: var(--foreground); }
* {
box-sizing: border-box;
}
html {
background: var(--background);
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
}
.clock-page {
position: relative; min-height: 100svh; overflow: hidden; display: flex; flex-direction: column;
padding: 28px clamp(20px, 5vw, 80px) 24px; 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%);
position: relative;
min-height: 100svh;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 28px clamp(20px, 5vw, 80px) 24px;
background-color: #090a0a;
background-image: linear-gradient(
135deg,
rgba(255, 255, 255, 0.018) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.018) 50%,
rgba(255, 255, 255, 0.018) 75%,
transparent 75%
);
background-size: 9px 9px;
}
.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; }
.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); }
.header-status { display: flex; gap: 9px; align-items: center; }
.ambient-line { position: absolute; z-index: 0; top: 47%; width: 17vw; height: 1px; background: linear-gradient(90deg, transparent, rgba(185,154,98,.25)); }
.ambient-line-left { left: 0; }.ambient-line-right { right: 0; transform: rotate(180deg); }
.hero-content { position: relative; z-index: 1; width: min(1360px, 100%); margin: auto; text-align: center; }
.eyebrow { display: flex; align-items: center; justify-content: center; gap: 14px; color: var(--gold); font: 10px var(--font-mono-local); letter-spacing: .28em; }
.eyebrow span { width: 32px; height: 1px; background: rgba(185,154,98,.5); }
h1 { margin: 22px 0 10px; color: #e8e1d3; font: 400 clamp(42px, 6.2vw, 92px)/.88 var(--font-cormorant), Georgia, serif; letter-spacing: -.035em; }
h1 em { color: var(--gold); font-style: italic; }
.subtitle { margin: 0 auto 44px; color: #88877f; font: 12px/1.5 var(--font-inter), sans-serif; letter-spacing: .05em; }
.clock-frame { border-top: 1px solid rgba(185,154,98,.5); border-bottom: 1px solid rgba(185,154,98,.5); padding: 17px 0 16px; }
.clock-frame-top, .clock-frame-bottom { display: flex; justify-content: space-between; color: #77766f; font: 9px var(--font-mono-local); letter-spacing: .16em; }
.clock-frame-bottom { margin-top: 22px; color: #9b8357; }
.clock-display { display: flex; align-items: center; justify-content: center; gap: clamp(7px, 1vw, 18px); margin-top: 13px; }
.clock-unit { min-width: 0; flex: 1; }.unit-digits { display: flex; gap: clamp(4px, .7vw, 11px); justify-content: center; }.unit-label { display: block; margin-top: 13px; color: #aba79c; font: 9px var(--font-mono-local); letter-spacing: .22em; text-transform: uppercase; }
.unit-divider { margin-top: -16px; color: #a98d5a; font: 600 clamp(28px, 4vw, 72px)/1 var(--font-mono-local); opacity: .8; }
.flip-digit { position: relative; display: block; width: clamp(38px, 7vw, 118px); height: clamp(54px, 11vw, 158px); perspective: 600px; color: #eee9dd; font: 500 clamp(43px, 8.8vw, 126px)/1 var(--font-mono-local); letter-spacing: -.14em; text-indent: -.12em; text-align: center; filter: drop-shadow(0 15px 15px rgba(0,0,0,.5)); }
.digit-face, .digit-flap { position: absolute; inset: 0; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid #393934; text-shadow: 0 2px 2px #000; }
.digit-top { clip-path: inset(0 0 50% 0); border-radius: 4px; background: linear-gradient(145deg, #343633, #171918 48%, #252724); }.digit-bottom { clip-path: inset(50% 0 0 0); border-radius: 4px; background: linear-gradient(145deg, #282b29, #111313 60%, #202220); }
.digit-face::after, .digit-flap::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(115deg, transparent 0 3px, rgba(255,255,255,.018) 3px 4px); pointer-events: none; }
.digit-seam { position: absolute; top: calc(50% - 1px); left: 0; right: 0; height: 2px; background: #0b0c0c; box-shadow: 0 1px 2px rgba(255,255,255,.13); z-index: 3; }
.digit-notch { position: absolute; z-index: 5; top: calc(50% - 4px); width: 7px; height: 8px; background: #090a0a; border-radius: 50%; }.digit-notch-left { left: -4px; }.digit-notch-right { right: -4px; }
.digit-flap { z-index: 4; backface-visibility: hidden; transform-style: preserve-3d; }.digit-flap-top { clip-path: inset(0 0 50% 0); transform-origin: 50% 50%; border-radius: 4px; background: linear-gradient(145deg, #343633, #171918 48%, #252724); }.digit-flap-bottom { clip-path: inset(50% 0 0 0); transform-origin: 50% 50%; border-radius: 4px; background: linear-gradient(145deg, #282b29, #111313 60%, #202220); transform: rotateX(90deg); }
.is-flipping .digit-flap-top { animation: flip-top .42s cubic-bezier(.37,.01,.2,1) forwards; }.is-flipping .digit-flap-bottom { animation: flip-bottom .42s cubic-bezier(.37,.01,.2,1) .2s forwards; }
@keyframes flip-top { 0% { transform: rotateX(0); } 100% { transform: rotateX(-90deg); } }
@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; }
@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 (prefers-reduced-motion: reduce) { .is-flipping .digit-flap { animation: none; } }
.clock-page::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse at 50% 45%, transparent 22%, rgba(0, 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: 0.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, 0.5);
padding-bottom: 2px;
transition:
color 0.2s,
border-color 0.2s;
}
.nav-link:hover {
color: var(--gold);
border-bottom-color: var(--gold);
}
.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, 0.6);
}
.header-status {
display: flex;
gap: 9px;
align-items: center;
}
.ambient-line {
position: absolute;
z-index: 0;
top: 47%;
width: 17vw;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(185, 154, 98, 0.25));
}
.ambient-line-left {
left: 0;
}
.ambient-line-right {
right: 0;
transform: rotate(180deg);
}
.hero-content {
position: relative;
z-index: 1;
width: min(1360px, 100%);
margin: auto;
text-align: center;
}
.eyebrow {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--gold);
font: 10px var(--font-mono-local);
letter-spacing: 0.28em;
}
.eyebrow span {
width: 32px;
height: 1px;
background: rgba(185, 154, 98, 0.5);
}
h1 {
margin: 22px 0 10px;
color: #e8e1d3;
font:
400 clamp(42px, 6.2vw, 92px)/0.88 var(--font-cormorant),
Georgia,
serif;
letter-spacing: -0.035em;
}
h1 em {
color: var(--gold);
font-style: italic;
}
.subtitle {
margin: 0 auto 44px;
color: #88877f;
font:
12px/1.5 var(--font-inter),
sans-serif;
letter-spacing: 0.05em;
}
.clock-frame {
border-top: 1px solid rgba(185, 154, 98, 0.5);
border-bottom: 1px solid rgba(185, 154, 98, 0.5);
padding: 17px 0 16px;
}
.clock-frame-top,
.clock-frame-bottom {
display: flex;
justify-content: space-between;
color: #77766f;
font: 9px var(--font-mono-local);
letter-spacing: 0.16em;
}
.clock-frame-bottom {
margin-top: 22px;
color: #9b8357;
}
.clock-display {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(7px, 1vw, 18px);
margin-top: 13px;
}
.clock-unit {
min-width: 0;
flex: 1;
}
.unit-digits {
display: flex;
gap: clamp(4px, 0.7vw, 11px);
justify-content: center;
}
.unit-label {
display: block;
margin-top: 13px;
color: #aba79c;
font: 9px var(--font-mono-local);
letter-spacing: 0.22em;
text-transform: uppercase;
}
.unit-divider {
margin-top: -16px;
color: #a98d5a;
font: 600 clamp(28px, 4vw, 72px)/1 var(--font-mono-local);
opacity: 0.8;
}
.flip-digit {
position: relative;
display: block;
width: clamp(38px, 7vw, 118px);
height: clamp(54px, 11vw, 158px);
perspective: 600px;
color: #eee9dd;
font: 500 clamp(43px, 8.8vw, 126px)/1 var(--font-mono-local);
letter-spacing: -0.14em;
text-indent: -0.12em;
text-align: center;
filter: drop-shadow(0 15px 15px rgba(0, 0, 0, 0.5));
}
.digit-face,
.digit-flap {
position: absolute;
inset: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid #393934;
text-shadow: 0 2px 2px #000;
}
.digit-top {
clip-path: inset(0 0 50% 0);
border-radius: 4px;
background: linear-gradient(145deg, #343633, #171918 48%, #252724);
}
.digit-bottom {
clip-path: inset(50% 0 0 0);
border-radius: 4px;
background: linear-gradient(145deg, #282b29, #111313 60%, #202220);
}
.digit-face::after,
.digit-flap::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(115deg, transparent 0 3px, rgba(255, 255, 255, 0.018) 3px 4px);
pointer-events: none;
}
.digit-seam {
position: absolute;
top: calc(50% - 1px);
left: 0;
right: 0;
height: 2px;
background: #0b0c0c;
box-shadow: 0 1px 2px rgba(255, 255, 255, 0.13);
z-index: 3;
}
.digit-notch {
position: absolute;
z-index: 5;
top: calc(50% - 4px);
width: 7px;
height: 8px;
background: #090a0a;
border-radius: 50%;
}
.digit-notch-left {
left: -4px;
}
.digit-notch-right {
right: -4px;
}
.digit-flap {
z-index: 4;
backface-visibility: hidden;
transform-style: preserve-3d;
}
.digit-flap-top {
clip-path: inset(0 0 50% 0);
transform-origin: 50% 50%;
border-radius: 4px;
background: linear-gradient(145deg, #343633, #171918 48%, #252724);
}
.digit-flap-bottom {
clip-path: inset(50% 0 0 0);
transform-origin: 50% 50%;
border-radius: 4px;
background: linear-gradient(145deg, #282b29, #111313 60%, #202220);
transform: rotateX(90deg);
}
.is-flipping .digit-flap-top {
animation: flip-top 0.42s cubic-bezier(0.37, 0.01, 0.2, 1) forwards;
}
.is-flipping .digit-flap-bottom {
animation: flip-bottom 0.42s cubic-bezier(0.37, 0.01, 0.2, 1) 0.2s forwards;
}
@keyframes flip-top {
0% {
transform: rotateX(0);
}
100% {
transform: rotateX(-90deg);
}
}
@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;
}
@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: 0.1em;
}
.clock-frame-bottom {
margin-top: 16px;
}
.clock-display {
gap: 4px;
}
.unit-divider {
font-size: 23px;
}
.unit-label {
font-size: 7px;
letter-spacing: 0.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;
}
}
/* 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: 0.18em;
color: #5a5952;
}
.log-line {
display: flex;
gap: 10px;
padding: 5px 2px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.04);
}
.log-line:last-child {
border-bottom: none;
}
.log-line-pending {
border-top: 1px dashed rgba(231, 76, 60, 0.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, 0.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, 0.4);
}
.timeline-dot-muted {
background: #3a3a36;
box-shadow: none;
}
.timeline-dot-danger {
background: #e74c3c;
box-shadow: 0 0 8px rgba(231, 76, 60, 0.5);
animation: pulse 1.6s ease-in-out infinite;
}
.timeline-date {
display: block;
font: 10px/1.6 var(--font-mono-local);
letter-spacing: 0.04em;
color: #9b8357;
margin-bottom: 5px;
}
.timeline-label {
display: block;
font: 10px/1.7 var(--font-mono-local);
letter-spacing: 0.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, 0.01) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.018) 50%,
rgba(255, 255, 255, 0.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, 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, 0.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: 0.18em;
text-decoration: none;
white-space: nowrap;
transition: color 0.2s;
}
.back-link:hover {
color: var(--gold);
}
.header-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.15);
}
.dash-name {
font: 600 10px var(--font-mono-local);
letter-spacing: 0.22em;
color: #cfcabc;
white-space: nowrap;
}
.ticket-chip {
font: 10px var(--font-mono-local);
letter-spacing: 0.12em;
color: var(--gold);
border: 1px solid rgba(185, 154, 98, 0.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: 0.18em;
padding: 5px 10px;
border-radius: 3px;
border: 1px solid rgba(243, 156, 18, 0.45);
color: #f5b043;
background: rgba(243, 156, 18, 0.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: 0.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(12, 13, 13, 0.75);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
animation: panel-in 0.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, 0.05);
background: rgba(255, 255, 255, 0.025);
}
.panel-title {
font: 600 9px var(--font-mono-local);
letter-spacing: 0.2em;
color: #8b8a83;
}
.panel-head-right {
display: flex;
align-items: center;
gap: 12px;
}
.panel-meta {
font: 8.5px var(--font-mono-local);
letter-spacing: 0.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, 0.9), rgba(185, 154, 98, 0.15));
}
.metric-rule-red {
background: linear-gradient(90deg, rgba(231, 76, 60, 0.9), rgba(231, 76, 60, 0.15));
}
.metric-label {
font: 600 9px var(--font-mono-local);
letter-spacing: 0.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: -0.03em;
white-space: nowrap;
}
.metric-value-danger {
color: #ff6a5b;
}
.metric-sub {
margin-top: 8px;
font: 8.5px var(--font-mono-local);
letter-spacing: 0.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;
padding-top: 20px;
height: 170px;
}
.gauge-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
transform: translateY(20px);
}
.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: 0.16em;
color: #5a5952;
}
.dash-note {
position: relative;
z-index: 1;
margin: 0 0 12px;
padding: 10px 14px;
font: 9px/1.7 var(--font-mono-local);
letter-spacing: 0.12em;
color: #8b8a83;
background: rgba(185, 154, 98, 0.045);
border: 1px solid rgba(185, 154, 98, 0.22);
border-left: 3px solid rgba(185, 154, 98, 0.7);
border-radius: 3px;
}
.dash-note b {
color: #e9e4d9;
font-weight: 600;
}
.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, 0.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: 0.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: 0.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: 0.35;
}
}
.panel-stagger-1 {
animation-delay: 0.04s;
}
.panel-stagger-2 {
animation-delay: 0.1s;
}
.panel-stagger-3 {
animation-delay: 0.16s;
}
.panel-stagger-4 {
animation-delay: 0.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;
}
}