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:
877
frontend/ai-native-design-audit.md
Normal file
877
frontend/ai-native-design-audit.md
Normal 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+ страниц.*
|
||||
224
frontend/scripts/audit-frontend-style.mjs
Normal file
224
frontend/scripts/audit-frontend-style.mjs
Normal 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
|
||||
586
frontend/src/lib/models/DashboardHubModel.svelte.ts
Normal file
586
frontend/src/lib/models/DashboardHubModel.svelte.ts
Normal 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
|
||||
45
frontend/src/lib/ui/EmptyState.svelte
Normal file
45
frontend/src/lib/ui/EmptyState.svelte
Normal 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 -->
|
||||
68
frontend/src/routes/dashboards/ColumnFilterPopover.svelte
Normal file
68
frontend/src/routes/dashboards/ColumnFilterPopover.svelte
Normal 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 -->
|
||||
193
frontend/src/routes/dashboards/DashboardRow.svelte
Normal file
193
frontend/src/routes/dashboards/DashboardRow.svelte
Normal 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 -->
|
||||
Reference in New Issue
Block a user