Compare commits
11
Commits
43148ec32f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1481160c | ||
|
|
56359afa2d | ||
|
|
bf97127b3d | ||
|
|
1698306717 | ||
|
|
fc1308e27e | ||
|
|
cf467d748d | ||
|
|
a397e0342c | ||
|
|
a583c79e08 | ||
|
|
70b857300f | ||
|
|
7918eb9aa8 | ||
|
|
08cf095b83 |
@@ -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/"
|
||||
@@ -8,3 +8,5 @@ pnpm-debug.log*
|
||||
.DS_Store
|
||||
.env
|
||||
.env.production
|
||||
|
||||
.vercel
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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
@@ -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 || '/'),
|
||||
})
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}/`}>← BACK TO TIMER</a>
|
||||
<span class="header-sep" aria-hidden="true"></span>
|
||||
<span class="dash-name">GITHUB SUPPORT 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>
|
||||
|
||||
<aside class="dash-note">
|
||||
DATA NOTE — 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">—</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,
|
||||
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 — 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, 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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user