From 7c924f9610a277e15a9252cd742c8029dbe46952 Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Wed, 2 Sep 2026 16:13:03 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(dash):=20redesign=20dashboard=20?= =?UTF-8?q?=E2=80=94=20tabs,=20accordion=20runs,=20TanStack=20Query,=20Ukr?= =?UTF-8?q?ainian=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace monolithic 501-line dash.vue with thin page + 5 dash/* components - Migrate all data fetching to TanStack Query (dashSummaryOptions, dashRunsOptions, dashRunGroupsOptions, dashFailuresOptions, dashTableSizesOptions) with gcTime:0 so ops data is never persisted to IndexedDB; polling via refetchInterval replaces recursive setTimeout - New IA: Hero status card with live pulse dot + progress bar (running only), metrics strip (runs/failures/events), UiTabs splitting runs/failures/db - Runs tab: status filter (UiSelect), accordion list (DashRunItem) with duration sparkbar, step timeline (✓/✗ Аудиторії/Групи/Викладачі), lazy group loading on expand — failed groups expanded by default, success groups collapsed with search input; errors as full monospace blocks with copy - Failures tab: always rendered, AppEmptyState when empty, full error blocks with per-entry copy button; groupName field added to SyncRunGroup and FailedGroupEntry (nullable, falls back to '#id') - DB tab: table sizes sorted by bytes with relative size bar - composables/useDash.ts: fmt, fmtRelative, duration, durationMs, deltaLabel, deltaClass, isRemovedSpike, groupLabel, statusVariant, STATUS_LABELS, TRIGGER_LABELS, STEP_LABELS — all Ukrainian - Add UiCard and UiProgress components (npx ui-thing@latest add card progress) - Fixes: error banner never reset (gone), horizontal scroll (no min-w tables), title= tooltips on touch (gone, replaced by expanded content), truncated errors (full blocks), raw English enums (Ukrainian labels) --- app/components/Ui/Card/Action.vue | 31 ++ app/components/Ui/Card/Card.vue | 58 +++ app/components/Ui/Card/Content.vue | 34 ++ app/components/Ui/Card/Description.vue | 36 ++ app/components/Ui/Card/Footer.vue | 31 ++ app/components/Ui/Card/Header.vue | 31 ++ app/components/Ui/Card/Title.vue | 37 ++ app/components/Ui/Progress/Indicator.vue | 27 + app/components/Ui/Progress/Progress.vue | 41 ++ app/components/dash/Failures.vue | 100 ++++ app/components/dash/Hero.vue | 112 ++++ app/components/dash/Metrics.vue | 69 +++ app/components/dash/RunItem.vue | 310 +++++++++++ app/components/dash/TableSizes.vue | 87 ++++ app/composables/useDash.ts | 116 +++++ app/pages/dash.vue | 623 ++++++----------------- app/queries/dash.ts | 136 +++++ 17 files changed, 1408 insertions(+), 471 deletions(-) create mode 100644 app/components/Ui/Card/Action.vue create mode 100644 app/components/Ui/Card/Card.vue create mode 100644 app/components/Ui/Card/Content.vue create mode 100644 app/components/Ui/Card/Description.vue create mode 100644 app/components/Ui/Card/Footer.vue create mode 100644 app/components/Ui/Card/Header.vue create mode 100644 app/components/Ui/Card/Title.vue create mode 100644 app/components/Ui/Progress/Indicator.vue create mode 100644 app/components/Ui/Progress/Progress.vue create mode 100644 app/components/dash/Failures.vue create mode 100644 app/components/dash/Hero.vue create mode 100644 app/components/dash/Metrics.vue create mode 100644 app/components/dash/RunItem.vue create mode 100644 app/components/dash/TableSizes.vue create mode 100644 app/composables/useDash.ts create mode 100644 app/queries/dash.ts diff --git a/app/components/Ui/Card/Action.vue b/app/components/Ui/Card/Action.vue new file mode 100644 index 0000000..a0d5c29 --- /dev/null +++ b/app/components/Ui/Card/Action.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/components/Ui/Card/Card.vue b/app/components/Ui/Card/Card.vue new file mode 100644 index 0000000..58f6b26 --- /dev/null +++ b/app/components/Ui/Card/Card.vue @@ -0,0 +1,58 @@ + + + diff --git a/app/components/Ui/Card/Content.vue b/app/components/Ui/Card/Content.vue new file mode 100644 index 0000000..222c8fe --- /dev/null +++ b/app/components/Ui/Card/Content.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/components/Ui/Card/Description.vue b/app/components/Ui/Card/Description.vue new file mode 100644 index 0000000..7ead06e --- /dev/null +++ b/app/components/Ui/Card/Description.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/components/Ui/Card/Footer.vue b/app/components/Ui/Card/Footer.vue new file mode 100644 index 0000000..282f509 --- /dev/null +++ b/app/components/Ui/Card/Footer.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/components/Ui/Card/Header.vue b/app/components/Ui/Card/Header.vue new file mode 100644 index 0000000..e883e5a --- /dev/null +++ b/app/components/Ui/Card/Header.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/components/Ui/Card/Title.vue b/app/components/Ui/Card/Title.vue new file mode 100644 index 0000000..ee8ac47 --- /dev/null +++ b/app/components/Ui/Card/Title.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/components/Ui/Progress/Indicator.vue b/app/components/Ui/Progress/Indicator.vue new file mode 100644 index 0000000..fe6813b --- /dev/null +++ b/app/components/Ui/Progress/Indicator.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/components/Ui/Progress/Progress.vue b/app/components/Ui/Progress/Progress.vue new file mode 100644 index 0000000..67f16da --- /dev/null +++ b/app/components/Ui/Progress/Progress.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/components/dash/Failures.vue b/app/components/dash/Failures.vue new file mode 100644 index 0000000..737843b --- /dev/null +++ b/app/components/dash/Failures.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/components/dash/Hero.vue b/app/components/dash/Hero.vue new file mode 100644 index 0000000..3f76644 --- /dev/null +++ b/app/components/dash/Hero.vue @@ -0,0 +1,112 @@ + + + diff --git a/app/components/dash/Metrics.vue b/app/components/dash/Metrics.vue new file mode 100644 index 0000000..19f39df --- /dev/null +++ b/app/components/dash/Metrics.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/components/dash/RunItem.vue b/app/components/dash/RunItem.vue new file mode 100644 index 0000000..825a3cb --- /dev/null +++ b/app/components/dash/RunItem.vue @@ -0,0 +1,310 @@ + + + diff --git a/app/components/dash/TableSizes.vue b/app/components/dash/TableSizes.vue new file mode 100644 index 0000000..2ae0af4 --- /dev/null +++ b/app/components/dash/TableSizes.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/composables/useDash.ts b/app/composables/useDash.ts new file mode 100644 index 0000000..32f02a8 --- /dev/null +++ b/app/composables/useDash.ts @@ -0,0 +1,116 @@ +import type { SyncRun, SyncRunGroup, DashSummary } from "~/queries/dash" + +// ── Labels ──────────────────────────────────────────────────────────────────── + +export const STATUS_LABELS: Record = { + running: "Виконується", + success: "Успіх", + partial: "Частковий", + failed: "Помилка", + unknown: "Невідомо", +} + +export const TRIGGER_LABELS: Record = { + cron: "Розклад", + bootstrap: "Запуск", +} + +export const STEP_LABELS: Record = { + auditoriums: "Аудиторії", + groups: "Групи", + teachers: "Викладачі", +} + +// ── Status variant ───────────────────────────────────────────────────────────── + +export function statusVariant( + status: SyncRun["status"] | DashSummary["currentStatus"] +): "success" | "warning" | "destructive" | "secondary" | "info" { + switch (status) { + case "success": + return "success" + case "partial": + return "warning" + case "failed": + return "destructive" + case "running": + return "info" + default: + return "secondary" + } +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +export function fmt(iso: string | null | undefined): string { + if (!iso) return "—" + return new Date(iso).toLocaleString("uk-UA", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +export function fmtRelative(iso: string | null | undefined): string { + if (!iso) return "—" + const ms = Date.now() - new Date(iso).getTime() + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + const d = Math.floor(h / 24) + if (d > 0) return `${d} дн тому` + if (h > 0) return `${h} год тому` + if (m > 0) return `${m} хв тому` + return `${s}с тому` +} + +export function duration(start: string, end: string | null): string { + if (!end) return "—" + const ms = new Date(end).getTime() - new Date(start).getTime() + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + const h = Math.floor(m / 60) + if (h > 0) return `${h}г ${m % 60}хв` + if (m > 0) return `${m}хв ${s % 60}с` + return `${s}с` +} + +/** Duration in ms for sparkbar scaling */ +export function durationMs(start: string, end: string | null): number { + if (!end) return 0 + return new Date(end).getTime() - new Date(start).getTime() +} + +// ── Events delta ─────────────────────────────────────────────────────────────── + +export 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) +} + +export function deltaClass(curr: number, prev: number | null): string { + if (prev === null) return "text-muted-foreground" + const diff = curr - prev + if (diff === 0) return "text-muted-foreground" + return diff > 0 ? "text-green-600 dark:text-green-400" : "text-destructive" +} + +// ── Spike detection ─────────────────────────────────────────────────────────── + +const REMOVED_SPIKE_RATIO = 0.05 + +export function isRemovedSpike(run: SyncRun): boolean { + if (run.totalEvents === 0) return false + return run.removedEvents / run.totalEvents > REMOVED_SPIKE_RATIO +} + +// ── Group label ─────────────────────────────────────────────────────────────── + +export function groupLabel(g: Pick): string { + return g.groupName ?? `#${g.groupId}` +} diff --git a/app/pages/dash.vue b/app/pages/dash.vue index ab5e326..4392950 100644 --- a/app/pages/dash.vue +++ b/app/pages/dash.vue @@ -1,501 +1,182 @@ diff --git a/app/queries/dash.ts b/app/queries/dash.ts new file mode 100644 index 0000000..729ebec --- /dev/null +++ b/app/queries/dash.ts @@ -0,0 +1,136 @@ +import { queryOptions } from "@tanstack/vue-query" +import type { MaybeRef } from "vue" + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface StepResult { + ok: boolean + count: number + error?: string +} + +export interface SyncSteps { + auditoriums: StepResult + groups: StepResult + teachers: StepResult +} + +export interface SyncRun { + id: number + startedAt: string + finishedAt: string | null + status: "running" | "success" | "partial" | "failed" + trigger: "cron" | "bootstrap" + totalGroups: number + failedGroups: number + removedEvents: number + totalEvents: number + steps: SyncSteps +} + +export interface SyncRunGroup { + runId: number + groupId: number + groupName: string | null + status: "success" | "failed" + eventsCount: number + prevEventsCount: number | null + error: string | null + finishedAt: string +} + +export interface FailedGroupEntry { + runId: number + groupId: number + groupName: string | null + error: string | null + finishedAt: string +} + +export interface TableSizeEntry { + tableName: string + rowCount: number + sizePretty: string + sizeBytes: number +} + +export interface DashSummary { + currentStatus: "running" | "success" | "partial" | "failed" | "unknown" + isRunning: boolean + lastRun: SyncRun | null + lastSuccessfulRun: SyncRun | null + nextCronAt: string | null + totalRuns: number + progress: { current: number; total: number } | null +} + +interface ApiResponse { + success: boolean + data: T + error: null | { code: string; message: string } +} + +// ── Factories ───────────────────────────────────────────────────────────────── + +const BASE_OPTIONS = { + // Ops data — never persist to IndexedDB + gcTime: 0, + staleTime: 0, +} as const + +export function dashSummaryOptions(isRunning: MaybeRef) { + return queryOptions({ + ...BASE_OPTIONS, + queryKey: ["dash", "summary"] as const, + queryFn: () => $fetch>("/dash-api/summary").then((r) => r.data), + refetchInterval: () => (toValue(isRunning) ? 5_000 : 60_000), + }) +} + +export function dashRunsOptions(limit = 30) { + return queryOptions({ + ...BASE_OPTIONS, + queryKey: ["dash", "runs", limit] as const, + queryFn: () => + $fetch>(`/dash-api/runs?limit=${limit}`).then( + (r) => r.data ?? [] + ), + refetchInterval: 60_000, + }) +} + +export function dashRunGroupsOptions(runId: MaybeRef) { + return queryOptions({ + ...BASE_OPTIONS, + queryKey: computed(() => ["dash", "run-groups", toValue(runId)]), + queryFn: () => + $fetch>(`/dash-api/runs/${toValue(runId)}/groups`).then( + (r) => r.data ?? [] + ), + enabled: computed(() => toValue(runId) !== null), + }) +} + +export function dashFailuresOptions(limit = 50) { + return queryOptions({ + ...BASE_OPTIONS, + queryKey: ["dash", "failures", limit] as const, + queryFn: () => + $fetch>(`/dash-api/failures?limit=${limit}`).then( + (r) => r.data ?? [] + ), + refetchInterval: 60_000, + }) +} + +export function dashTableSizesOptions() { + return queryOptions({ + ...BASE_OPTIONS, + queryKey: ["dash", "table-sizes"] as const, + queryFn: () => + $fetch>("/dash-api/table-sizes").then( + (r) => r.data ?? [] + ), + refetchInterval: 5 * 60_000, + }) +} From e8c381edd67021cec6596ca0b2136506f0b190ab Mon Sep 17 00:00:00 2001 From: Roman Trashutin Date: Wed, 2 Sep 2026 16:23:03 +0300 Subject: [PATCH 2/5] fix(dash): register Dash components, fix tabs layout, add error state --- app/components/dash/Hero.vue | 10 ++++++++++ app/pages/dash.vue | 13 +++++++------ nuxt.config.ts | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/components/dash/Hero.vue b/app/components/dash/Hero.vue index 3f76644..1a367b7 100644 --- a/app/components/dash/Hero.vue +++ b/app/components/dash/Hero.vue @@ -6,6 +6,7 @@ defineProps<{ summary: DashSummary | undefined isPending: boolean isFetching: boolean + isError: boolean onRefresh: () => void }>() @@ -65,6 +66,15 @@ defineProps<{ + + + + Помилка завантаження + + Не вдалося отримати дані з сервера. Спробуйте оновити. + + + + +
+
+ +
+

Прогонів

+ +

+ {{ summary?.totalRuns ?? "—" }} +

+
+ +
+

Провалів груп

+ +

+ {{ failuresCount }} +

+
+ +
+

Подій

+ +

+ {{ summary?.lastRun?.totalEvents?.toLocaleString("uk-UA") ?? "—" }} +

+
+
+
diff --git a/app/components/dash/Metrics.vue b/app/components/dash/Metrics.vue deleted file mode 100644 index 19f39df..0000000 --- a/app/components/dash/Metrics.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/app/components/dash/RunItem.vue b/app/components/dash/RunItem.vue index 825a3cb..144bad4 100644 --- a/app/components/dash/RunItem.vue +++ b/app/components/dash/RunItem.vue @@ -6,6 +6,7 @@ import { TRIGGER_LABELS, STEP_LABELS, fmt, + fmtShort, duration, durationMs, deltaLabel, @@ -86,14 +87,14 @@ const { copy, copied } = useClipboard({ legacy: true }) - - - #{{ run.id }} + + + {{ fmtShort(run.startedAt) }}