From 2e9b4787b4dbcf5b3456d9114fdf14c06bd03701 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 16:32:25 +0300 Subject: [PATCH 01/10] feat(dashboard): add /dash page with sync history and proxy route - server/routes/dash-api/[...].ts: nitro proxy to backend /api/dash/* - NUXT_DASH_API_BASE_URL private runtimeConfig (default localhost:8080) - routeRules: /dash and /dash-api/** excluded from robots/sitemap, ssr:false - pages/dash.vue: status cards, runs table, per-run group breakdown - polls every 5s while running, 60s idle - UiBadge variants for all statuses - without-navbar layout --- .env.example | 5 + app/pages/dash.vue | 350 ++++++++++++++++++++++++++++++++ nuxt.config.ts | 7 + server/routes/dash-api/[...].ts | 21 ++ 4 files changed, 383 insertions(+) create mode 100644 app/pages/dash.vue create mode 100644 server/routes/dash-api/[...].ts diff --git a/.env.example b/.env.example index d9df30a..a16ad8c 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ NUXT_OG_IMAGE_SECRET= # Get it from: GA4 property → Admin → Data Streams → Web stream → Measurement ID. # Leave empty to disable analytics entirely (no script loaded, no consent banner). NUXT_PUBLIC_GA_MEASUREMENT_ID= + +# Backend URL for the /dash-api proxy (server-side only, never sent to browser). +# In production, point this to the internal API address (e.g. http://schedule-api:8080). +# Default: http://localhost:8080 +NUXT_DASH_API_BASE_URL= diff --git a/app/pages/dash.vue b/app/pages/dash.vue new file mode 100644 index 0000000..22d0bb6 --- /dev/null +++ b/app/pages/dash.vue @@ -0,0 +1,350 @@ + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 8e09318..19146e4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -35,6 +35,9 @@ export default defineNuxtConfig({ ], }, runtimeConfig: { + // Backend URL for the dashboard proxy — server-side only, never exposed to client. + // Override via NUXT_DASH_API_BASE_URL env var. + dashApiBaseUrl: "http://localhost:8080", public: { maintenance: false, // Bumped on every build — used to bust the IndexedDB query cache on deploy @@ -131,6 +134,10 @@ export default defineNuxtConfig({ // check the SW byte-for-byte on every page load, but a stale cached response // would prevent the browser from seeing an updated worker. "/sw.js": { headers: { "cache-control": "no-cache, no-store, must-revalidate" } }, + // Dashboard — internal ops page, no robots, no SSR, no caching. + "/dash": { robots: false, sitemap: false, ssr: false }, + // Proxy route for dashboard API — same auth boundary as /dash. + "/dash-api/**": { robots: false }, }, robots: { sitemap: "/sitemap.xml", diff --git a/server/routes/dash-api/[...].ts b/server/routes/dash-api/[...].ts new file mode 100644 index 0000000..17b537f --- /dev/null +++ b/server/routes/dash-api/[...].ts @@ -0,0 +1,21 @@ +/** + * Server-side proxy for the internal dashboard API. + * + * Requests to /dash-api/* are forwarded to the backend API server at + * NUXT_DASH_API_BASE_URL/api/dash/*. The base URL stays server-side only — + * the browser never sees it, which is what lets Caddy guard /dash-api/* with + * basic_auth at the edge without exposing the backend origin. + * + * Default base URL points to the same host the schedule backend runs on in + * local dev (http://localhost:8080). Override via NUXT_DASH_API_BASE_URL env. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const base = (config.dashApiBaseUrl as string | undefined) || "http://localhost:8080" + + // Strip /dash-api prefix, keep the rest (e.g. /summary, /runs, /runs/123/groups) + const path = event.path.replace(/^\/dash-api/, "") || "/" + const target = `${base.replace(/\/$/, "")}/api/dash${path}` + + return proxyRequest(event, target) +}) From 653e5bf7390113ca82bc46e9ab725f601b00d811 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:06:42 +0300 Subject: [PATCH 02/10] fix(dashboard): guard runs and selectedRunGroups against null api response --- app/pages/dash.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 22d0bb6..86282df 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -80,7 +80,7 @@ async function fetchSummary() { async function fetchRuns() { try { const res = await $fetch>("/dash-api/runs?limit=30") - runs.value = res.data + runs.value = res.data ?? [] } catch (e) { error.value = e instanceof Error ? e.message : String(e) } @@ -90,7 +90,7 @@ async function fetchGroups(runId: number) { loadingGroups.value = true try { const res = await $fetch>(`/dash-api/runs/${runId}/groups`) - selectedRunGroups.value = res.data + selectedRunGroups.value = res.data ?? [] selectedRunId.value = runId } catch (e) { error.value = e instanceof Error ? e.message : String(e) From bd65b0960075c42a12a9709af469ae37164a0bbf Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:07:47 +0300 Subject: [PATCH 03/10] fix(dashboard): replace useSeo with useHead to avoid defineOgImage in client-only context --- app/pages/dash.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 86282df..98157e7 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -3,7 +3,8 @@ definePageMeta({ layout: "without-navbar", }) -useSeo({ title: "Dashboard", noindex: true }) +useHead({ title: "Dashboard" }) +useServerSeoMeta({ robots: "noindex, nofollow" }) // ── Types ──────────────────────────────────────────────────────────────────── From 8a4e72f85504b9112f2311359ab9797b6cd47ad7 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:09:41 +0300 Subject: [PATCH 04/10] fix(dashboard): swap UiTableHead/UiTableHeader usage to fix stacked headers --- app/pages/dash.vue | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 98157e7..8d85b88 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -234,19 +234,19 @@ function duration(start: string, end: string | null): string {

Останні запуски

- + - ID - Статус - Тригер - Початок - Тривалість - Групи (провал) - Видалено подій - Кроки - + ID + Статус + Тригер + Початок + Тривалість + Групи (провал) + Видалено подій + Кроки + - + Немає даних - + - Група ID - Статус - Подій - Час - Помилка + Група ID + Статус + Подій + Час + Помилка - + Немає даних From a342961af29279255750d1893b34cdfb06bfa4c5 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:19:58 +0300 Subject: [PATCH 05/10] feat(dashboard): totalEvents, events delta, failures feed, table sizes, spike badge --- app/pages/dash.vue | 148 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 8d85b88..637806a 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -29,6 +29,7 @@ interface SyncRun { totalGroups: number failedGroups: number removedEvents: number + totalEvents: number steps: SyncSteps } @@ -37,10 +38,25 @@ interface SyncRunGroup { groupId: number status: "success" | "failed" eventsCount: number + prevEventsCount: number | null error: string | null finishedAt: string } +interface FailedGroupEntry { + runId: number + groupId: number + error: string | null + finishedAt: string +} + +interface TableSizeEntry { + tableName: string + rowCount: number + sizePretty: string + sizeBytes: number +} + interface Summary { currentStatus: "running" | "success" | "partial" | "failed" | "unknown" isRunning: boolean @@ -61,9 +77,13 @@ interface ApiResponse { const POLL_RUNNING = 5_000 const POLL_IDLE = 60_000 +// Spike: flag removedEvents when it exceeds this fraction of total events +const REMOVED_SPIKE_RATIO = 0.05 const summary = ref(null) const runs = ref([]) +const failures = ref([]) +const tableSizes = ref([]) const selectedRunId = ref(null) const selectedRunGroups = ref([]) const loadingGroups = ref(false) @@ -100,9 +120,26 @@ async function fetchGroups(runId: number) { } } +async function fetchFailures() { + try { + const res = await $fetch>("/dash-api/failures?limit=50") + failures.value = res.data ?? [] + } catch (e) { + error.value = e instanceof Error ? e.message : String(e) + } +} + +async function fetchTableSizes() { + try { + const res = await $fetch>("/dash-api/table-sizes") + tableSizes.value = res.data ?? [] + } catch (e) { + error.value = e instanceof Error ? e.message : String(e) + } +} + async function refresh() { - await Promise.all([fetchSummary(), fetchRuns()]) - // refresh groups panel if a run is selected + await Promise.all([fetchSummary(), fetchRuns(), fetchFailures(), fetchTableSizes()]) if (selectedRunId.value !== null) { await fetchGroups(selectedRunId.value) } @@ -171,6 +208,25 @@ function duration(start: string, end: string | null): string { if (m > 0) return `${m}хв ${s % 60}с` return `${s}с` } + +function deltaLabel(curr: number, prev: number | null): string { + if (prev === null) return "" + const diff = curr - prev + if (diff === 0) return "" + return diff > 0 ? `+${diff}` : String(diff) +} + +function deltaClass(curr: number, prev: number | null): string { + if (prev === null) return "" + const diff = curr - prev + if (diff === 0) return "" + return diff > 0 ? "text-green-600 dark:text-green-400" : "text-destructive" +} + +function isRemovedSpike(run: SyncRun): boolean { + if (run.totalEvents === 0) return false + return run.removedEvents / run.totalEvents > REMOVED_SPIKE_RATIO +} From 785868962f8d27dd1203ca1740c7e9da251dcc66 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:40:01 +0300 Subject: [PATCH 06/10] feat(dashboard): add Basic Auth middleware and inject x-dash-key header --- .env.example | 8 +++++ nuxt.config.ts | 7 +++++ server/middleware/dash-auth.ts | 54 +++++++++++++++++++++++++++++++++ server/routes/dash-api/[...].ts | 5 ++- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 server/middleware/dash-auth.ts diff --git a/.env.example b/.env.example index a16ad8c..f5d6216 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,11 @@ NUXT_PUBLIC_GA_MEASUREMENT_ID= # In production, point this to the internal API address (e.g. http://schedule-api:8080). # Default: http://localhost:8080 NUXT_DASH_API_BASE_URL= + +# Shared secret sent as x-dash-key to the backend. Must match DASH_API_KEY in schedule-api. +# Generate with: openssl rand -hex 32 +NUXT_DASH_API_KEY=change_me_to_a_strong_random_secret + +# HTTP Basic Auth credentials for the /dash page (browser prompt). +NUXT_DASH_USER=admin +NUXT_DASH_PASSWORD=change_me diff --git a/nuxt.config.ts b/nuxt.config.ts index 19146e4..637f663 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -38,6 +38,13 @@ export default defineNuxtConfig({ // Backend URL for the dashboard proxy — server-side only, never exposed to client. // Override via NUXT_DASH_API_BASE_URL env var. dashApiBaseUrl: "http://localhost:8080", + // Shared secret forwarded to the backend as x-dash-key. + // Override via NUXT_DASH_API_KEY env var. + dashApiKey: "change_me_to_a_strong_random_secret", + // HTTP Basic Auth for the /dash page — browser prompt. + // Override via NUXT_DASH_USER / NUXT_DASH_PASSWORD env vars. + dashUser: "admin", + dashPassword: "change_me", public: { maintenance: false, // Bumped on every build — used to bust the IndexedDB query cache on deploy diff --git a/server/middleware/dash-auth.ts b/server/middleware/dash-auth.ts new file mode 100644 index 0000000..0f0c16c --- /dev/null +++ b/server/middleware/dash-auth.ts @@ -0,0 +1,54 @@ +import { createHash, timingSafeEqual } from "node:crypto" + +const hash = (s: string) => createHash("sha256").update(s).digest() + +/** + * HTTP Basic Auth for /dash and /dash-api/* routes. + * + * Browser sends credentials as "Basic base64(user:pass)". + * We hash both sides before comparing so the check is timing-safe. + * + * Fails closed: if NUXT_DASH_USER or NUXT_DASH_PASSWORD is not set, + * returns 503 on /dash* instead of serving an unprotected dashboard. + */ +export default defineEventHandler((event) => { + if (!event.path.startsWith("/dash")) return + + const config = useRuntimeConfig() + const expectedUser = config.dashUser as string | undefined + const expectedPass = config.dashPassword as string | undefined + + if (!expectedUser || !expectedPass) { + setResponseStatus(event, 503) + return "Dashboard auth not configured" + } + + const authHeader = getRequestHeader(event, "authorization") ?? "" + const [scheme, encoded] = authHeader.split(" ") + + if (scheme?.toLowerCase() !== "basic" || !encoded) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } + + const decoded = Buffer.from(encoded, "base64").toString("utf8") + const colonIdx = decoded.indexOf(":") + if (colonIdx === -1) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } + + const providedUser = decoded.slice(0, colonIdx) + const providedPass = decoded.slice(colonIdx + 1) + + const userOk = timingSafeEqual(hash(providedUser), hash(expectedUser)) + const passOk = timingSafeEqual(hash(providedPass), hash(expectedPass)) + + if (!userOk || !passOk) { + setResponseStatus(event, 401) + setResponseHeader(event, "WWW-Authenticate", 'Basic realm="Schedule Dashboard"') + return "Unauthorized" + } +}) diff --git a/server/routes/dash-api/[...].ts b/server/routes/dash-api/[...].ts index 17b537f..40f0a7f 100644 --- a/server/routes/dash-api/[...].ts +++ b/server/routes/dash-api/[...].ts @@ -12,10 +12,13 @@ export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const base = (config.dashApiBaseUrl as string | undefined) || "http://localhost:8080" + const apiKey = (config.dashApiKey as string | undefined) || "" // Strip /dash-api prefix, keep the rest (e.g. /summary, /runs, /runs/123/groups) const path = event.path.replace(/^\/dash-api/, "") || "/" const target = `${base.replace(/\/$/, "")}/api/dash${path}` - return proxyRequest(event, target) + return proxyRequest(event, target, { + headers: { "x-dash-key": apiKey }, + }) }) From ae1fccb25c08fa10d9c3a2c503c168af6263c82a Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 17:52:11 +0300 Subject: [PATCH 07/10] fix(dashboard): full-width tables, step tooltips, responsive cards --- app/layouts/dash.vue | 9 +++++ app/pages/dash.vue | 86 ++++++++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 app/layouts/dash.vue diff --git a/app/layouts/dash.vue b/app/layouts/dash.vue new file mode 100644 index 0000000..312873a --- /dev/null +++ b/app/layouts/dash.vue @@ -0,0 +1,9 @@ + diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 637806a..ec093de 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -1,6 +1,6 @@ From a657f2c01eb57127c53802f6f1f17bf18bd8be47 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Tue, 1 Sep 2026 21:02:54 +0300 Subject: [PATCH 09/10] fix(dashboard): hide low-value columns on mobile for readable table --- app/pages/dash.vue | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/app/pages/dash.vue b/app/pages/dash.vue index 671a6df..3b6be4c 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -304,16 +304,18 @@ function stepTitle(name: string, step: StepResult): string { - ID - Статус - Тригер - Початок - Тривалість - Групи (провал) - Подій всього - Видалено - Кроки - + ID + Статус + + Початок + Тривалість + Групи (провал) + + + + @@ -331,7 +333,9 @@ function stepTitle(name: string, step: StepResult): string { {{ run.status }} - {{ run.trigger }} + {{ fmt(run.startedAt) }} {{ duration(run.startedAt, run.finishedAt) }} @@ -342,8 +346,10 @@ function stepTitle(name: string, step: StepResult): string { ({{ run.failedGroups }} ✗) - {{ run.totalEvents }} - + + - + - + {{ run.trigger }} {{ fmt(run.startedAt) }} {{ duration(run.startedAt, run.finishedAt) }} @@ -346,10 +342,8 @@ function stepTitle(name: string, step: StepResult): string { ({{ run.failedGroups }} ✗) - -