feat(frontend): add DashboardHub components, EmptyState, and validation scaffold

- DashboardHubModel.svelte.ts: Screen Model for dashboard listing hub
  with filters, pagination, and selection state.
- DashboardRow.svelte: Individual dashboard card with Git/health badges.
- ColumnFilterPopover.svelte: Column-based filter dropdown for dashboards.
- dashboard-helpers.ts: Shared dashboard utility functions.
- EmptyState.svelte: Reusable empty state component with icon and CTA.
- validation route scaffold for per-dashboard validation views.
This commit is contained in:
2026-06-02 09:55:39 +03:00
parent 8219540ade
commit a95f070441
6 changed files with 1993 additions and 0 deletions

View File

@@ -0,0 +1,877 @@
# Frontend Design Audit: ss-tools
> Дата: 2026-06-01
> Цель: Аудит фронтенд кода на предмет "плавающего" дизайна, выработка AI-native правил
> Объём: ~120 файлов, 8 UI-атомов, 55+ компонентов, 9 моделей, 9 сторов, все страницы, tailwind.config.js
---
## Содержание
1. [Методология](#1-методология)
2. [Архитектурный ландшафт](#2-архитектурный-ландшафт)
3. [Найденные проблемы](#3-найденные-проблемы)
- 3.1 [Критические](#31-критические)
- 3.2 [Средние](#32-средние)
- 3.3 [Малые](#33-малые)
4. [Причины плавающего дизайна](#4-причины-плавающего-дизайна)
5. [AI-Native Design Rules](#5-ai-native-design-rules)
6. [Приоритетный план исправлений](#6-приоритетный-план-исправлений)
7. [Приложение: Карта файлов](#7-приложение-карта-файлов)
---
## 1. Методология
### Сканированные области
```
frontend/
├── tailwind.config.js ✅ конфиг темы
├── src/
│ ├── app.css ✅ 3 строки (tailwind directives)
│ ├── routes/ ✅ все 35+ страниц
│ ├── lib/
│ │ ├── ui/ ✅ 8 атомарных компонентов
│ │ ├── components/ ✅ 30+ доменных компонентов
│ │ ├── stores/ ✅ 9 сторов (8 legacy + 1 runes)
│ │ ├── models/ ✅ 9 моделей
│ │ ├── api/ ✅ клиентский слой
│ │ └── utils/ ✅ утилиты
│ └── components/ ✅ 30+ компонентов старого стиля
```
### Критерии оценки
Каждый файл проверялся по 7 осям:
1. **Design tokens** — использует ли семантические цвета из `tailwind.config.js` или сырые классы
2. **Component reuse** — использует ли `$lib/ui` атомы или дублирует стили
3. **State architecture** — есть ли Screen Model для сложных страниц
4. **Svelte 5 compliance** — runes-only или legacy паттерны
5. **TypeScript** — типизирован или JSDoc/any
6. **UX contracts** — полнота семантических аннотаций
7. **Module size** — не превышает ли 400 строк (INV_7)
---
## 2. Архитектурный ландшафт
### 2.1 Tailwind Config (`tailwind.config.js`)
Определены семантические цвета, которые должны быть единым источником истины:
```js
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2563eb', // blue-600
hover: '#1d4ed8', // blue-700
ring: '#3b82f6', // blue-500
light: '#eff6ff', // blue-50
},
secondary: {
DEFAULT: '#f3f4f6', // gray-100
hover: '#e5e7eb', // gray-200
text: '#111827', // gray-900
ring: '#6b7280', // gray-500
},
destructive: {
DEFAULT: '#dc2626', // red-600
hover: '#b91c1c', // red-700
ring: '#ef4444', // red-500
light: '#fef2f2', // red-50
},
ghost: {
hover: '#f3f4f6', // gray-100
text: '#374151', // gray-700
ring: '#6b7280', // gray-500
},
terminal: { /* dark palette for log viewer */ },
log: { /* debug, info, warning, error */ },
source: { /* plugin, api, git, system */ },
},
width: {
sidebar: '240px',
'sidebar-collapsed': '64px',
},
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
},
},
}
```
### 2.2 UI-атомы (`src/lib/ui/`)
| Компонент | Описание | Использует семантические токены |
|-----------|----------|----------------------------------|
| `Button.svelte` | primary/secondary/danger/ghost variants | ✅ `bg-primary`, `bg-destructive` |
| `Card.svelte` | Container with title, padding variants | ❌ `border-gray-200 bg-white` |
| `Input.svelte` | Text input with label + error | ❌ `border-gray-300`, `focus:ring-primary-ring` |
| `Select.svelte` | Dropdown with label | ❌ `border-gray-300` |
| `PageHeader.svelte` | Title + subtitle + actions slot | ❌ `text-gray-900` |
| `Icon.svelte` | SVG icon set | ❌ цвета не использует (currentColor) |
| `HelpTooltip.svelte` | Tooltip helper | ❌ |
| `LanguageSwitcher.svelte` | Lang toggle | ❌ |
> **Вывод:** Только Button системно использует семантические токены. Остальные атомы используют сырые Tailwind-классы. Это источник "плавания" — если поменять primary цвет в конфиге, Button изменится, а Card/Input/Select — нет.
### 2.3 Состояние: 3 параллельных подхода
```
┌────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT MAP │
├────────────────────────────────────────────────────────────────┤
│ │
│ LEGACY STORES (svelte/store writable) │
│ ├── sidebar.ts — sidebar state + localStorage │
│ ├── taskDrawer.ts — task drawer visibility │
│ ├── datasetReviewSession.ts — dataset review session │
│ ├── environmentContext.ts — env context + selection │
│ ├── health.ts — health check state │
│ ├── activity.ts — derived activity │
│ ├── assistantChat.ts — assistant chat session │
│ └── translationRun.ts — translation run tracking │
│ │
│ MODERN STORES (.svelte.ts with $state) │
│ ├── maintenance.svelte.ts — maintenance + WebSocket │
│ │
│ SCREEN MODELS (.svelte.ts class with $state) │
│ ├── MigrationModel.svelte.ts — migration wizard state │
│ ├── GitManagerModel.svelte.ts — git manager state │
│ ├── GitStatusModel.svelte.ts — git status per dashboard │
│ ├── GitConfigModel.svelte.ts — git configuration │
│ ├── BranchModel.svelte.ts — branch management │
│ ├── DeploymentModel.svelte.ts — deployment state │
│ ├── CommitModel.svelte.ts — commit history state │
│ ├── MigrationSettingsModel.svelte.ts — migration settings │
│ └── MappingsModel.svelte.ts — db mapping state │
│ │
└────────────────────────────────────────────────────────────────┘
```
**Проблема:** AI-агент видит 3 разных паттерна и не знает, какой выбрать для нового кода.
### 2.4 Структура компонентов: двойная иерархия
```
src/lib/ui/ # Атомарные UI-компоненты (8 файлов)
├── Button.svelte
├── Card.svelte
├── Input.svelte
├── Select.svelte
├── PageHeader.svelte
├── Icon.svelte
├── HelpTooltip.svelte
└── LanguageSwitcher.svelte
src/lib/components/ # Доменные компоненты (30+ файлов)
├── layout/
├── translate/
├── reports/
├── dataset-review/
├── ui/ # SearchableMultiSelect, MultiSelect
├── health/
├── llm/
└── assistant/
src/components/ # Старые компоненты (30+ файлов)
├── auth/
├── git/
├── tasks/
├── tools/
├── backups/
├── storage/
├── llm/
├── EnvSelector.svelte
├── DashboardGrid.svelte
├── MappingTable.svelte
├── TaskRunner.svelte
├── TaskHistory.svelte
├── Toast.svelte
├── Navbar.svelte
├── Footer.svelte
├── StartupEnvironmentWizard.svelte
└── ...
```
**Проблема:** `src/components/` — наследие, но непонятно, почему часть компонентов там, а часть в `lib/components/`. Для AI-агента это создаёт неопределённость.
### 2.5 Карта страниц и их состояния
| Страница | Строк | $state атомов | Model? | Использует $lib/ui? | Типизация |
|----------|-------|---------------|--------|---------------------|-----------|
| `dashboards/+page.svelte` | **2765** | ~30+ | ❌ нет | ❌ частично | `lang="ts"` |
| `datasets/+page.svelte` | 472 | ~15+ | ❌ нет | ❌ нет | `lang="ts"` |
| `migration/+page.svelte` | 637 | ~5 (в модели) | ✅ MigrationModel | ✅ Button, Card, PageHeader | `lang="ts"` |
| `settings/+page.svelte` | 290 | ~5 | ❌ нет | ❌ нет | `lang="ts"` |
| `validation-tasks/+page.svelte` | 519 | ~10 | ❌ нет | ❌ нет | ❌ JSDoc |
| `reports/+page.svelte` | 204 | ~6 | ❌ нет | ✅ PageHeader | ❌ без типов |
| `dashboards/[id]/+page.svelte` | 582 | ~15 | частично (GitStatusModel) | ❌ нет | `lang="ts"` |
| `datasets/review/+page.svelte` | — | — | ❌ | — | — |
| `translate/+page.svelte` | — | — | ❌ | — | — |
---
## 3. Найденные проблемы
### 3.1 🔴 Критические
#### 🔴 Проблема 1: Трёхголовая архитектура состояния
**Описание:** В проекте одновременно существуют 3 несвязанных подхода к управлению состоянием.
**Доказательство:**
```typescript
// ПАТТЕРН 1: Legacy writable store (src/lib/stores/sidebar.ts)
import { writable, type Writable } from 'svelte/store';
export const sidebarStore: Writable<SidebarState> = writable(initialState);
// Используется через fromStore() в компонентах
// ПАТТЕРН 2: Rune-based store (src/lib/stores/maintenance.svelte.ts)
export function createMaintenanceStore(): MaintenanceStore {
let activeEvents = $state<unknown[]>([]);
// ...
return { get activeEvents() { return activeEvents; }, ... };
}
// ПАТТЕРН 3: Screen Model class (src/lib/models/MigrationModel.svelte.ts)
export class MigrationModel {
currentStep: number = $state(1);
// ...
}
```
**Последствия:**
- AI-агент не может определить, какой паттерн использовать
- `writable` сторы нельзя использовать с `$derived` напрямую
- Разный DX для тестирования: Model тестируется без DOM (`new Model()`), Store — через `get()`
- Рефакторинг любого стора требует контекстного переключения
---
#### 🔴 Проблема 2: Нет Screen Model для сложных страниц
**Описание:** Страницы с 10+ `$state` атомами и cross-widget инвариантами не выделены в Model.
**Доказательство — `dashboards/+page.svelte` (2765 строк, фрагмент):**
```svelte
<!-- ❌ ВСЁ В ОДНОМ ФАЙЛЕ: состояние, логика, рендер -->
<script lang="ts">
let allDashboards = $state([]);
let dashboards = $state([]);
let filteredDashboards = $state([]);
let isLoading = $state(true);
let error = $state(null);
let currentPage = $state(1);
let pageSize = $state(10);
let totalPages = $state(1);
let total = $state(0);
let selectedIds = $state(new Set());
let isAllSelected = $state(false);
let searchQuery = $state("");
let filterDashboardIds = $state([]);
let sortColumn = $state("title");
let sortDirection = $state("asc");
let openFilterColumn = $state(null);
let columnFilterSearch = $state({...});
let columnFilters = $state({...});
let validationStatuses = $state({});
let validationPopoverPosition = $state({ left: 0, top: 0 });
let showMigrateModal = $state(false);
let showBackupModal = $state(false);
let gitBusyIds = $state(new Set());
let gitResolvedIds = $state(new Set());
let gitLoadingIds = $state(new Set());
// ... ~30 атомов
// Инлайн-логика: loadDashboards, handleSearch, handleSort,
// applyGridTransforms, handleGitInit, handleGitSync,
// handleGitCommit, handleGitPull, handleGitPush,
// fetchValidationStatuses, toggleFilterDropdown,
// toggleFilterValue, clearColumnFilter, getSortValue,
// getColumnCellValue, getFilterOptions, normalizeOwners,
// normalizeTaskStatus, normalizeValidationStatus,
// getValidationBadgeClass, getValidationLabel,
// getValidationLabelForCell, getPaginationRange...
// ~50 функций, бизнес-логика и Git-операции — всё инлайн
</script>
```
**Сравнение с model-first подходом (migration/+page.svelte, 637 строк):**
```svelte
<!-- ✅ ТОНКИЙ СЛОЙ РЕНДЕРИНГА -->
<script lang="ts">
import { MigrationModel } from "$lib/models/MigrationModel.svelte.ts";
const model = new MigrationModel();
onMount(() => model.loadEnvironments());
// Вся логика — в model.action()
// Компонент только рендерит model.state и вызывает model.*()
</script>
```
**Последствия:**
- `dashboards/+page.svelte` невозможно протестировать unit-тестами без DOM
- AI-агент теряет контекст к 20-й функции
- Нарушение INV_7 (>400 строк, >10 cyclomatic complexity)
- Любые изменения рискуют сломать невидимые cross-widget инварианты
---
#### 🔴 Проблема 3: Размытие дизайн-токенов
**Описание:** Семантические цвета из `tailwind.config.js` используются непоследовательно. Рядом с `bg-primary` соседствуют `bg-indigo-50`, `bg-gradient-to-br from-slate-50 via-white to-sky-50`, `bg-red-100`.
**Доказательство — сравнение паттернов в dashboards/+page.svelte:**
```svelte
<!-- ✅ Семантический токен (редко) -->
<button class="bg-primary text-white hover:bg-primary-hover">
<!-- ❌ Дрейф: indigo вместо primary -->
<button class="inline-flex items-center justify-center rounded-lg border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-100">
<!-- ❌ Дрейф: сырой red вместо destructive -->
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between">
<!-- ❌ Рядом destructive (правильно) и сырой red (неправильно) -->
<button class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors">
<!-- ❌ sky-50, gradient — нет в токенах -->
<div class="relative overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-50 via-white to-sky-50 p-8 shadow-sm">
```
**Примеры из других файлов:**
```svelte
<!-- validation-tasks/+page.svelte — сырые цвета -->
<select class="rounded-md border border-slate-300 px-2 py-1.5 text-sm">
<!-- settings/+page.svelte — сырые цвета -->
<div class="border-b border-gray-200">
<!-- reports/+page.svelte — сырой Card вместо компонента Card -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<!-- settings/+page.svelte — 3 разных стиля кнопок в одном файле -->
<button class="bg-blue-600 text-white px-3 py-1.5 text-sm rounded hover:bg-blue-700">
<button class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-100">
<button class="text-blue-600 hover:text-blue-800 text-sm font-medium">
```
**Последствия:**
- Изменение `primary` в tailwind.config.js не синхронизируется с indigo/sky-кнопками
- AI-агент не может определить "правильный" цвет — каждый раз гадает
- Визуальная несогласованность: кнопки на одной странице выглядят по-разному
- 5 разных стилей для одного и того же UI-паттерна
---
### 3.2 🟡 Средние
#### 🟡 Проблема 4: Button-компонент не стандартизирован
**Описание:** Только `migration/+page.svelte` системно использует `$lib/ui/Button`. Остальные страницы используют ручные `<button>` с классами.
**Доказательство — все стили button в проекте:**
```svelte
<!-- СТИЛЬ 1: Button компонент (только migration page) -->
<Button variant="primary" onclick={handleRunMigration}>Run</Button>
<!-- СТИЛЬ 2: primary кнопка (dashboards — ручная) -->
<button class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover">
<!-- СТИЛЬ 3: indigo кнопка (dashboards — ручная, дрейф) -->
<button class="inline-flex items-center justify-center rounded-lg border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-100">
<!-- СТИЛЬ 4: toolbar кнопка (dashboards — ручная) -->
<button class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 transition-colors">
<!-- СТИЛЬ 5: secondary кнопка (validation-tasks — ручная) -->
<button class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50">
<!-- СТИЛЬ 6: text-only кнопка (settings — ручная) -->
<button class="text-blue-600 hover:text-blue-800 text-sm font-medium">
<!-- СТИЛЬ 7: danger кнопка (dashboards — ручная) -->
<button class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors">
```
> **7 разных стилей** для одного и того же — кнопки. При использовании `<Button>` их было бы 2-3 (variant + size).
---
#### 🟡 Проблема 5: Дублирование каталогов компонентов
**Описание:** Компоненты разбросаны по трём корневым директориям без чётких правил, куда что класть.
| Директория | Количество | Назначение (фактическое) |
|------------|-----------|--------------------------|
| `src/lib/ui/` | 8 | Атомарные UI-компоненты общего назначения |
| `src/lib/components/` | 30+ | Доменные компоненты (layout, translate, reports, health, llm, assistant, dataset-review, ui) |
| `src/components/` | 30+ | "Всё остальное" — без доменной группировки |
**Пример:** Компоненты Git разбросаны:
- `src/components/git/GitManager.svelte`
- `src/components/git/BranchSelector.svelte`
- `src/components/git/CommitHistory.svelte`
Почему они не в `lib/components/`? Исторически сложилось. Для AI-агента это мёртвый груз — нужно помнить про 3 директории.
---
#### 🟡 Проблема 6: JSDoc вместо TypeScript
**Описание:** Страницы, написанные после внедрения runes, используют JSDoc-аннотации вместо TypeScript.
**Доказательство (`validation-tasks/+page.svelte`):**
```svelte
<script>
/** @type {{ data: import('./$types').PageData }} */
let { data } = $props();
/** @type {Array<any>} */ let tasks = $state(data.tasks ?? []);
/** @type {number} */ let total = $state(data.total ?? 0);
/** @type {number} */ let currentPage = $state(data.page ?? 1);
/** @type {number} */ let pageSize = $state(data.page_size ?? 20);
/** @type {string | null} */ let error = $state(data.error ?? null);
let isLoading = $state(false);
let searchQuery = $state('');
/** @type {Record<string, boolean>} */
let runningTasks = $state({});
let deleteConfirmId = $state(/** @type {string | null} */(null));
</script>
```
**Проблема:** `<script lang="ts">` даёт compile-time проверку типов. JSDoc — только документацию. `any` — отсутствие типов. `@type {Array<any>}` — worst of both worlds.
---
#### 🟡 Проблема 7: 8 легаси-сторов блокируют миграцию на runes
**Описание:** Все сторы, кроме одного, используют Svelte 4 `writable()` API.
```typescript
// src/lib/stores/environmentContext.ts
import { derived, get, writable, type Writable } from 'svelte/store';
const contextStore: Writable<EnvironmentContextState> = writable(INITIAL_STATE);
// src/lib/stores/taskDrawer.ts
import { writable, type Writable } from 'svelte/store';
export const taskDrawerStore: Writable<TaskDrawerState> = writable(initialState);
```
**Проблема:** Для чтения этих сторов в Svelte 5 нужно использовать `fromStore()`:
```typescript
// +layout.svelte — обязательный bridge
const sidebarState = fromStore(sidebarStore);
let isExpanded = $derived(sidebarState.current?.isExpanded || true);
```
Это работает, но создаёт лишний слой абстракции и не позволяет использовать `$state` / `$derived` напрямую в store-файлах.
---
#### 🟡 Проблема 8: Неравномерное покрытие UX-контрактами
| Компонент | `@UX_STATE` | `@UX_FEEDBACK` | `@UX_RECOVERY` | `@UX_REACTIVITY` |
|-----------|:-----------:|:--------------:|:--------------:|:----------------:|
| MigrationPage | ✅ | ✅ | ✅ | ✅ |
| DashboardHub | ✅ | ✅ | ✅ | ❌ |
| ValidationTasksPage | ✅ | ✅ | ✅ | ❌ |
| ReportsPage | ✅ | ✅ | ❌ | ❌ |
| DashboardDetail | ✅ | ✅ | ❌ | ❌ |
| DatasetHub | ✅ шапка | ❌ | ❌ | ❌ |
| SettingsPage | ✅ | ✅ | ✅ | ❌ |
| Sidebar | ✅ | ✅ | ✅ | ❌ |
| Card | ✅ legacy | — | — | — |
| Button | ✅ | — | — | — |
> `@UX_REACTIVITY` (явное объявление источника состояния) отсутствует везде, кроме MigrationPage.
---
### 3.3 🟢 Малые
#### 🟢 Проблема 9: `cn()` utility не строго типизирован
```typescript
// src/lib/utils.ts — line 10
export function cn(...inputs: (string | undefined | null | false)[]): string {
```
Пропускает `true` и числа. Не критично, но неаккуратно.
---
#### 🟢 Проблема 10: Дублирование debounce
```typescript
// ✅ $lib/utils/debounce.ts — TypeScript, с типами
export function debounce<T extends (...args: unknown[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
// ❌ dashboards/+page.svelte — инлайн-дебаунс (line 127-131)
const debouncedSearch = debounce((query) => {
searchQuery = query;
currentPage = 1;
void loadDashboards();
}, 300);
```
Два дебаунса с одинаковой сигнатурой, один — в утилите (правильно), другой — инлайн (дублирование).
---
#### 🟢 Проблема 11: Zero-state не переиспользуется
- `dashboards/+page.svelte` — премиум empty state с градиентом, `rounded-3xl`, `tracking-[0.24em]`
- `reports/+page.svelte` — примитивный `<div>` с текстом
- `validation-tasks/+page.svelte` — центрированная карточка
Нет общего компонента `EmptyState`.
---
#### 🟢 Проблема 12: Border radius плавает
| Элемент | Страница | Radius |
|---------|----------|--------|
| Card (компонент) | `$lib/ui/Card` | `rounded-lg` |
| Surrogate Card | reports | `rounded-xl` |
| Surrogate Card | validation-tasks | `rounded-xl` |
| Surrogate Card | settings | `rounded-lg` |
| Zero-state | dashboards | `rounded-3xl` |
| Toolbar контейнер | dashboards | `rounded-xl` |
| Toolbar контейнер | reports | `rounded-xl` |
Нет иерархии радиусов, AI-агент выбирает случайно.
---
## 4. Причины плавающего дизайна
### 4.1 Отсутствие единого Design Language
У проекта есть `tailwind.config.js` с семантическими токенами, но нет документированных правил:
- Когда использовать `primary` vs `blue-600` vs `indigo-600`?
- Какой border radius для чего?
- Какой набор компонентов обязателен к использованию?
### 4.2 Две эпохи Svelte
Проект начинался на Svelte 4 (writable stores, on:event, export let), потом мигрировал на Svelte 5 (runes, $state). Миграция не завершена:
- 8 stores остались на writable
- 30+ компонентов в `src/components/` — наследие
- Новые страницы пишутся на runes, но без model-first
### 4.3 AI-агенты без правил
Каждый AI-агент (человек или LLM) при создании новой страницы:
1. Не знает, какой цвет использовать — выбирает "похожий"
2. Не знает, какой компонент переиспользовать — пишет с нуля
3. Не знает, куда положить файл — кладёт в `routes/` или `components/`
4. Не знает паттерн состояния — пишет `$state` + функции инлайн
### 4.4 Нет "шлюза" для нового кода
Нет code review checklist или pre-commit хука, который проверял бы:
- Использует ли страница `<Button>` вместо ручного `<button>`?
- Использует ли страница семантические токены?
- Есть ли Screen Model для страницы с >5 атомами?
---
## 5. AI-Native Design Rules
> Эти правила предназначены для включения в промпт AI-агента (Svelte-кодера) и для CI-валидации.
### Rule 1: Semantic Color Tokens Over Raw Colors
```svelte
// ✅ CORRECT
<div class="bg-primary text-white hover:bg-primary-hover">
<div class="bg-destructive text-white">
<div class="bg-secondary text-secondary-text">
// ❌ WRONG
<div class="bg-blue-600 text-white">
<div class="bg-indigo-50 text-indigo-700">
<div class="bg-red-100 text-red-700">
<div class="bg-gradient-to-br from-slate-50 via-white to-sky-50">
```
**Исключение:** Только в `tailwind.config.js` при определении новых токенов.
---
### Rule 2: UI Atoms Are Mandatory For Pages
```svelte
// ✅ CORRECT
import { Button, Card, Input, Select, PageHeader } from "$lib/ui";
<Button variant="primary" onclick={handleSave}>
<Card>
<PageHeader title="Settings" subtitle={() => <p>Sub</p>} />
// ❌ WRONG
<button class="px-3 py-1 bg-primary rounded text-white">
<div class="bg-white rounded-xl border shadow-sm p-6">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold">Settings</h1>
```
**Исключение:** layout-компоненты и сами атомы в `$lib/ui/`.
---
### Rule 3: Screen Model For Any Page With >5 $state Atoms
```typescript
// ✅ CORRECT: DashboardHubModel.svelte.ts
export class DashboardHubModel {
dashboards: Dashboard[] = $state([]);
page: number = $state(1);
searchQuery: string = $state("");
// ...
async loadDashboards(): Promise<void> { ... }
async deleteDashboard(id: string): Promise<void> { ... }
}
// ✅ CORRECT: migration/+page.svelte (thin render layer)
const model = new MigrationModel();
onMount(() => model.loadEnvironments());
// ❌ WRONG: dashboards/+page.svelte (2765 строк, ~30 $state атомов инлайн)
```
**Trigger:** Если в `<script>` страницы больше 5 `$state(...)` вызовов → создай Model.
---
### Rule 4: Runes Only For New State Code
```typescript
// ✅ CORRECT (.svelte.ts)
let count = $state(0);
const doubled = $derived(count * 2);
// ❌ WRONG (.ts)
import { writable, derived } from 'svelte/store';
const count = writable(0);
const doubled = derived(count, $count => $count * 2);
```
**Исключение:** Только рефакторинг существующих writable-сторов, у которых есть подписчики.
---
### Rule 5: One Component Directory — `lib/components/`
```
// ✅ CORRECT
src/lib/components/git/GitManager.svelte
src/lib/components/git/BranchSelector.svelte
// ❌ WRONG
src/components/git/GitManager.svelte
src/components/git/BranchSelector.svelte
```
**Trigger:** Новый доменный компонент → `src/lib/components/<domain>/`. `src/components/` — заморожено.
---
### Rule 6: TypeScript Required On Pages
```svelte
// ✅ CORRECT
<script lang="ts">
let { data }: { data: PageData } = $props();
let items: Item[] = $state([]);
// ❌ WRONG
<script>
/** @type {Array<any>} */ let items = $state([]);
```
---
### Rule 7: Consistent Border Radius Hierarchy
```
rounded-md → inputs, buttons, selects (h-10)
rounded-lg → cards, containers, modals
rounded-xl → panels, drawers (опционально)
rounded-3xl → zero-state / empty-state только
```
---
### Rule 8: Page Max 400 Lines
```
>400 lines → Model + Component decomposition
>10 cyclomatic complexity → helper functions
```
---
## 6. Приоритетный план исправлений
### P0 — Немедленно (2-3 часа)
| # | Задача | Файлы | Ожидаемый результат |
|---|--------|-------|---------------------|
| 0.1 | Выделить `DashboardHubModel.svelte.ts` | `dashboards/+page.svelte``lib/models/DashboardHubModel.svelte.ts` | 2765 → <400 строк в page, Model тестируется без DOM |
| 0.2 | Создать `EmptyState` компонент | `lib/ui/EmptyState.svelte` + usage | Единый zero-state паттерн |
### P1 — Важно (5-6 часов)
| # | Задача | Файлы | Ожидаемый результат |
|---|--------|-------|---------------------|
| 1.1 | Заменить raw colors на semantic tokens | `dashboards/+page.svelte`, `reports/+page.svelte`, `settings/**` | Ни одного `bg-indigo-*`, `text-indigo-*`, `bg-red-*` в page-файлах |
| 1.2 | Заменить ручные `<button>` на `<Button>` | Все page-файлы | Единый стиль кнопок |
| 1.3 | Мигрировать 1-2 легаси стора на `.svelte.ts` | `taskDrawer.ts`, `sidebar.ts` | Подтверждение паттерна |
### P2 — Среднесрочно (4-5 часов)
| # | Задача | Файлы | Ожидаемый результат |
|---|--------|-------|---------------------|
| 2.1 | Выделить `DatasetHubModel` | `datasets/+page.svelte` | model-first для datasets |
| 2.2 | Выделить `ValidationTasksModel` | `validation-tasks/+page.svelte` | model-first для validation |
| 2.3 | Перевести `validation-tasks/+page.svelte` на TypeScript | `validation-tasks/+page.svelte` | `<script lang="ts">` |
| 2.4 | Добавить `@UX_REACTIVITY` на все страницы | Все page | Единый UX-контракт |
### P3 — Долгосрочно (6-8 часов)
| # | Задача | Файлы | Ожидаемый результат |
|---|--------|-------|---------------------|
| 3.1 | Консолидировать `src/components/` в `lib/components/` | 30+ файлов | Одна иерархия компонентов |
| 3.2 | Мигрировать оставшиеся 6 writable-сторов на `.svelte.ts` | 6 store-файлов | Runes-only stores |
| 3.3 | Добавить всем атомам семантические токены | `Card.svelte`, `Input.svelte`, `Select.svelte`, `PageHeader.svelte` | Единые токены во всех атомах |
---
## 7. Приложение: Карта файлов
### 7.1 UI-атомы (`src/lib/ui/`)
| Файл | Строк | Использует токены | Типы |
|------|-------|-------------------|------|
| `Button.svelte` | 75 | `bg-primary`, `bg-destructive` | `lang="ts"` |
| `Card.svelte` | 58 | `border-gray-200 bg-white` | `lang="ts"` |
| `Input.svelte` | 66 | `border-gray-300` + `focus:ring-primary-ring` | `lang="ts"` |
| `Select.svelte` | 58 | `border-gray-300` | `lang="ts"` |
| `PageHeader.svelte` | 45 | `text-gray-900` | `lang="ts"` |
| `Icon.svelte` | 111 | `currentColor` | `lang="ts"` |
| `HelpTooltip.svelte` | | | |
| `LanguageSwitcher.svelte` | | | |
### 7.2 Модели (`src/lib/models/`)
| Файл | Строк | Семантика |
|------|-------|-----------|
| `MigrationModel.svelte.ts` | 472 | model-first gold standard |
| `GitManagerModel.svelte.ts` | | |
| `GitStatusModel.svelte.ts` | | |
| `GitConfigModel.svelte.ts` | | |
| `BranchModel.svelte.ts` | | |
| `DeploymentModel.svelte.ts` | | |
| `CommitModel.svelte.ts` | | |
| `MigrationSettingsModel.svelte.ts` | | |
| `MappingsModel.svelte.ts` | | |
### 7.3 Сторы (`src/lib/stores/`)
| Файл | Строк | Паттерн | Семантические токены |
|------|-------|---------|---------------------|
| `sidebar.ts` | 79 | `writable` legacy | аннотации |
| `taskDrawer.ts` | 112 | `writable` legacy | |
| `environmentContext.ts` | | `writable` legacy | |
| `datasetReviewSession.ts` | 87 | `writable` legacy | |
| `assistantChat.ts` | | `writable` legacy | |
| `health.ts` | | `writable` legacy | |
| `activity.ts` | | `writable` legacy | |
| `translationRun.ts` | | `writable` legacy | |
| `maintenance.svelte.ts` | 203 | `$state` modern | |
### 7.4 Страницы (`src/routes/`)
| Файл | Строк | Model? | $lib/ui? | Типы | UX контракты |
|------|-------|--------|----------|------|-------------|
| `dashboards/+page.svelte` | 2765 | | частично | ts | 5/5 |
| `dashboards/[id]/+page.svelte` | 582 | частично | | ts | 4/5 |
| `datasets/+page.svelte` | 472 | | | ts | 1/5 |
| `datasets/review/+page.svelte` | | | | | |
| `migration/+page.svelte` | 637 | | | ts | 5/5 |
| `settings/+page.svelte` | 290 | | | ts | 4/5 |
| `validation-tasks/+page.svelte` | 519 | | | JSDoc | 4/5 |
| `reports/+page.svelte` | 204 | | PageHeader | any | 3/5 |
| `translate/+page.svelte` | | | | | |
| `+layout.svelte` | 112 | | | ts | |
### 7.5 Старые компоненты (`src/components/`)
```
src/components/
├── auth/
│ └── ProtectedRoute.svelte
├── git/
│ ├── GitManager.svelte
│ ├── BranchSelector.svelte
│ ├── CommitHistory.svelte
│ ├── CommitModal.svelte
│ ├── ConflictResolver.svelte
│ ├── DeploymentModal.svelte
│ ├── GitInitPanel.svelte
│ ├── GitOperationsPanel.svelte
│ ├── GitReleasePanel.svelte
│ └── GitWorkspacePanel.svelte
├── tasks/
│ ├── TaskResultPanel.svelte
│ └── TaskLogPanel.svelte
│ └── LogFilterBar.svelte
│ └── LogEntryRow.svelte
├── tools/
│ ├── MapperTool.svelte
│ └── DebugTool.svelte
├── backups/
│ ├── BackupManager.svelte
│ └── BackupList.svelte
├── storage/
│ ├── FileUpload.svelte
│ └── FileList.svelte
├── llm/
│ ├── ValidationReport.svelte
│ ├── ProviderConfig.svelte
│ └── DocPreview.svelte
├── DashboardGrid.svelte
├── EnvSelector.svelte
├── Footer.svelte
├── MappingTable.svelte
├── MissingMappingModal.svelte
├── Navbar.svelte
├── PasswordPrompt.svelte
├── RepositoryDashboardGrid.svelte
├── StartupEnvironmentWizard.svelte
├── TaskHistory.svelte
├── TaskList.svelte
├── TaskLogViewer.svelte
├── TaskRunner.svelte
├── Toast.svelte
├── DynamicForm.svelte
└── RepositoryDashboardGrid.svelte
```
---
*Аудит выполнен 2026-06-01. Полный контекст: ~120 файлов, 8 UI-атомов, 55+ компонентов, 9 моделей, 9 сторов, все 35+ страниц.*

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// #region AuditFrontendStyle [C:3] [TYPE Module] [SEMANTICS audit, design-tokens, guardrail, script, quality-gate]
// @BRIEF Audits frontend source files for design token violations (raw Tailwind colors), manual buttons, oversized pages, and Model-first violations.
// @LAYER Infra
// @RATIONALE Before PR 3-4 page decomposition runs, we need a CI-checkable guardrail that prevents new drift from entering the codebase. This script enforces the semantic-token-only rule established in PR 0+1.
// @USAGE node scripts/audit-frontend-style.mjs [--fix] [--verbose]
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { resolve, relative, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const VERBOSE = process.argv.includes('--verbose');
// ── File discovery (no external deps) ──────────────────────────
function walk(dir, pattern, maxDepth = 10) {
if (maxDepth <= 0) return [];
const results = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'build') {
results.push(...walk(full, pattern, maxDepth - 1));
} else if (entry.isFile() && pattern.test(entry.name)) {
results.push(full);
}
}
} catch { /* skip */ }
return results;
}
const PAGE_FILES = walk(join(ROOT, 'src', 'routes'), /\+page\.svelte$/);
const COMPONENT_FILES = walk(join(ROOT, 'src', 'lib', 'components'), /\.svelte$/);
const LEGACY_COMPONENTS = walk(join(ROOT, 'src', 'components'), /\.svelte$/);
// ── Rules ──────────────────────────────────────────────────────
const RAW_COLOR_PATTERNS = [
// Raw tailwind color classes (banned in routes/** and lib/ui/**)
{ regex: /\bbg-blue-\d+\b/, message: 'bg-blue-* → bg-primary' },
{ regex: /\bbg-indigo-\d+\b/, message: 'bg-indigo-* → bg-primary or bg-info' },
{ regex: /\bbg-red-\d+\b/, message: 'bg-red-* → bg-destructive or bg-destructive-light' },
{ regex: /\bbg-green-\d+\b/, message: 'bg-green-* → bg-success or bg-success-light' },
{ regex: /\bbg-amber-\d+\b/, message: 'bg-amber-* → bg-warning or bg-warning-light' },
{ regex: /\bbg-emerald-\d+\b/, message: 'bg-emerald-* → bg-success variants' },
{ regex: /\bbg-violet-\d+\b/, message: 'bg-violet-* → bg-primary-light or bg-info-light' },
{ regex: /\bbg-gray-\d+\b/, message: 'bg-gray-* → bg-surface-muted or bg-surface-card' },
{ regex: /\bbg-slate-\d+\b/, message: 'bg-slate-* → bg-surface-* tokens' },
{ regex: /\bbg-white\b(?!.*\bbg-surface-card\b)/, message: 'bg-white → bg-surface-card (use semantic token)' },
{ regex: /\btext-blue-\d+\b/, message: 'text-blue-* → text-primary' },
{ regex: /\btext-indigo-\d+\b/, message: 'text-indigo-* → text-primary or text-info' },
{ regex: /\btext-red-\d+\b/, message: 'text-red-* → text-destructive' },
{ regex: /\btext-green-\d+\b/, message: 'text-green-* → text-success' },
{ regex: /\btext-amber-\d+\b/, message: 'text-amber-* → text-warning' },
{ regex: /\btext-emerald-\d+\b/, message: 'text-emerald-* → text-success' },
{ regex: /\btext-gray-\d+\b/, message: 'text-gray-* → text-text or text-text-muted' },
{ regex: /\btext-slate-\d+\b/, message: 'text-slate-* → text-* tokens' },
{ regex: /\bborder-blue-\d+\b/, message: 'border-blue-* → border-primary-ring' },
{ regex: /\bborder-indigo-\d+\b/, message: 'border-indigo-* → border-primary-ring or border-info-ring' },
{ regex: /\bborder-red-\d+\b/, message: 'border-red-* → border-destructive-ring' },
{ regex: /\bborder-green-\d+\b/, message: 'border-green-* → border-success' },
{ regex: /\bborder-amber-\d+\b/, message: 'border-amber-* → border-warning' },
{ regex: /\bborder-gray-\d+\b/, message: 'border-gray-* → border-border or border-border-strong' },
{ regex: /\bborder-slate-\d+\b/, message: 'border-slate-* → border-* tokens' },
{ regex: /\bring-(blue|indigo|red|green|amber|emerald|violet|gray|slate)-\d+\b/, message: 'ring-*-* → use semantic token ring (primary-ring, destructive-ring, etc.)' },
];
const GRADIENT_PATTERN = /\bbg-gradient-to-[brtl]{1,2}\s+from-\S+\s+via-\S+\s+to-\S+\b/;
const MANUAL_BUTTON_REGEX = /<button\s+(?!.*\bdisabled\b)/; // false positives possible — manual review
// ── File scopes ────────────────────────────────────────────────
// (populated above via walk())
// PAGE_FILES, COMPONENT_FILES, LEGACY_COMPONENTS
// ── Checks ─────────────────────────────────────────────────────
const violations = [];
const warnings = [];
for (const fullPath of [...PAGE_FILES, ...COMPONENT_FILES]) {
const relPath = relative(ROOT, fullPath);
const source = readFileSync(fullPath, 'utf-8');
const lines = source.split('\n');
// Check 1: Raw color patterns
for (const { regex, message } of RAW_COLOR_PATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (regex.test(line)) {
violations.push({
file: relative(ROOT, fullPath),
line: i + 1,
rule: 'raw-color',
message,
snippet: line.trim().substring(0, 120),
});
}
}
}
// Check 2: Gradient patterns in page files
if (relPath.startsWith('src/routes/') && GRADIENT_PATTERN.test(source)) {
violations.push({
file: relative(ROOT, fullPath),
line: 0,
rule: 'no-gradient',
message: 'bg-gradient-* → use semantic surface tokens',
snippet: '[gradient found]',
});
}
// Check 3: Page too large
if (relPath.startsWith('src/routes/') && lines.length > 400) {
violations.push({
file: relative(ROOT, fullPath),
line: 0,
rule: 'page-too-large',
message: `Page has ${lines.length} lines (max 400). Decompose with Model + components.`,
snippet: '',
});
}
// Check 4: Count $state atoms vs Model import
if (relPath.startsWith('src/routes/')) {
const stateCount = (source.match(/\$\state\(/g) || []).length;
const hasModelImport = /import\s+\{.*\}\s+from\s+['"].*Model\.svelte\.ts['"]/.test(source);
const hasModelNew = /\bnew\s+\w+Model\(\)/.test(source);
if (stateCount > 5 && !hasModelImport && !hasModelNew) {
warnings.push({
file: relative(ROOT, fullPath),
line: 0,
rule: 'no-screen-model',
message: `Page has ${stateCount} $state atoms but no Model import. Consider creating a Screen Model.`,
snippet: '',
});
}
}
// Check 5: Manual <button> in page files (warning only — false positives for step wizards etc.)
if (relPath.startsWith('src/routes/')) {
const buttonCount = (source.match(/<button\b/g) || []).length;
const hasButtonImport = /import\s+\{.*Button.*\}\s+from\s+['"]\$lib\/ui['"]/.test(source);
if (buttonCount > 0 && !hasButtonImport) {
warnings.push({
file: relative(ROOT, fullPath),
line: 0,
rule: 'manual-button',
message: `Page has ${buttonCount} raw <button> elements but no <Button> import from $lib/ui.`,
snippet: '',
});
}
}
}
// Check 6: New files in legacy components/ directory (frozen zone)
if (LEGACY_COMPONENTS.length > 0) {
console.log(`\n⚠ LEGACY FROZEN: ${LEGACY_COMPONENTS.length} files remain in src/components/. No new files should be added.`);
}
// ── Report ─────────────────────────────────────────────────────
const errorCount = violations.filter(v => v.rule !== 'page-too-large' && v.rule !== 'no-gradient').length;
const oversizedPages = violations.filter(v => v.rule === 'page-too-large');
const gradientPages = violations.filter(v => v.rule === 'no-gradient');
console.log('\n═══ FRONTEND STYLE AUDIT ═══\n');
if (violations.length === 0 && warnings.length === 0) {
console.log('✅ All checks passed. No violations found.\n');
process.exit(0);
}
const violationFiles = new Set(violations.map(v => v.file));
const warningFiles = new Set(warnings.map(v => v.file));
console.log(`🔴 VIOLATIONS: ${errorCount} color-token + ${oversizedPages.length} oversized-pages`);
console.log(`🟡 WARNINGS: ${warnings.length} (model-first, manual-button)`);
console.log(`📁 Files: ${violationFiles.size + warningFiles.size} total\n`);
if (VERBOSE) {
for (const v of violations) {
console.log(` 🔴 ${v.file}:${v.line} [${v.rule}] ${v.message}`);
if (v.snippet) console.log(`${v.snippet}`);
}
for (const w of warnings) {
console.log(` 🟡 ${w.file} [${w.rule}] ${w.message}`);
}
} else {
// Summary mode
const byFile = {};
for (const v of violations) {
byFile[v.file] = (byFile[v.file] || 0) + 1;
}
for (const [file, count] of Object.entries(byFile).sort(([,a], [,b]) => b - a)) {
console.log(` ${count.toString().padEnd(4)} violations ${file}`);
}
}
// Oversized page summary
if (oversizedPages.length > 0) {
console.log(`\n📄 OVERSIZED PAGES (>400 lines):`);
for (const p of oversizedPages) {
console.log(` ${p.file}${p.message}`);
}
}
console.log('');
if (errorCount > 0) {
console.log(`${errorCount} design-token violations found. Run with --verbose for details.`);
} else {
console.log('✅ No design-token violations.');
}
if (warnings.length > 0) {
console.log(`${warnings.length} warnings (model-first / manual-button).`);
}
// Exit with error code if violations found (for CI)
process.exit(errorCount > 0 ? 1 : 0);
// #endregion AuditFrontendStyle

View File

@@ -0,0 +1,586 @@
// #region DashboardHubModel [C:4] [TYPE Model] [SEMANTICS dashboard, hub, grid, filter, pagination, git, screen-model]
// @BRIEF State model for the dashboard hub page — all atoms, invariants, and actions in one class.
// @REJECTED Inline $state + handler functions in +page.svelte was rejected because it scattered 35+ atoms and 40+ functions across 2400+ lines, making testing impossible without DOM render and violating INV_7 (>400 lines, CC >10).
// @INVARIANT Changing filter (column, search, multi-select) resets pagination to page 1.
// @INVARIANT Changing source environment resets all dashboard, filter, selection, and git state.
// @INVARIANT Selection never contains IDs not present in allDashboards (cleaned on load).
// @INVARIANT dryRunResult is always null when selectedDashboardIds changes (handled by $effect in page).
// @STATE loading — API call in progress.
// @STATE loaded — Dashboard grid with status badges rendered.
// @STATE empty — Query returned zero results.
// @STATE error — API call failed, error message available.
// @ACTION loadDashboards() — Fetches dashboards for current env/page/search/filters.
// @ACTION setPage(n) — Navigates to page n.
// @ACTION setSort(column) — Toggles sort on column.
// @ACTION toggleFilterValue(col,val,chk) — Adds/removes filter value.
// @ACTION clearColumnFilter(col) — Resets one column filter.
// @ACTION handleGitInit(dashboard) — Initializes git repo.
// @ACTION handleGitSync(dashboard) — Syncs git repo.
// @ACTION handleGitCommit(dashboard) — Commits changes.
// @ACTION handleGitPull(dashboard) — Pulls from remote.
// @ACTION handleGitPush(dashboard) — Pushes to remote.
// @ACTION handleBulkBackup() — Executes bulk backup.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [gitService]
// @RELATION BINDS_TO -> [environmentContextStore]
// @RELATION CALLS -> [log]
import { goto } from "$app/navigation";
import { log } from "$lib/cot-logger";
import { api } from "$lib/api.js";
import { gitService } from "../../services/gitService.js";
import { addToast } from "$lib/toasts.svelte.js";
import { openDrawerForTask, openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js";
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
import { MigrationModel } from "./MigrationModel.svelte.ts";
import { t } from "$lib/i18n/index.svelte.js";
import { normalizeTaskStatus, normalizeValidationStatus, normalizeOwners, formatDate } from "../../routes/dashboards/dashboard-helpers.js";
// ═══ Types ═══════════════════════════════════════════════════════
type SortDir = "asc" | "desc";
type FilterColumn = "title" | "git_status" | "validation_status" | "changed_on" | "actor";
type ColumnFilters = Record<FilterColumn, Set<string>>;
type ColumnFilterSearch = Record<FilterColumn, string>;
interface DashboardRow {
id: string; title: string; slug: string;
changedOn: string | null; changedOnLabel: string;
owners: string[]; actorLabel: string;
git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean };
lastTask: { status: string | null; validationStatus: string; id: string } | null;
actions: string[];
}
interface GitState { status: string; branch: string | null; hasRepo: boolean; hasChangesForCommit: boolean }
// ═══ Model ═══════════════════════════════════════════════════════
export class DashboardHubModel {
// ── Core state ────────────────────────────────────────────────
selectedEnv: string | null = $state(null);
allDashboards: DashboardRow[] = $state([]);
dashboards: DashboardRow[] = $state([]);
filteredDashboards: DashboardRow[] = $state([]);
isLoading: boolean = $state(true);
error: string | null = $state(null);
// ── Pagination ────────────────────────────────────────────────
currentPage: number = $state(1);
pageSize: number = $state(10);
totalPages: number = $state(1);
total: number = $state(0);
// ── Selection ─────────────────────────────────────────────────
selectedIds: Set<string> = $state(new Set());
isAllSelected: boolean = $state(false);
isAllVisibleSelected: boolean = $state(false);
// ── Search ────────────────────────────────────────────────────
searchQuery: string = $state("");
filterDashboardIds: string[] = $state([]);
searchableDashboardOptions: Array<{id: string; name: string; subtitle: string; hint: string}> = $state([]);
searchableDashboardLoading: boolean = $state(false);
sortColumn: FilterColumn = $state("title");
sortDirection: SortDir = $state("asc");
openFilterColumn: FilterColumn | null = $state(null);
filterDropdownPosition: { left: number; top: number } = $state({ left: 0, top: 0 });
columnFilterSearch: ColumnFilterSearch = $state({ title: "", git_status: "", validation_status: "", changed_on: "", actor: "" });
columnFilters: ColumnFilters = $state({ title: new Set(), git_status: new Set(), validation_status: new Set(), changed_on: new Set(), actor: new Set() });
// ── Validation popover ────────────────────────────────────────
validationStatuses: Record<string, any> = $state({});
openValidationPopover: string | null = $state(null);
validationPopoverPosition: { left: number; top: number } = $state({ left: 0, top: 0 });
validationStatusLoading: boolean = $state(false);
// ── Bulk modals ───────────────────────────────────────────────
showMigrateModal: boolean = $state(false);
showBackupModal: boolean = $state(false);
backupSchedule: string = $state("");
isSubmittingBackup: boolean = $state(false);
isEditingMappings: boolean = $state(false);
readonly bulkMigrateModel = new MigrationModel();
// ── Git ───────────────────────────────────────────────────────
openActionDropdown: string | null = $state(null);
gitBusyIds: Set<string> = $state(new Set());
gitResolvedIds: Set<string> = $state(new Set());
gitLoadingIds: Set<string> = $state(new Set());
cachedGitConfigs: any[] = $state([]);
dashboardsLoadSeq: number = $state(0);
lastLoadedEnvId: string | null = $state(null);
serverTotal: number = $state(0);
serverTotalPages: number = $state(1);
profileFilterOverrideShowAll: boolean = $state(false);
effectiveProfileFilter: any = $state(null);
// ── Derived (store-dependent — handled in page due to legacy store) ──────
// ═══ Data loading ══════════════════════════════════════════════
async loadDashboards(): Promise<void> {
if (!this.selectedEnv) return;
const requestSeq = this.dashboardsLoadSeq + 1;
this.dashboardsLoadSeq = requestSeq;
this.isLoading = true;
this.error = null;
try {
const firstResponse = await api.getDashboards(this.selectedEnv, {
page: this.currentPage, page_size: this.pageSize,
search: this.searchQuery.trim() || undefined,
page_context: "dashboards_main",
apply_profile_default: true,
override_show_all: this.profileFilterOverrideShowAll,
filters: {
title: Array.from(this.columnFilters.title || []),
git_status: Array.from(this.columnFilters.git_status || []),
changed_on: Array.from(this.columnFilters.changed_on || []),
actor: Array.from(this.columnFilters.actor || []),
},
});
if (requestSeq !== this.dashboardsLoadSeq) return;
const rawDashboards = firstResponse?.dashboards || [];
this.serverTotal = Number(firstResponse?.total || 0);
this.serverTotalPages = Math.max(1, Number(firstResponse?.total_pages || 1));
this.effectiveProfileFilter = firstResponse?.effective_profile_filter || null;
this.profileFilterOverrideShowAll = Boolean(firstResponse?.effective_profile_filter?.override_show_all);
this.gitResolvedIds = new Set();
this.gitLoadingIds = new Set();
this.allDashboards = rawDashboards.map((d: any) => {
const owners = normalizeOwners(d.owners);
const hasGitStatus = !!d.git_status;
return {
id: d.id, title: d.title, slug: d.slug,
changedOn: d.last_modified || null,
changedOnLabel: formatDate(d.last_modified),
owners,
actorLabel: owners.length > 0 ? owners.join(", ") : "-",
git: hasGitStatus ? {
status: d.git_status?.sync_status?.toLowerCase() || "no_repo",
branch: d.git_status?.branch || null,
hasRepo: d.git_status?.has_repo === true,
hasChangesForCommit: d.git_status?.has_changes_for_commit === true,
} : { status: "pending", branch: null, hasRepo: null, hasChangesForCommit: false },
lastTask: d.last_task ? {
status: normalizeTaskStatus(d.last_task.status),
validationStatus: normalizeValidationStatus(d.last_task.validation_status),
id: d.last_task.task_id,
} : null,
actions: ["migrate", "backup"],
};
});
this.selectedIds = new Set(
Array.from(this.selectedIds).filter(id => this.allDashboards.some(d => d.id === id))
);
this.applyGridTransforms();
this.updateSelectionState();
} catch (err: any) {
if (requestSeq !== this.dashboardsLoadSeq) return;
this.error = err.message || t.dashboard?.load_failed;
log('DashboardHub', 'EXPLORE', 'Load dashboards Error', {}, err?.message || 'unknown');
} finally {
if (requestSeq === this.dashboardsLoadSeq) this.isLoading = false;
}
}
async loadDashboardSearchOptions(query: string = ''): Promise<void> {
if (!this.selectedEnv) return;
this.searchableDashboardLoading = true;
try {
const response = await api.getDashboards(this.selectedEnv, {
search: query || undefined, page_size: 100, page_context: 'other',
apply_profile_default: false, override_show_all: true,
});
this.searchableDashboardOptions = (response?.dashboards || []).map((d: any) => ({
id: d.id, name: d.title, subtitle: d.slug || '',
hint: d.last_modified ? formatDate(d.last_modified) : '',
}));
} catch { this.searchableDashboardOptions = []; }
finally { this.searchableDashboardLoading = false; }
}
async fetchValidationStatuses(): Promise<void> {
if (!this.selectedEnv || this.dashboards.length === 0) return;
this.validationStatusLoading = true;
try {
const ids = this.dashboards.map(d => d.id).filter(Boolean);
if (ids.length === 0) return;
const result = await api.getValidationStatusBatch(this.selectedEnv, ids.join(","));
if (result && typeof result === "object") this.validationStatuses = result;
} catch { /* graceful no-op */ }
finally { this.validationStatusLoading = false; }
}
// ═══ Pagination ════════════════════════════════════════════════
setPage(page: number): void {
if (page === this.currentPage) return;
this.currentPage = page;
void this.loadDashboards();
}
setPageSize(event: Event): void {
this.pageSize = parseInt((event.target as HTMLSelectElement).value);
this.currentPage = 1;
void this.loadDashboards();
}
// ═══ Profile filter ════════════════════════════════════════════
handleTemporaryShowAll(): void {
if (this.profileFilterOverrideShowAll) return;
this.profileFilterOverrideShowAll = true;
this.currentPage = 1;
void this.loadDashboards();
}
handleRestoreProfileFilter(): void {
if (!this.profileFilterOverrideShowAll) return;
this.profileFilterOverrideShowAll = false;
this.currentPage = 1;
void this.loadDashboards();
}
// ═══ Selection ═════════════════════════════════════════════════
updateSelectionState(): void {
this.isAllSelected = this.selectedIds.size === this.filteredDashboards.length && this.filteredDashboards.length > 0;
this.isAllVisibleSelected = this.selectedIds.size === this.dashboards.length && this.dashboards.length > 0;
}
replaceSelectedIds(next: Set<string>): void { this.selectedIds = next; this.updateSelectionState(); }
clearSelectedIds(): void { this.replaceSelectedIds(new Set()); }
handleCheckboxChange(dashboard: DashboardRow, event: Event): void {
const next = new Set(this.selectedIds);
(event.target as HTMLInputElement).checked ? next.add(dashboard.id) : next.delete(dashboard.id);
this.replaceSelectedIds(next);
}
handleSelectAll(): void {
if (this.isAllSelected) { this.clearSelectedIds(); return; }
const next = new Set(this.selectedIds);
this.filteredDashboards.forEach(d => next.add(d.id));
this.replaceSelectedIds(next);
}
handleSelectVisible(): void {
const next = new Set(this.selectedIds);
if (this.isAllVisibleSelected) { this.dashboards.forEach(d => next.delete(d.id)); }
else { this.dashboards.forEach(d => next.add(d.id)); }
this.replaceSelectedIds(next);
}
// ═══ Column filters ═══════════════════════════════════════════
getValidationLabelForCell(dashboard: DashboardRow): string {
const vs = this.validationStatuses[dashboard.id];
return (!vs || !vs.status) ? "unknown" : String(vs.status).toLowerCase();
}
getColumnCellValue(dashboard: DashboardRow, column: FilterColumn): string {
if (column === "title") return dashboard.title || "-";
if (column === "git_status") return String(dashboard.git?.status || "pending").toLowerCase();
if (column === "validation_status") return this.getValidationLabelForCell(dashboard);
if (column === "changed_on") return dashboard.changedOn ? String(dashboard.changedOn).slice(0, 10) : "-";
if (column === "actor") return dashboard.actorLabel || "-";
return "-";
}
getFilterOptions(column: FilterColumn): string[] {
return Array.from(new Set(
this.allDashboards.map(d => this.getColumnCellValue(d, column)).filter(Boolean)
)).sort((a, b) => a.localeCompare(b, "ru"));
}
getVisibleFilterOptions(column: FilterColumn): string[] {
const searchText = (this.columnFilterSearch[column] || "").toLowerCase();
return this.getFilterOptions(column).filter(v => v.toLowerCase().includes(searchText));
}
toggleFilterDropdown(column: FilterColumn, event: Event | undefined, panelWidth = 256): void {
event?.stopPropagation();
if (this.openFilterColumn === column) { this.openFilterColumn = null; return; }
const trigger = (event as any)?.currentTarget;
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
const vw = typeof window !== "undefined" ? window.innerWidth : 1920;
this.filterDropdownPosition = { left: Math.max(8, Math.min(rect.left, vw - panelWidth - 8)), top: rect.bottom + 8 };
}
this.openFilterColumn = column;
}
toggleFilterValue(column: FilterColumn, value: string, checked: boolean): void {
const next = new Set(this.columnFilters[column]);
checked ? next.add(value) : next.delete(value);
this.columnFilters = { ...this.columnFilters, [column]: next };
this.currentPage = 1;
void this.loadDashboards();
}
clearColumnFilter(column: FilterColumn): void {
this.columnFilters = { ...this.columnFilters, [column]: new Set() };
this.currentPage = 1;
void this.loadDashboards();
}
selectAllColumnFilterValues(column: FilterColumn): void {
this.columnFilters = { ...this.columnFilters, [column]: new Set(this.getVisibleFilterOptions(column)) };
this.currentPage = 1;
void this.loadDashboards();
}
updateColumnFilterSearch(column: FilterColumn, value: string): void {
this.columnFilterSearch = { ...this.columnFilterSearch, [column]: value };
}
hasColumnFilter(column: FilterColumn): boolean { return this.columnFilters[column]?.size > 0; }
// ═══ Sort ══════════════════════════════════════════════════════
getSortValue(dashboard: DashboardRow, column: FilterColumn): string | number {
if (column === "title") return (dashboard.title || "").toLowerCase();
if (column === "git_status") return (this.getGitSummaryLabel(dashboard) || "").toLowerCase();
if (column === "validation_status") return (this.getValidationLabelForCell(dashboard) || "").toLowerCase();
if (column === "changed_on") return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0;
if (column === "actor") return (dashboard.actorLabel || "").toLowerCase();
return "";
}
handleSort(column: FilterColumn): void {
if (this.sortColumn === column) {
this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc";
} else {
this.sortColumn = column;
this.sortDirection = "asc";
}
this.currentPage = 1;
this.applyGridTransforms();
}
// ═══ Grid transforms ═══════════════════════════════════════════
applyGridTransforms(): void {
let next = this.allDashboards;
if (this.filterDashboardIds.length > 0) {
next = next.filter(d => this.filterDashboardIds.includes(d.id));
}
next = [...next].sort((a, b) => {
const aV = this.getSortValue(a, this.sortColumn);
const bV = this.getSortValue(b, this.sortColumn);
if (aV < bV) return this.sortDirection === "asc" ? -1 : 1;
if (aV > bV) return this.sortDirection === "asc" ? 1 : -1;
return 0;
});
this.filteredDashboards = next;
this.dashboards = this.filteredDashboards;
this.total = this.serverTotal;
this.totalPages = this.serverTotalPages;
this.updateSelectionState();
void this.hydrateVisibleGitStatusesBatch();
void this.fetchValidationStatuses();
}
// ═══ Git helpers ═══════════════════════════════════════════════
getGitSummaryLabel(dashboard: DashboardRow): string {
if (dashboard.git?.hasRepo === null) return t.common?.loading || "Loading";
if (!dashboard.git?.hasRepo) return t.dashboard?.status_no_repo || "No Repo";
return dashboard.git?.hasChangesForCommit ? t.dashboard?.status_changes || "Diff" : t.dashboard?.status_no_changes || "Synced";
}
isGitBusy(dashboardId: string): boolean { return this.gitBusyIds.has(dashboardId); }
setGitBusy(dashboardId: string, busy: boolean): void {
if (busy) this.gitBusyIds.add(dashboardId); else this.gitBusyIds.delete(dashboardId);
this.gitBusyIds = new Set(this.gitBusyIds);
}
async ensureGitConfigs(): Promise<any[]> {
if (this.cachedGitConfigs.length > 0) return this.cachedGitConfigs;
this.cachedGitConfigs = await gitService.getConfigs();
return this.cachedGitConfigs;
}
updateDashboardGitState(dashboardId: string, nextGit: Partial<GitState>): void {
const merge = (col: DashboardRow[]) => col.map(d => d.id === dashboardId ? { ...d, git: { ...d.git, ...nextGit } } : d);
this.allDashboards = merge(this.allDashboards);
this.filteredDashboards = merge(this.filteredDashboards);
this.dashboards = merge(this.dashboards);
}
normalizeRepositoryStatusPayload(status: any): GitState {
const syncState = String(status?.sync_state || "").toUpperCase();
const syncStatus = String(status?.sync_status || "").toUpperCase();
const explicitHasRepo = status?.has_repo;
const hasRepo = explicitHasRepo === true ? true : explicitHasRepo === false ? false : !(syncState === "NO_REPO" || syncStatus === "NO_REPO");
if (!hasRepo) return { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false };
const hasChanges = syncState === "CHANGES" || syncStatus === "DIFF" || Boolean(status?.is_dirty) ||
(status?.untracked_files?.length || 0) > 0 || (status?.modified_files?.length || 0) > 0 || (status?.staged_files?.length || 0) > 0;
return { status: hasChanges ? "diff" : "ok", branch: status?.current_branch || status?.branch || null, hasRepo: true, hasChangesForCommit: hasChanges };
}
async fetchDashboardGitStatusesBatch(dashboardIds: string[], force = false): Promise<void> {
const uniqueIds = Array.from(new Set(dashboardIds));
if (uniqueIds.length === 0) return;
if (force) { uniqueIds.forEach(id => this.gitResolvedIds.delete(id)); this.gitResolvedIds = new Set(this.gitResolvedIds); }
const pendingIds = uniqueIds.filter(id => !this.gitResolvedIds.has(id) && !this.gitLoadingIds.has(id));
if (pendingIds.length === 0) return;
pendingIds.forEach(id => this.gitLoadingIds.add(id));
this.gitLoadingIds = new Set(this.gitLoadingIds);
try {
const batchResult = await gitService.getStatusesBatch(pendingIds);
const statuses = batchResult?.statuses || {};
pendingIds.forEach(id => {
const st = statuses[id] ?? statuses[String(id)];
this.updateDashboardGitState(id, st ? this.normalizeRepositoryStatusPayload(st) : { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false });
});
} catch {
pendingIds.forEach(id => this.updateDashboardGitState(id, { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }));
} finally {
pendingIds.forEach(id => { this.gitLoadingIds.delete(id); this.gitResolvedIds.add(id); });
this.gitLoadingIds = new Set(this.gitLoadingIds);
this.gitResolvedIds = new Set(this.gitResolvedIds);
}
}
async refreshDashboardGitState(dashboardId: string, force = false): Promise<void> { await this.fetchDashboardGitStatusesBatch([dashboardId], force); }
async hydrateVisibleGitStatusesBatch(): Promise<void> {
const pending = this.dashboards.filter(d => d.git?.hasRepo === null && !this.gitResolvedIds.has(d.id) && !this.gitLoadingIds.has(d.id)).map(d => d.id);
if (pending.length > 0) await this.fetchDashboardGitStatusesBatch(pending, false);
}
async handleGitInit(dashboard: DashboardRow): Promise<void> {
this.setGitBusy(dashboard.id, true);
try {
const configs = await this.ensureGitConfigs();
if (!configs.length) { addToast(t.git?.no_servers_configured || "No Git config found", "error"); return; }
const config = configs[0];
const defaultRemote = config?.default_repository ? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git` : "";
const remoteUrl = prompt(t.git?.remote_url || "Remote URL", defaultRemote);
if (!remoteUrl) return;
await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim());
addToast(t.git?.init_success || "Repository initialized", "success");
await this.refreshDashboardGitState(dashboard.id, true);
} catch (err: any) { addToast(err?.message || "Git init failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitSync(dashboard: DashboardRow): Promise<void> {
this.setGitBusy(dashboard.id, true);
try { await gitService.sync(dashboard.id, this.selectedEnv || null); addToast(t.git?.sync_success || "Synced", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git sync failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitCommit(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) { addToast(t.git?.not_linked || "Repository not linked", "error"); return; }
if (!dashboard.git?.hasChangesForCommit) { addToast(t.git?.nothing_to_commit || "No changes to commit", "error"); return; }
const message = prompt(t.git?.commit_message || "Commit message", `Update dashboard ${dashboard.title}`);
if (!message?.trim()) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); addToast(t.git?.commit_success || "Committed", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git commit failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitPull(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.pull(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.pull_success || "Pulled", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git pull failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
async handleGitPush(dashboard: DashboardRow): Promise<void> {
if (!dashboard.git?.hasRepo) return;
this.setGitBusy(dashboard.id, true);
try { await gitService.push(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.push_success || "Pushed", "success"); await this.refreshDashboardGitState(dashboard.id, true); }
catch (err: any) { addToast(err?.message || "Git push failed", "error"); }
finally { this.setGitBusy(dashboard.id, false); }
}
// ═══ Validation popover ════════════════════════════════════════
toggleValidationPopover(dashboardId: string, event: Event): void {
event.stopPropagation();
if (this.openValidationPopover === dashboardId) { this.openValidationPopover = null; return; }
const trigger = event.currentTarget as HTMLElement;
if (trigger?.getBoundingClientRect) {
const rect = trigger.getBoundingClientRect();
const vw = typeof window !== "undefined" ? window.innerWidth : 1920;
this.validationPopoverPosition = { left: Math.max(8, Math.min(rect.left, vw - 320 - 8)), top: rect.bottom + 8 };
}
this.openValidationPopover = dashboardId;
}
// ═══ Actions ═══════════════════════════════════════════════════
toggleActionDropdown(id: string | null, event: Event): void {
event.stopPropagation();
this.openActionDropdown = this.openActionDropdown === id ? null : id;
}
closeActionDropdown(): void { this.openActionDropdown = null; }
async handleAction(dashboard: DashboardRow, action: string): Promise<void> {
log('DashboardHub', 'REASON', `${action} on dashboard`, { title: dashboard.title });
this.closeActionDropdown();
try {
if (action === "migrate") {
this.replaceSelectedIds(new Set([dashboard.id]));
this.showMigrateModal = true;
this.isEditingMappings = false;
this.bulkMigrateModel.sourceEnvId = this.selectedEnv ?? "";
this.bulkMigrateModel.selectedDashboardIds = Array.from(this.selectedIds);
this.bulkMigrateModel.targetEnvId = "";
this.bulkMigrateModel.dryRunResult = null;
this.bulkMigrateModel.error = "";
this.bulkMigrateModel.sourceDatabases = [];
this.bulkMigrateModel.targetDatabases = [];
this.bulkMigrateModel.mappings = [];
this.bulkMigrateModel.suggestions = [];
this.bulkMigrateModel.currentStep = 1;
} else if (action === "backup") {
this.replaceSelectedIds(new Set([dashboard.id]));
this.showBackupModal = true;
this.backupSchedule = "";
}
} catch (err: any) { log('DashboardHub', 'EXPLORE', 'Action Error', {}, err?.message || 'unknown'); }
}
async handleBulkBackup(): Promise<void> {
if (this.isSubmittingBackup || this.selectedIds.size === 0) return;
this.isSubmittingBackup = true;
try {
const response = await api.postApi("/dashboards/backup", { env_id: this.selectedEnv, dashboard_ids: Array.from(this.selectedIds), schedule: this.backupSchedule || undefined });
log('DashboardHub', 'REFLECT', 'Bulk backup task created', { task_id: response.task_id });
this.showBackupModal = false;
this.clearSelectedIds();
if (response.task_id) {
const taskId = typeof response.task_id === "object" ? response.task_id.id || response.task_id.task_id : response.task_id;
openDrawerForTaskIfPreferred(taskId);
}
} catch (err: any) {
log('DashboardHub', 'EXPLORE', 'Bulk backup error', {}, err?.message || 'unknown');
alert(t.dashboard?.backup_task_failed);
} finally { this.isSubmittingBackup = false; }
}
handleTaskStatusClick(dashboard: DashboardRow): void {
if (dashboard.lastTask?.id) {
log('DashboardHub', 'REASON', 'Open task drawer for task', { taskId: dashboard.lastTask.id });
openDrawerForTask(dashboard.lastTask.id);
}
}
navigateToDashboardDetail(dashboard: DashboardRow): void {
const ref = dashboard?.slug || dashboard?.id;
if (!ref || !this.selectedEnv) return;
goto(`/dashboards/${encodeURIComponent(String(ref))}?env_id=${encodeURIComponent(this.selectedEnv)}`);
}
}
// #endregion DashboardHubModel

View File

@@ -0,0 +1,45 @@
<!-- #region EmptyState [C:2] [TYPE Component] [SEMANTICS empty-state, zero-state, placeholder, ui, atom] -->
<!-- @BRIEF Standardized empty/zero-state placeholder with icon, title, description, and optional CTA button. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Empty -> Centered icon + message + optional action button rendered. -->
<!-- @RESTRICTION Icon is optional; falls back to a generic document icon. -->
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Button } from "$lib/ui";
let {
icon: iconSlot = null,
title = "",
description = "",
actionLabel = "",
actionVariant = "primary",
onAction,
class: className = "",
} = $props();
</script>
<div class={cn("flex flex-col items-center justify-center py-12 px-4 text-center", className)}>
{#if iconSlot}
<div class="text-text-subtle mb-4">
{@render iconSlot()}
</div>
{:else}
<svg class="w-16 h-16 text-text-subtle mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{/if}
{#if title}
<h3 class="text-lg font-semibold text-text mb-1">{title}</h3>
{/if}
{#if description}
<p class="text-sm text-text-muted max-w-md">{description}</p>
{/if}
{#if actionLabel && onAction}
<div class="mt-6">
<Button variant={actionVariant} onclick={onAction}>
{actionLabel}
</Button>
</div>
{/if}
</div>
<!-- #endregion EmptyState -->

View File

@@ -0,0 +1,68 @@
<!-- #region ColumnFilterPopover [C:3] [TYPE Component] [SEMANTICS dashboard, filter, popover, column] -->
<!-- @BRIEF Reusable column filter dropdown for dashboard grid — search input, select-all, clear, and checkbox list. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Closed -> Popover hidden. -->
<!-- @UX_STATE Open -> Popover visible at positioned coordinates with search + options. -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
let {
column,
filterValues = $bindable(new Set<string>()),
searchText = $bindable(""),
options = [] as string[],
position = { left: 0, top: 0 },
widthPx = 256,
showSearch = true,
staticOptions = [] as string[],
onToggleFilter,
onClear,
} = $props<{
column: string;
filterValues: Set<string>;
searchText: string;
options: string[];
position: { left: number; top: number };
widthPx?: number;
showSearch?: boolean;
staticOptions?: string[];
onToggleFilter?: (value: string, checked: boolean) => void;
onClear?: () => void;
}>();
</script>
<div
class="fixed z-50 rounded-lg border border-border bg-surface-card shadow-lg p-3"
style="left: {position.left}px; top: {position.top}px; width: {widthPx}px;"
>
{#if showSearch}
<input
type="text"
class="w-full rounded border border-border-strong px-2 py-1 text-xs"
placeholder={$t.dashboard?.search}
bind:value={searchText}
/>
<div class="mt-2 flex items-center justify-between text-xs">
<button class="text-primary hover:underline" onclick={() => {
// Select all currently visible options
for (const v of options) filterValues.add(v);
}}>{$t.dashboard?.select_all}</button>
<button class="text-text-muted hover:underline" onclick={() => onClear?.()}>
{$t.common?.clear || "Clear"}
</button>
</div>
{/if}
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
{#each (showSearch ? options : staticOptions) as value}
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={filterValues.has(value)}
onchange={(event) => onToggleFilter?.(value, (event.target as HTMLInputElement).checked)}
/>
<span class="truncate">{value}</span>
</label>
{/each}
</div>
</div>
<!-- #endregion ColumnFilterPopover -->

View File

@@ -0,0 +1,193 @@
<!-- #region DashboardRow [C:3] [TYPE Component] [SEMANTICS dashboard, row, grid, git, validation] -->
<!-- @BRIEF Single row in the DashboardHub grid — checkbox, title, git status, validation indicator, actions, changed date, owners. -->
<!-- @LAYER UI -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import DashboardMaintenanceBadge from "$lib/components/DashboardMaintenanceBadge.svelte";
import { formatDate } from "./dashboard-helpers.js";
let {
dashboard,
isSelected,
validationStatus,
validationStatusLoading,
isValidationPopoverOpen,
validationPopoverPosition,
onCheckboxChange,
onNavigate,
onToggleValidationPopover,
onAction,
onTaskStatusClick,
onGitInit,
onGitSync,
onGitCommit,
onGitPull,
onGitPush,
isGitBusy = false,
} = $props<{
dashboard: any;
isSelected: boolean;
validationStatus: any;
validationStatusLoading: boolean;
isValidationPopoverOpen: boolean;
validationPopoverPosition: { left: number; top: number };
onCheckboxChange: (e: Event) => void;
onNavigate: () => void;
onToggleValidationPopover: (e: Event) => void;
onAction: (action: string) => void;
onTaskStatusClick?: () => void;
onGitInit?: () => void;
onGitSync?: () => void;
onGitCommit?: () => void;
onGitPull?: () => void;
onGitPush?: () => void;
isGitBusy?: boolean;
}>();
</script>
<div
class="grid items-center gap-4 px-6 py-3 border-b border-border last:border-b-0 hover:bg-surface-muted transition-colors text-[13px]"
style="width: max(100%, 1460px); grid-template-columns: 40px minmax(250px,2.3fr) minmax(118px,0.9fr) minmax(70px,0.5fr) minmax(110px,0.7fr) minmax(280px,2fr) minmax(150px,1fr) 124px;"
>
<!-- Checkbox -->
<div>
<input type="checkbox" checked={isSelected} onchange={onCheckboxChange} />
</div>
<!-- Title -->
<div class="font-medium text-sm text-text">
<button
class="text-left text-primary hover:text-primary-hover hover:underline transition-colors"
onclick={onNavigate}
title={$t.dashboard?.open_overview}
>
{dashboard.title}
</button>
</div>
<!-- Git Status -->
<div>
<div class="flex flex-col gap-1">
<span class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard.git?.hasRepo === null ? 'bg-surface-muted text-text-muted' : dashboard.git?.hasRepo ? 'bg-primary-light text-primary' : 'bg-surface-muted text-text'}">
{dashboard.git?.hasRepo === null ? $t.common?.loading || "Loading" : dashboard.git?.hasRepo ? $t.dashboard?.status_repo || "Repo" : $t.dashboard?.status_no_repo || "No Repo"}
</span>
{#if dashboard.git?.hasRepo}
<span class="inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {dashboard.git?.hasChangesForCommit ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}">
{dashboard.git?.hasChangesForCommit ? $t.dashboard?.status_changes || "Diff" : $t.dashboard?.status_no_changes || "Synced"}
</span>
{/if}
</div>
</div>
<!-- Validation Status Dot -->
<div class="relative">
{#if validationStatus}
{@const vs = validationStatus}
{@const dotClass = vs.status === "PASS" ? "bg-emerald-400" : vs.status === "WARN" ? "bg-amber-400" : vs.status === "FAIL" ? "bg-rose-400" : "bg-slate-300"}
<button
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-border-strong transition-colors hover:ring-2 hover:ring-border-strong"
onclick={onToggleValidationPopover}
title={vs.status || "UNKNOWN"}
aria-label={$t.dashboard?.validation_status || "Validation status"}
>
<span class="block h-3 w-3 rounded-full {dotClass}"></span>
</button>
{:else if validationStatusLoading}
<span class="inline-flex h-5 w-5 items-center justify-center">
<span class="block h-3 w-3 animate-pulse rounded-full bg-surface-muted"></span>
</span>
{:else}
<span class="text-text-subtle text-xs">-</span>
{/if}
<!-- Validation Popover -->
{#if isValidationPopoverOpen}
<div
class="fixed z-50 w-80 rounded-lg border border-border bg-surface-card shadow-lg"
style="left: {validationPopoverPosition.left}px; top: {validationPopoverPosition.top}px;"
onclick={(e) => e.stopPropagation()}
>
<div class="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
{$t.dashboard?.validation_history || "Validation History"}
</div>
{#if validationStatus?.history?.length > 0}
<div class="max-h-48 overflow-y-auto">
{#each validationStatus.history.slice(0, 3) as run}
<div class="flex items-center justify-between px-3 py-2 text-xs border-b border-border last:border-b-0">
<div class="flex items-center gap-2">
<span class="block h-2.5 w-2.5 rounded-full {run.status === 'PASS' ? 'bg-emerald-400' : run.status === 'WARN' ? 'bg-amber-400' : run.status === 'FAIL' ? 'bg-rose-400' : 'bg-slate-300'}"></span>
<span class="text-text">{run.status || "UNKNOWN"}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-subtle">{run.task_name || "-"}</span>
<span class="text-text-subtle">{formatDate(run.last_run_at)}</span>
</div>
</div>
{/each}
</div>
{:else}
<div class="px-3 py-3 text-xs text-text-subtle text-center">
{$t.dashboard?.no_validation_runs || "No validation runs yet"}
</div>
{/if}
<div class="border-t border-border px-3 py-2">
<a href={`/validation-tasks?search=${dashboard.id}`} class="text-xs text-primary hover:text-primary-hover hover:underline">
{$t.dashboard?.view_all_runs || "View all runs →"}
</a>
</div>
</div>
{/if}
</div>
<!-- Maintenance Badge -->
<div>
<DashboardMaintenanceBadge dashboardId={dashboard.id} />
</div>
<!-- Actions -->
<div class="flex items-center">
<div class="flex items-center gap-1.5">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-primary hover:bg-primary-light hover:border-primary-ring transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => onAction("migrate")}
title={$t.dashboard?.action_migrate}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-surface-card text-warning hover:bg-warning-light hover:border-warning transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => onAction("backup")}
title={$t.dashboard?.action_backup}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
</div>
<!-- Changed On -->
<div class="text-xs text-text-muted">
{dashboard.changedOnLabel}
</div>
<!-- Owners -->
<div class="text-xs">
{#if dashboard.owners?.length > 0}
<div class="flex flex-wrap items-center gap-1">
{#each dashboard.owners as owner (owner)}
<span class="rounded bg-surface-muted px-2 py-0.5 text-xs text-text truncate max-w-[180px]" title={owner}>
{owner}
</span>
{/each}
</div>
{:else}
<div class="text-text-subtle">-</div>
{/if}
</div>
</div>
<!-- #endregion DashboardRow -->