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">
|
<script lang="ts">
|
||||||
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
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';
|
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,7 +21,6 @@
|
|||||||
onDiffRevAChange: (value: string) => void;
|
onDiffRevAChange: (value: string) => void;
|
||||||
onDiffRevBChange: (value: string) => void;
|
onDiffRevBChange: (value: string) => void;
|
||||||
onLoadDiff: () => void;
|
onLoadDiff: () => void;
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,11 +31,13 @@
|
|||||||
diffLoading,
|
diffLoading,
|
||||||
onDiffRevAChange,
|
onDiffRevAChange,
|
||||||
onDiffRevBChange,
|
onDiffRevBChange,
|
||||||
onLoadDiff,
|
onLoadDiff
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: 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)[] {
|
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
||||||
@@ -82,26 +85,36 @@
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-h-0 space-y-4">
|
<CardContent class="min-h-0 space-y-4">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<select
|
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
|
||||||
value={diffRevA}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
{diffRevA
|
||||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
? revisions.find((r) => r.id === diffRevA)
|
||||||
>
|
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
|
||||||
<option value="">Ревизия A</option>
|
: diffRevA
|
||||||
{#each revisions as rev (rev.id)}
|
: 'Ревизия A'}
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
</SelectTrigger>
|
||||||
{/each}
|
<SelectContent>
|
||||||
</select>
|
<SelectItem value="">Ревизия A</SelectItem>
|
||||||
<select
|
{#each revisions as rev (rev.id)}
|
||||||
value={diffRevB}
|
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
{/each}
|
||||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
<option value="">Ревизия B</option>
|
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
|
||||||
{#each revisions as rev (rev.id)}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
{diffRevB
|
||||||
{/each}
|
? revisions.find((r) => r.id === diffRevB)
|
||||||
</select>
|
? 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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0 self-start sm:self-auto"
|
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"
|
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
||||||
style="scrollbar-gutter: stable;"
|
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 min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
|
||||||
<div
|
<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 class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
||||||
<span>Добавлено ({addedSorted.length})</span>
|
<span>Добавлено ({addedSorted.length})</span>
|
||||||
@@ -137,7 +149,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
||||||
<tr
|
<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
|
<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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- − Удалено -->
|
|
||||||
<div class="flex min-h-0 min-w-0 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-col">
|
||||||
<div
|
<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 class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
||||||
<span>Удалено ({removedSorted.length})</span>
|
<span>Удалено ({removedSorted.length})</span>
|
||||||
@@ -177,7 +188,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
||||||
<tr
|
<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
|
<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"
|
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">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
import Filter from '@lucide/svelte/icons/filter';
|
import Filter from '@lucide/svelte/icons/filter';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { JobRow } from '$lib/api/types.js';
|
import type { JobRow } from '$lib/api/types.js';
|
||||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
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 RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
@@ -49,7 +51,6 @@
|
|||||||
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
||||||
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
||||||
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -69,8 +70,7 @@
|
|||||||
isJobExpanded,
|
isJobExpanded,
|
||||||
getJobLogEntries,
|
getJobLogEntries,
|
||||||
getJobLogTotal,
|
getJobLogTotal,
|
||||||
jobStatusVariant,
|
jobStatusVariant
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Создана
|
Создана
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.created_at)}</p>
|
<p class="mt-1">{formatDateTime(job.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
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" />
|
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Запущена
|
Запущена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.started_at)}</p>
|
<p class="mt-1">{formatDateTime(job.started_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
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" />
|
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Завершена
|
Завершена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.finished_at)}</p>
|
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,9 +450,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-muted-foreground py-10 text-center text-sm">
|
{#if jobsLoading}
|
||||||
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
|
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
title="Нет задач"
|
||||||
|
description="Задачи появятся после refresh, apply или rollback."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BirdStatus } from '$lib/api/types.js';
|
import type { BirdStatus } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Card } from '$lib/components/ui/card/index.js';
|
import { Card } from '$lib/ui/core/card/index.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
@@ -114,9 +114,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="font-semibold">Состояние BIRD</p>
|
<p class="font-semibold">Состояние BIRD</p>
|
||||||
{#if birdStatus}
|
{#if birdStatus}
|
||||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
<Badge
|
||||||
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
|
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
||||||
|
class={birdStatus.healthy === true
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
|
{birdHealthyShortLabel(birdStatus.healthy)}
|
||||||
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="max-w-[56ch] text-sm text-foreground/80">
|
<p class="max-w-[56ch] text-sm text-foreground/80">
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionRow } from '$lib/api/types.js';
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import {
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
Table,
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '$lib/components/ui/table/index.js';
|
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Undo from '@lucide/svelte/icons/undo';
|
import Undo from '@lucide/svelte/icons/undo';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
@@ -28,7 +22,6 @@
|
|||||||
onOpenPreview: (rev: RevisionRow) => void;
|
onOpenPreview: (rev: RevisionRow) => void;
|
||||||
onRollbackRequest: (rev: RevisionRow) => void;
|
onRollbackRequest: (rev: RevisionRow) => void;
|
||||||
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,9 +30,31 @@
|
|||||||
onReload,
|
onReload,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onRollbackRequest,
|
onRollbackRequest,
|
||||||
onDownloadDiagnosticLog,
|
onDownloadDiagnosticLog
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -61,55 +76,45 @@
|
|||||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-w-0 p-0">
|
<CardContent class="min-w-0 p-4 pt-0">
|
||||||
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
|
<AppDataTable
|
||||||
<Table class="min-w-[44rem]">
|
columns={[...columns]}
|
||||||
<TableHeader>
|
rows={revisions}
|
||||||
<TableRow>
|
rowKey={(rev) => rev.id}
|
||||||
<TableHead>ID</TableHead>
|
loading={revLoading}
|
||||||
<TableHead>Создана</TableHead>
|
emptyTitle="Нет ревизий"
|
||||||
<TableHead>Префиксов</TableHead>
|
emptyDescription="Ревизии появятся после обновления модулей."
|
||||||
<TableHead>Хэш</TableHead>
|
>
|
||||||
<TableHead class="w-32"></TableHead>
|
{#snippet cell({ row: rev, column })}
|
||||||
</TableRow>
|
{#if column.id === 'id'}
|
||||||
</TableHeader>
|
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||||
<TableBody>
|
{:else if column.id === 'created'}
|
||||||
{#each revisions as rev (rev.id)}
|
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
|
||||||
<TableRow>
|
{:else if column.id === 'prefixes'}
|
||||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
{rev.materialized_prefix_count}
|
||||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
{:else if column.id === 'hash'}
|
||||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
<span class="font-mono text-xs text-muted-foreground"
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground"
|
>{rev.content_hash.slice(0, 12)}…</span
|
||||||
>{rev.content_hash.slice(0, 12)}…</TableCell
|
>
|
||||||
>
|
{:else if column.id === 'actions'}
|
||||||
<TableCell>
|
<div class="flex gap-1">
|
||||||
<div class="flex gap-1">
|
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
<Eye class="size-3.5" />
|
||||||
<Eye class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
<Undo class="size-3.5" />
|
||||||
<Undo class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon-sm"
|
||||||
size="icon-sm"
|
title="Скачать диагностический лог"
|
||||||
title="Скачать диагностический лог"
|
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||||
onclick={() => onDownloadDiagnosticLog(rev)}
|
>
|
||||||
>
|
<Download class="size-3.5" />
|
||||||
<Download class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</TableCell>
|
{/snippet}
|
||||||
</TableRow>
|
</AppDataTable>
|
||||||
{:else}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
|
|
||||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getPaginationRowModel
|
getPaginationRowModel
|
||||||
} from '@tanstack/table-core';
|
} from '@tanstack/table-core';
|
||||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/ui/core/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RowData[];
|
rows: RowData[];
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
|
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'queued':
|
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 те же). */
|
/** Подпись типа задачи для фильтров (значения API те же). */
|
||||||
export function jobKindFilterRu(kind: string): string {
|
export function jobKindFilterRu(kind: string): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
|
|||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import Network from '@lucide/svelte/icons/network';
|
import Network from '@lucide/svelte/icons/network';
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
|
|||||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||||
{ href: '/network', label: 'Сеть', icon: Network },
|
{ href: '/network', label: 'Сеть', icon: Network },
|
||||||
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||||
{ href: '/operations', label: 'Операции', icon: Zap },
|
|
||||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
@@ -23,28 +26,28 @@
|
|||||||
ModulesResponse
|
ModulesResponse
|
||||||
} from '$lib/api/types.js';
|
} from '$lib/api/types.js';
|
||||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
import { jobStatusRu } from '$lib/ui-labels.js';
|
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogAction
|
|
||||||
} from '$lib/components/ui/alert-dialog/index.js';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/components/ui/dialog/index.js';
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/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 OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
|
||||||
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
|
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
|
||||||
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
||||||
@@ -56,6 +59,10 @@
|
|||||||
ReportRow
|
ReportRow
|
||||||
} from '$lib/components/operations/types.js';
|
} from '$lib/components/operations/types.js';
|
||||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
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 {
|
import {
|
||||||
dialogBodyDocument,
|
dialogBodyDocument,
|
||||||
dialogBodyPanel,
|
dialogBodyPanel,
|
||||||
@@ -65,14 +72,23 @@
|
|||||||
dialogHeaderPanel
|
dialogHeaderPanel
|
||||||
} from '$lib/dialog-layout.js';
|
} from '$lib/dialog-layout.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import { toast } from 'svelte-sonner';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
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
|
// Revisions
|
||||||
let revisions = $state<RevisionRow[]>([]);
|
let revisions = $state<RevisionRow[]>([]);
|
||||||
let revLoading = $state(false);
|
let revLoading = $state(false);
|
||||||
let rollbackTarget = $state<RevisionRow | null>(null);
|
|
||||||
let rollingBack = $state(false);
|
let rollingBack = $state(false);
|
||||||
|
|
||||||
// Preview/Prefixes
|
// Preview/Prefixes
|
||||||
@@ -115,8 +131,6 @@
|
|||||||
let jobFilterModule = $state('');
|
let jobFilterModule = $state('');
|
||||||
let jobActiveOnly = $state(false);
|
let jobActiveOnly = $state(false);
|
||||||
let moduleNameById = $state(new Map<string, string>());
|
let moduleNameById = $state(new Map<string, string>());
|
||||||
let cancelTarget = $state<JobRow | null>(null);
|
|
||||||
let cancelling = $state(false);
|
|
||||||
let jobDetailDialog = $state(false);
|
let jobDetailDialog = $state(false);
|
||||||
let jobDetail = $state<JobRow | null>(null);
|
let jobDetail = $state<JobRow | null>(null);
|
||||||
let expandedJobIds = new SvelteSet<string>();
|
let expandedJobIds = new SvelteSet<string>();
|
||||||
@@ -128,14 +142,18 @@
|
|||||||
// Global apply / bird reload
|
// Global apply / bird reload
|
||||||
let applying = $state(false);
|
let applying = $state(false);
|
||||||
let reloading = $state(false);
|
let reloading = $state(false);
|
||||||
let applyConfirm = $state(false);
|
|
||||||
let reloadConfirm = $state(false);
|
|
||||||
|
|
||||||
// BIRD runtime status (birdc на хосте API, если настроен сокет)
|
// BIRD runtime status (birdc на хосте API, если настроен сокет)
|
||||||
let birdStatus = $state<BirdStatus | null>(null);
|
let birdStatus = $state<BirdStatus | null>(null);
|
||||||
let birdLoading = $state(false);
|
let birdLoading = $state(false);
|
||||||
let birdProtocolsOpen = $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 {
|
function summarizeJobBirdMeta(job: JobRow): string {
|
||||||
const check = job.meta?.bird_post_apply_check;
|
const check = job.meta?.bird_post_apply_check;
|
||||||
if (check === 'skipped_no_birdc_socket') {
|
if (check === 'skipped_no_birdc_socket') {
|
||||||
@@ -156,7 +174,7 @@
|
|||||||
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
birdStatus = null;
|
birdStatus = null;
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
birdLoading = false;
|
birdLoading = false;
|
||||||
}
|
}
|
||||||
@@ -168,7 +186,7 @@
|
|||||||
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
|
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
|
||||||
revisions = r.items;
|
revisions = r.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
revLoading = false;
|
revLoading = false;
|
||||||
}
|
}
|
||||||
@@ -211,7 +229,7 @@
|
|||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
} catch (e) {
|
} 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()}`);
|
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
|
||||||
jobs = j.items;
|
jobs = j.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobsLoading = false;
|
jobsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -314,17 +332,149 @@
|
|||||||
}
|
}
|
||||||
moduleNameById = m;
|
moduleNameById = m;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadRevisions();
|
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
|
||||||
loadJobs();
|
tabSyncReady = true;
|
||||||
loadBirdStatus();
|
void refreshAll(true);
|
||||||
loadModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
async function openPreview(rev: RevisionRow) {
|
||||||
previewRevision = rev;
|
previewRevision = rev;
|
||||||
previewLoading = true;
|
previewLoading = true;
|
||||||
@@ -343,7 +493,7 @@
|
|||||||
const frags = asPreviewFragments(prev);
|
const frags = asPreviewFragments(prev);
|
||||||
birdPreviewPath = defaultBirdPreviewPath(frags);
|
birdPreviewPath = defaultBirdPreviewPath(frags);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading = false;
|
previewLoading = false;
|
||||||
}
|
}
|
||||||
@@ -351,29 +501,29 @@
|
|||||||
|
|
||||||
async function loadDiff() {
|
async function loadDiff() {
|
||||||
if (!diffRevA || !diffRevB) {
|
if (!diffRevA || !diffRevB) {
|
||||||
toast.error('Выберите две ревизии');
|
notify.error('Выберите две ревизии');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
diffLoading = true;
|
diffLoading = true;
|
||||||
try {
|
try {
|
||||||
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
diffLoading = false;
|
diffLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rollback() {
|
async function rollback(rev: RevisionRow) {
|
||||||
if (!rollbackTarget) return;
|
|
||||||
rollingBack = true;
|
rollingBack = true;
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {});
|
await apiMutate(`/v1/revisions/${rev.id}/rollback`, 'POST', {});
|
||||||
toast.success('Откат выполнен');
|
notify.success('Откат выполнен');
|
||||||
rollbackTarget = null;
|
|
||||||
await loadRevisions();
|
await loadRevisions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
rollingBack = false;
|
rollingBack = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,23 +533,22 @@
|
|||||||
try {
|
try {
|
||||||
const revId = revisions[0]?.id;
|
const revId = revisions[0]?.id;
|
||||||
if (!revId) {
|
if (!revId) {
|
||||||
toast.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
|
notify.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
|
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
|
||||||
revision_id: revId
|
revision_id: revId
|
||||||
});
|
});
|
||||||
applyConfirm = false;
|
|
||||||
if (!res?.job_id) {
|
if (!res?.job_id) {
|
||||||
toast.error('Ответ API без job_id');
|
notify.error('Ответ API без job_id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
|
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
|
||||||
const extra = summarizeJobBirdMeta(job);
|
const extra = summarizeJobBirdMeta(job);
|
||||||
if (job.status === 'succeeded') {
|
if (job.status === 'succeeded') {
|
||||||
toast.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
|
notify.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
notify.error(
|
||||||
job.error
|
job.error
|
||||||
? `${jobStatusRu(job.status)}: ${job.error}`
|
? `${jobStatusRu(job.status)}: ${job.error}`
|
||||||
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
||||||
@@ -408,7 +557,7 @@
|
|||||||
await loadJobs();
|
await loadJobs();
|
||||||
await loadBirdStatus();
|
await loadBirdStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
applying = false;
|
applying = false;
|
||||||
}
|
}
|
||||||
@@ -418,19 +567,18 @@
|
|||||||
reloading = true;
|
reloading = true;
|
||||||
try {
|
try {
|
||||||
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
|
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
|
||||||
reloadConfirm = false;
|
|
||||||
if (!res?.job_id) {
|
if (!res?.job_id) {
|
||||||
toast.error('Ответ API без job_id');
|
notify.error('Ответ API без job_id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
|
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
|
||||||
const extra = summarizeJobBirdMeta(job);
|
const extra = summarizeJobBirdMeta(job);
|
||||||
if (job.status === 'succeeded') {
|
if (job.status === 'succeeded') {
|
||||||
toast.success(
|
notify.success(
|
||||||
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
|
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
notify.error(
|
||||||
job.error
|
job.error
|
||||||
? `${jobStatusRu(job.status)}: ${job.error}`
|
? `${jobStatusRu(job.status)}: ${job.error}`
|
||||||
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
||||||
@@ -439,24 +587,20 @@
|
|||||||
await loadJobs();
|
await loadJobs();
|
||||||
await loadBirdStatus();
|
await loadBirdStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
reloading = false;
|
reloading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelJob() {
|
async function cancelJob(job: JobRow) {
|
||||||
if (!cancelTarget) return;
|
|
||||||
cancelling = true;
|
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/jobs/${cancelTarget.job_id}/cancel`, 'POST', {});
|
await apiMutate(`/v1/jobs/${job.job_id}/cancel`, 'POST', {});
|
||||||
toast.success('Задача отменена');
|
notify.success('Задача отменена');
|
||||||
cancelTarget = null;
|
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
throw e;
|
||||||
cancelling = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +631,7 @@
|
|||||||
jobDetailsById.set(jobId, freshJob);
|
jobDetailsById.set(jobId, freshJob);
|
||||||
await ensureJobReport(freshJob);
|
await ensureJobReport(freshJob);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobDetailsLoading.delete(jobId);
|
jobDetailsLoading.delete(jobId);
|
||||||
}
|
}
|
||||||
@@ -625,7 +769,7 @@
|
|||||||
ipRanges
|
ipRanges
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobReportsLoading.delete(jobId);
|
jobReportsLoading.delete(jobId);
|
||||||
}
|
}
|
||||||
@@ -703,18 +847,6 @@
|
|||||||
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
|
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(
|
function birdHealthyBadgeVariant(
|
||||||
h: boolean | null | undefined
|
h: boolean | null | undefined
|
||||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
@@ -724,7 +856,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function birdHealthyShortLabel(h: boolean | null | undefined): string {
|
function birdHealthyShortLabel(h: boolean | null | undefined): string {
|
||||||
if (h === true) return 'ОК';
|
if (h === true) return 'В норме';
|
||||||
if (h === false) return 'Проблема';
|
if (h === false) return 'Проблема';
|
||||||
return 'Н/Д';
|
return 'Н/Д';
|
||||||
}
|
}
|
||||||
@@ -732,31 +864,100 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Операции"
|
title="Ревизии и операции"
|
||||||
description="Деплой конфигурации, управление ревизиями и задачами."
|
description={lastUpdated
|
||||||
icon={Zap}
|
? `Деплой, сравнение конфигураций и задачи. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Деплой конфигурации, управление ревизиями и задачами.'}
|
||||||
|
icon={Activity}
|
||||||
iconClass="bg-chart-2/15 text-chart-2"
|
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
|
<OperationsQuickActions
|
||||||
{applying}
|
{applying}
|
||||||
{reloading}
|
{reloading}
|
||||||
{birdLoading}
|
{birdLoading}
|
||||||
{birdStatus}
|
{birdStatus}
|
||||||
onApply={() => (applyConfirm = true)}
|
onApply={requestApply}
|
||||||
onReload={() => (reloadConfirm = true)}
|
onReload={requestReload}
|
||||||
onRefreshBirdStatus={loadBirdStatus}
|
onRefreshBirdStatus={loadBirdStatus}
|
||||||
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
|
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
|
||||||
{birdHealthyBadgeVariant}
|
{birdHealthyBadgeVariant}
|
||||||
{birdHealthyShortLabel}
|
{birdHealthyShortLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value="revisions">
|
<Tabs bind:value={activeTab}>
|
||||||
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
||||||
<TabsList class="inline-flex min-w-max">
|
<TabsList class="inline-flex min-w-max">
|
||||||
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
|
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||||
<TabsTrigger value="jobs">Задачи</TabsTrigger>
|
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -766,9 +967,8 @@
|
|||||||
{revLoading}
|
{revLoading}
|
||||||
onReload={loadRevisions}
|
onReload={loadRevisions}
|
||||||
onOpenPreview={openPreview}
|
onOpenPreview={openPreview}
|
||||||
onRollbackRequest={(rev) => (rollbackTarget = rev)}
|
onRollbackRequest={requestRollback}
|
||||||
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
|
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -782,7 +982,6 @@
|
|||||||
onDiffRevAChange={(value) => (diffRevA = value)}
|
onDiffRevAChange={(value) => (diffRevA = value)}
|
||||||
onDiffRevBChange={(value) => (diffRevB = value)}
|
onDiffRevBChange={(value) => (diffRevB = value)}
|
||||||
onLoadDiff={loadDiff}
|
onLoadDiff={loadDiff}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -815,103 +1014,17 @@
|
|||||||
{jobReportsLoading}
|
{jobReportsLoading}
|
||||||
onReloadJobs={loadJobs}
|
onReloadJobs={loadJobs}
|
||||||
onOpenJobDetail={openJobDetail}
|
onOpenJobDetail={openJobDetail}
|
||||||
onRequestCancelJob={(job) => (cancelTarget = job)}
|
onRequestCancelJob={requestCancelJob}
|
||||||
onToggleJobExpanded={toggleJobExpanded}
|
onToggleJobExpanded={toggleJobExpanded}
|
||||||
{isJobExpanded}
|
{isJobExpanded}
|
||||||
{getJobLogEntries}
|
{getJobLogEntries}
|
||||||
{getJobLogTotal}
|
{getJobLogTotal}
|
||||||
{jobStatusVariant}
|
jobStatusVariant={jobStatusBadgeVariant}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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 -->
|
<!-- Preview dialog -->
|
||||||
<Dialog bind:open={previewDialog}>
|
<Dialog bind:open={previewDialog}>
|
||||||
<DialogContent class={dialogContentDocument}>
|
<DialogContent class={dialogContentDocument}>
|
||||||
@@ -922,7 +1035,10 @@
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{#if previewLoading}
|
{#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}
|
{:else}
|
||||||
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
|
<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">
|
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
@@ -941,16 +1057,23 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
<label for="bird-frag" class="text-sm text-muted-foreground">Файл</label>
|
<span class="text-sm text-muted-foreground">Файл</span>
|
||||||
<select
|
<Select
|
||||||
id="bird-frag"
|
type="single"
|
||||||
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"
|
value={birdPreviewPath}
|
||||||
bind:value={birdPreviewPath}
|
onValueChange={(v) => {
|
||||||
|
if (v) birdPreviewPath = v;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each Object.keys(frags).sort() as path (path)}
|
<SelectTrigger class="max-w-xl min-w-0 flex-1">
|
||||||
<option value={path}>{path}</option>
|
{birdPreviewPath || 'Выберите файл'}
|
||||||
{/each}
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
{#each Object.keys(frags).sort() as path (path)}
|
||||||
|
<SelectItem value={path}>{path}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p class="shrink-0 text-xs text-muted-foreground">
|
<p class="shrink-0 text-xs text-muted-foreground">
|
||||||
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
|
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
|
||||||
@@ -1021,18 +1144,18 @@
|
|||||||
>{jobDetail.job_id}</span
|
>{jobDetail.job_id}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Статус</span><span
|
<span class="text-muted-foreground">Статус</span><span
|
||||||
><Badge variant={jobStatusVariant(jobDetail.status)}
|
><Badge variant={jobStatusBadgeVariant(jobDetail.status)}
|
||||||
>{jobStatusRu(jobDetail.status)}</Badge
|
>{jobStatusRu(jobDetail.status)}</Badge
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Создана</span><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
|
<span class="text-muted-foreground">Начата</span><span
|
||||||
>{formatDate(jobDetail.started_at)}</span
|
>{formatDateTime(jobDetail.started_at)}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Завершена</span><span
|
<span class="text-muted-foreground">Завершена</span><span
|
||||||
>{formatDate(jobDetail.finished_at)}</span
|
>{formatDateTime(jobDetail.finished_at)}</span
|
||||||
>
|
>
|
||||||
{#if jobDetail.error}
|
{#if jobDetail.error}
|
||||||
<span class="text-muted-foreground">Ошибка</span><span
|
<span class="text-muted-foreground">Ошибка</span><span
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
goto(resolve('/operations'));
|
|
||||||
|
goto(`${resolve('/operations')}?tab=revisions`, { replaceState: true });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
moduleTypeBadgeVariant
|
moduleTypeBadgeVariant
|
||||||
} from '$lib/modules/display.js';
|
} from '$lib/modules/display.js';
|
||||||
import { jobKindTitle } from '$lib/operations/job-kind-label.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 { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
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 {
|
function truncateError(error: string | null | undefined, max = 120): string {
|
||||||
if (!error) return '';
|
if (!error) return '';
|
||||||
return error.length > max ? `${error.slice(0, max)}…` : error;
|
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||||
@@ -385,7 +377,7 @@
|
|||||||
{#if column.id === 'kind'}
|
{#if column.id === 'kind'}
|
||||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||||
{:else if column.id === 'status'}
|
{: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'}
|
{:else if column.id === 'created'}
|
||||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||||
>{formatDateTime(j.created_at)}</span
|
>{formatDateTime(j.created_at)}</span
|
||||||
|
|||||||
Reference in New Issue
Block a user