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:
@@ -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[];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user