feat(web): enhance operations UI with new job status handling and component updates

- Added a new function for determining job status badge variants to improve UI consistency.
- Refactored imports to utilize the core UI library for better maintainability.
- Updated job report and filters components to enhance user experience and streamline functionality.
- Improved layout and responsiveness across operations-related components.
This commit is contained in:
Denozordec
2026-05-20 12:14:33 +07:00
parent ce747673fd
commit ab8660ac42
11 changed files with 475 additions and 326 deletions
@@ -1,13 +1,15 @@
<script lang="ts">
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { formatDateTime } from '$lib/modules/display.js';
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = {
@@ -19,7 +21,6 @@
onDiffRevAChange: (value: string) => void;
onDiffRevBChange: (value: string) => void;
onLoadDiff: () => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -30,11 +31,13 @@
diffLoading,
onDiffRevAChange,
onDiffRevBChange,
onLoadDiff,
formatDate
onLoadDiff
}: Props = $props();
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
function revisionLabel(rev: RevisionRow): string {
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
}
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
@@ -82,26 +85,36 @@
</CardHeader>
<CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row">
<select
value={diffRevA}
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия A</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
<select
value={diffRevB}
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия B</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevA
? revisions.find((r) => r.id === diffRevA)
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
: diffRevA
: 'Ревизия A'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия A</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevB
? revisions.find((r) => r.id === diffRevB)
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
: diffRevB
: 'Ревизия B'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия B</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 self-start sm:self-auto"
@@ -117,10 +130,9 @@
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
style="scrollbar-gutter: stable;"
>
<!-- + Добавлено -->
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
<span>Добавлено ({addedSorted.length})</span>
@@ -137,7 +149,7 @@
<tbody>
{#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -157,10 +169,9 @@
</div>
</div>
<!-- Удалено -->
<div class="flex min-h-0 min-w-0 flex-col">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none"></span>
<span>Удалено ({removedSorted.length})</span>
@@ -177,7 +188,7 @@
<tbody>
{#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
@@ -2,15 +2,17 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { JobRow } from '$lib/api/types.js';
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
@@ -49,7 +51,6 @@
getJobLogEntries: (job: JobRow) => JobLogEntry[];
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
formatDate: (d?: string | null) => string;
};
let {
@@ -69,8 +70,7 @@
isJobExpanded,
getJobLogEntries,
getJobLogTotal,
jobStatusVariant,
formatDate
jobStatusVariant
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
@@ -178,7 +178,7 @@
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDate(job.created_at)}</p>
<p class="mt-1">{formatDateTime(job.created_at)}</p>
</div>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
@@ -187,7 +187,7 @@
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDate(job.started_at)}</p>
<p class="mt-1">{formatDateTime(job.started_at)}</p>
</div>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
@@ -196,7 +196,7 @@
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDate(job.finished_at)}</p>
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
</div>
</div>
</div>
@@ -450,9 +450,14 @@
{/if}
</div>
{:else}
<div class="text-muted-foreground py-10 text-center text-sm">
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
</div>
{#if jobsLoading}
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<EmptyState
title="Нет задач"
description="Задачи появятся после refresh, apply или rollback."
/>
{/if}
{/each}
</CardContent>
</Card>
@@ -1,8 +1,8 @@
<script lang="ts">
import type { BirdStatus } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card } from '$lib/ui/core/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
@@ -114,9 +114,14 @@
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
<Badge
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
class={birdStatus.healthy === true
? 'border-success/30 bg-success/15 text-success'
: undefined}
>
{birdHealthyShortLabel(birdStatus.healthy)}
</Badge>
{/if}
</div>
<p class="max-w-[56ch] text-sm text-foreground/80">
@@ -1,21 +1,15 @@
<script lang="ts">
import type { RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Undo from '@lucide/svelte/icons/undo';
import Eye from '@lucide/svelte/icons/eye';
@@ -28,7 +22,6 @@
onOpenPreview: (rev: RevisionRow) => void;
onRollbackRequest: (rev: RevisionRow) => void;
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -37,9 +30,31 @@
onReload,
onOpenPreview,
onRollbackRequest,
onDownloadDiagnosticLog,
formatDate
onDownloadDiagnosticLog
}: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'hash', label: 'Хэш' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
</script>
<Card>
@@ -61,55 +76,45 @@
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="min-w-0 p-0">
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
<Table class="min-w-[44rem]">
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Хэш</TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each revisions as rev (rev.id)}
<TableRow>
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}</TableCell>
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
<TableCell>{rev.materialized_prefix_count}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={revisions}
rowKey={(rev) => rev.id}
loading={revLoading}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
{:else if column.id === 'prefixes'}
{rev.materialized_prefix_count}
{:else if column.id === 'hash'}
<span class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</span
>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -6,9 +6,9 @@
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
import * as Table from '$lib/ui/core/table/index.js';
import { Button } from '$lib/ui/core/button/index.js';
type Props = {
rows: RowData[];