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[];
+12 -2
View File
@@ -1,5 +1,3 @@
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
export function jobStatusRu(status: string): string {
switch (status) {
case 'queued':
@@ -17,6 +15,18 @@ export function jobStatusRu(status: string): string {
}
}
/** Variant Badge для статуса задачи (shadcn). */
export function jobStatusBadgeVariant(
status: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
const lower = status.toLowerCase();
if (lower === 'succeeded') return 'default';
if (lower === 'running' || lower === 'queued') return 'secondary';
if (lower === 'failed' || lower === 'error' || lower === 'canceled' || lower === 'cancelled')
return 'destructive';
return 'outline';
}
/** Подпись типа задачи для фильтров (значения API те же). */
export function jobKindFilterRu(kind: string): string {
switch (kind) {
+1 -4
View File
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Zap from '@lucide/svelte/icons/zap';
export type NavItem = {
href: string;
label: string;
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
{ href: '/modules', label: 'Модули', icon: Boxes },
{ href: '/directories', label: 'Справочники', icon: BookOpen },
{ href: '/network', label: 'Сеть', icon: Network },
{ href: '/revisions', label: 'Ревизии', icon: Activity },
{ href: '/operations', label: 'Операции', icon: Zap },
{ href: '/operations', label: 'Ревизии', icon: Activity },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
];
+314 -191
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import {
apiFetch,
@@ -23,28 +26,28 @@
ModulesResponse
} from '$lib/api/types.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '$lib/components/ui/alert-dialog/index.js';
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
@@ -56,6 +59,10 @@
ReportRow
} from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import {
dialogBodyDocument,
dialogBodyPanel,
@@ -65,14 +72,23 @@
dialogHeaderPanel
} from '$lib/dialog-layout.js';
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import Zap from '@lucide/svelte/icons/zap';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Activity from '@lucide/svelte/icons/activity';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Clock from '@lucide/svelte/icons/clock';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import Info from '@lucide/svelte/icons/info';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
type OpsTab = 'revisions' | 'diff' | 'jobs';
function parseOpsTab(value: string | null): OpsTab {
if (value === 'diff' || value === 'jobs') return value;
return 'revisions';
}
// Revisions
let revisions = $state<RevisionRow[]>([]);
let revLoading = $state(false);
let rollbackTarget = $state<RevisionRow | null>(null);
let rollingBack = $state(false);
// Preview/Prefixes
@@ -115,8 +131,6 @@
let jobFilterModule = $state('');
let jobActiveOnly = $state(false);
let moduleNameById = $state(new Map<string, string>());
let cancelTarget = $state<JobRow | null>(null);
let cancelling = $state(false);
let jobDetailDialog = $state(false);
let jobDetail = $state<JobRow | null>(null);
let expandedJobIds = new SvelteSet<string>();
@@ -128,14 +142,18 @@
// Global apply / bird reload
let applying = $state(false);
let reloading = $state(false);
let applyConfirm = $state(false);
let reloadConfirm = $state(false);
// BIRD runtime status (birdc на хосте API, если настроен сокет)
let birdStatus = $state<BirdStatus | null>(null);
let birdLoading = $state(false);
let birdProtocolsOpen = $state(false);
let activeTab = $state<OpsTab>('revisions');
let initialLoading = $state(true);
let refreshing = $state(false);
let lastUpdated = $state<Date | null>(null);
let tabSyncReady = $state(false);
function summarizeJobBirdMeta(job: JobRow): string {
const check = job.meta?.bird_post_apply_check;
if (check === 'skipped_no_birdc_socket') {
@@ -156,7 +174,7 @@
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
} catch (e) {
birdStatus = null;
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
birdLoading = false;
}
@@ -168,7 +186,7 @@
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
revisions = r.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
revLoading = false;
}
@@ -211,7 +229,7 @@
link.remove();
URL.revokeObjectURL(objectUrl);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
}
}
@@ -225,7 +243,7 @@
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
jobs = j.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobsLoading = false;
}
@@ -314,17 +332,149 @@
}
moduleNameById = m;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
}
}
onMount(() => {
loadRevisions();
loadJobs();
loadBirdStatus();
loadModules();
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
void refreshAll(true);
});
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const runningJobsCount = $derived(
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
);
const failedJobsCount = $derived(
jobs.filter((j) => {
const s = String(j.status ?? '').toLowerCase();
return s === 'failed' || s === 'error' || s === 'canceled' || s === 'cancelled';
}).length
);
const kpiCards = $derived.by(() => [
{
id: 'revisions',
label: 'Ревизий',
value: initialLoading ? '—' : String(revisions.length),
description: 'в последней выборке',
icon: Activity,
accent: statAccents[0],
badge: 'история конфигов'
},
{
id: 'running',
label: 'Активных задач',
value: initialLoading ? '—' : String(runningJobsCount),
description: 'queued и running',
icon: Clock,
accent: statAccents[1],
badge: 'в работе'
},
{
id: 'failed',
label: 'Задач с ошибкой',
value: initialLoading ? '—' : String(failedJobsCount),
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
icon: AlertTriangle,
accent: statAccents[2],
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
badgeClass:
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
href: failedJobsCount > 0 ? ('/schedule' as const) : undefined
}
]);
function syncTabToUrl(tab: OpsTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'revisions') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
const next = `${url.pathname}${url.search}${url.hash}`;
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
}
}
$effect(() => {
if (!tabSyncReady) return;
syncTabToUrl(activeTab);
});
async function refreshAll(isInitial = false) {
if (isInitial) initialLoading = true;
else refreshing = true;
await Promise.all([loadRevisions(), loadJobs(), loadBirdStatus(), loadModules()]);
lastUpdated = new Date();
initialLoading = false;
refreshing = false;
}
function requestApply() {
void confirm({
title: 'Применить конфигурацию на всех спикерах?',
description:
'Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.',
confirmLabel: 'Применить',
onConfirm: doApply
});
}
function requestReload() {
void confirm({
title: 'Перезагрузить BIRD?',
description: 'BIRD перезагрузит конфигурацию. Требуется роль operator.',
confirmLabel: 'Перезагрузить',
onConfirm: doBirdReload
});
}
function requestRollback(rev: RevisionRow) {
void confirm({
title: `Откатиться к ревизии ${rev.id.slice(0, 8)}…?`,
description: 'Будет создана новая ревизия на основе выбранной. Требуется роль operator.',
confirmLabel: 'Откатить',
destructive: true,
onConfirm: async () => {
await rollback(rev);
}
});
}
function requestCancelJob(job: JobRow) {
void confirm({
title: 'Отменить задачу?',
description: `Задача: ${jobKindTitle(job, moduleNameById)} (${job.job_id.slice(0, 8)}…)`,
confirmLabel: 'Отменить',
destructive: true,
onConfirm: async () => {
await cancelJob(job);
}
});
}
async function openPreview(rev: RevisionRow) {
previewRevision = rev;
previewLoading = true;
@@ -343,7 +493,7 @@
const frags = asPreviewFragments(prev);
birdPreviewPath = defaultBirdPreviewPath(frags);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
previewLoading = false;
}
@@ -351,29 +501,29 @@
async function loadDiff() {
if (!diffRevA || !diffRevB) {
toast.error('Выберите две ревизии');
notify.error('Выберите две ревизии');
return;
}
diffLoading = true;
try {
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
diffLoading = false;
}
}
async function rollback() {
if (!rollbackTarget) return;
async function rollback(rev: RevisionRow) {
rollingBack = true;
try {
await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {});
toast.success('Откат выполнен');
rollbackTarget = null;
await apiMutate(`/v1/revisions/${rev.id}/rollback`, 'POST', {});
notify.success('Откат выполнен');
await loadRevisions();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
throw e;
} finally {
rollingBack = false;
}
}
@@ -383,23 +533,22 @@
try {
const revId = revisions[0]?.id;
if (!revId) {
toast.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
notify.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
return;
}
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
revision_id: revId
});
applyConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
notify.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
notify.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
} else {
toast.error(
notify.error(
job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
@@ -408,7 +557,7 @@
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
applying = false;
}
@@ -418,19 +567,18 @@
reloading = true;
try {
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
reloadConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
notify.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(
notify.success(
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
);
} else {
toast.error(
notify.error(
job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
@@ -439,24 +587,20 @@
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
reloading = false;
}
}
async function cancelJob() {
if (!cancelTarget) return;
cancelling = true;
async function cancelJob(job: JobRow) {
try {
await apiMutate(`/v1/jobs/${cancelTarget.job_id}/cancel`, 'POST', {});
toast.success('Задача отменена');
cancelTarget = null;
await apiMutate(`/v1/jobs/${job.job_id}/cancel`, 'POST', {});
notify.success('Задача отменена');
await loadJobs();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
cancelling = false;
notifyApiError(e);
throw e;
}
}
@@ -487,7 +631,7 @@
jobDetailsById.set(jobId, freshJob);
await ensureJobReport(freshJob);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobDetailsLoading.delete(jobId);
}
@@ -625,7 +769,7 @@
ipRanges
});
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
jobReportsLoading.delete(jobId);
}
@@ -703,18 +847,6 @@
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
}
function jobStatusVariant(status: string): 'default' | 'secondary' | 'outline' | 'destructive' {
if (status === 'succeeded') return 'default';
if (status === 'running') return 'secondary';
if (status === 'failed') return 'destructive';
return 'outline';
}
function formatDate(d?: string | null) {
if (!d) return '—';
return new Date(d).toLocaleString('ru');
}
function birdHealthyBadgeVariant(
h: boolean | null | undefined
): 'default' | 'secondary' | 'outline' | 'destructive' {
@@ -724,7 +856,7 @@
}
function birdHealthyShortLabel(h: boolean | null | undefined): string {
if (h === true) return 'ОК';
if (h === true) return 'В норме';
if (h === false) return 'Проблема';
return 'Н/Д';
}
@@ -732,31 +864,100 @@
<div class="flex flex-col gap-6">
<PageHeader
title="Операции"
description="Деплой конфигурации, управление ревизиями и задачами."
icon={Zap}
title="Ревизии и операции"
description={lastUpdated
? `Деплой, сравнение конфигураций и задачи. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Деплой конфигурации, управление ревизиями и задачами.'}
icon={Activity}
iconClass="bg-chart-2/15 text-chart-2"
/>
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={() => refreshAll()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
префиксов;
<strong>Задачи</strong> — ingest, apply, rollback. Apply и Reload требуют operator. Сводный
мониторинг BGP — на
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<OperationsQuickActions
{applying}
{reloading}
{birdLoading}
{birdStatus}
onApply={() => (applyConfirm = true)}
onReload={() => (reloadConfirm = true)}
onApply={requestApply}
onReload={requestReload}
onRefreshBirdStatus={loadBirdStatus}
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
{birdHealthyBadgeVariant}
{birdHealthyShortLabel}
/>
<Tabs value="revisions">
<Tabs bind:value={activeTab}>
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
</TabsList>
</div>
@@ -766,9 +967,8 @@
{revLoading}
onReload={loadRevisions}
onOpenPreview={openPreview}
onRollbackRequest={(rev) => (rollbackTarget = rev)}
onRollbackRequest={requestRollback}
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
{formatDate}
/>
</TabsContent>
@@ -782,7 +982,6 @@
onDiffRevAChange={(value) => (diffRevA = value)}
onDiffRevBChange={(value) => (diffRevB = value)}
onLoadDiff={loadDiff}
{formatDate}
/>
</TabsContent>
@@ -815,103 +1014,17 @@
{jobReportsLoading}
onReloadJobs={loadJobs}
onOpenJobDetail={openJobDetail}
onRequestCancelJob={(job) => (cancelTarget = job)}
onRequestCancelJob={requestCancelJob}
onToggleJobExpanded={toggleJobExpanded}
{isJobExpanded}
{getJobLogEntries}
{getJobLogTotal}
{jobStatusVariant}
{formatDate}
jobStatusVariant={jobStatusBadgeVariant}
/>
</TabsContent>
</Tabs>
</div>
<!-- Apply confirm -->
<AlertDialog bind:open={applyConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Применить конфигурацию на всех спикерах?</AlertDialogTitle>
<AlertDialogDescription
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doApply} disabled={applying}
>{applying ? 'Применение…' : 'Применить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- BIRD reload confirm -->
<AlertDialog bind:open={reloadConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Перезагрузить BIRD?</AlertDialogTitle>
<AlertDialogDescription
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doBirdReload} disabled={reloading}
>{reloading ? 'Перезагрузка…' : 'Перезагрузить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Rollback confirm -->
<AlertDialog
open={!!rollbackTarget}
onOpenChange={(v) => {
if (!v) rollbackTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
<AlertDialogDescription
>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={rollback} disabled={rollingBack}
>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Cancel job confirm -->
<AlertDialog
open={!!cancelTarget}
onOpenChange={(v) => {
if (!v) cancelTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Отменить задачу?</AlertDialogTitle>
<AlertDialogDescription>
Задача: {cancelTarget ? jobKindTitle(cancelTarget, moduleNameById) : ''} ({cancelTarget?.job_id?.slice(
0,
8
)}…)
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
<AlertDialogAction onclick={cancelJob} disabled={cancelling}
>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Preview dialog -->
<Dialog bind:open={previewDialog}>
<DialogContent class={dialogContentDocument}>
@@ -922,7 +1035,10 @@
</DialogDescription>
</DialogHeader>
{#if previewLoading}
<div class="px-6 py-10 text-center text-sm text-muted-foreground">Загрузка…</div>
<div class="space-y-3 px-6 py-6">
<Skeleton class="h-9 w-full max-w-md" />
<Skeleton class="h-48 w-full" />
</div>
{:else}
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
@@ -941,16 +1057,23 @@
</p>
{:else}
<div class="flex shrink-0 flex-wrap items-center gap-2">
<label for="bird-frag" class="text-sm text-muted-foreground">Файл</label>
<select
id="bird-frag"
class="max-w-xl min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
bind:value={birdPreviewPath}
<span class="text-sm text-muted-foreground">Файл</span>
<Select
type="single"
value={birdPreviewPath}
onValueChange={(v) => {
if (v) birdPreviewPath = v;
}}
>
{#each Object.keys(frags).sort() as path (path)}
<option value={path}>{path}</option>
{/each}
</select>
<SelectTrigger class="max-w-xl min-w-0 flex-1">
{birdPreviewPath || 'Выберите файл'}
</SelectTrigger>
<SelectContent>
{#each Object.keys(frags).sort() as path (path)}
<SelectItem value={path}>{path}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<p class="shrink-0 text-xs text-muted-foreground">
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
@@ -1021,18 +1144,18 @@
>{jobDetail.job_id}</span
>
<span class="text-muted-foreground">Статус</span><span
><Badge variant={jobStatusVariant(jobDetail.status)}
><Badge variant={jobStatusBadgeVariant(jobDetail.status)}
>{jobStatusRu(jobDetail.status)}</Badge
></span
>
<span class="text-muted-foreground">Создана</span><span
>{formatDate(jobDetail.created_at)}</span
>{formatDateTime(jobDetail.created_at)}</span
>
<span class="text-muted-foreground">Начата</span><span
>{formatDate(jobDetail.started_at)}</span
>{formatDateTime(jobDetail.started_at)}</span
>
<span class="text-muted-foreground">Завершена</span><span
>{formatDate(jobDetail.finished_at)}</span
>{formatDateTime(jobDetail.finished_at)}</span
>
{#if jobDetail.error}
<span class="text-muted-foreground">Ошибка</span><span
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
goto(resolve('/operations'));
goto(`${resolve('/operations')}?tab=revisions`, { replaceState: true });
</script>
+2 -10
View File
@@ -9,7 +9,7 @@
moduleTypeBadgeVariant
} from '$lib/modules/display.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
import { jobStatusRu, jobStatusBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
@@ -158,14 +158,6 @@
}
]);
function jobStatusVariant(s: string): 'default' | 'secondary' | 'outline' | 'destructive' {
const lower = s.toLowerCase();
if (lower === 'succeeded') return 'default';
if (lower === 'running' || lower === 'queued') return 'secondary';
if (lower === 'failed' || lower === 'error' || lower === 'canceled') return 'destructive';
return 'outline';
}
function truncateError(error: string | null | undefined, max = 120): string {
if (!error) return '';
return error.length > max ? `${error.slice(0, max)}…` : error;
@@ -385,7 +377,7 @@
{#if column.id === 'kind'}
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
{:else if column.id === 'status'}
<Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
{:else if column.id === 'created'}
<span class="text-xs whitespace-nowrap text-muted-foreground"
>{formatDateTime(j.created_at)}</span