diff --git a/web/package-lock.json b/web/package-lock.json index b374d07..504f5b3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.0.1", "dependencies": { + "@tanstack/table-core": "^8.21.3", "bits-ui": "^2.17.2", "clsx": "^2.1.1", "svelte-sonner": "^1.1.0", @@ -1258,6 +1259,19 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", diff --git a/web/package.json b/web/package.json index df3bc6b..b8945a2 100644 --- a/web/package.json +++ b/web/package.json @@ -14,13 +14,13 @@ "format": "prettier --write ." }, "devDependencies": { - "@types/node": "^22.15.0", "@internationalized/date": "^3.12.0", "@lucide/svelte": "^1.7.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/node": "^22.15.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "prettier-plugin-tailwindcss": "^0.7.2", @@ -31,6 +31,7 @@ "vite": "^7.3.1" }, "dependencies": { + "@tanstack/table-core": "^8.21.3", "bits-ui": "^2.17.2", "clsx": "^2.1.1", "svelte-sonner": "^1.1.0", diff --git a/web/src/lib/components/operations/OperationsJobsTab.svelte b/web/src/lib/components/operations/OperationsJobsTab.svelte index 98f4e7f..a98b5e4 100644 --- a/web/src/lib/components/operations/OperationsJobsTab.svelte +++ b/web/src/lib/components/operations/OperationsJobsTab.svelte @@ -4,17 +4,27 @@ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; - import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; + import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle + } from '$lib/components/ui/card/index.js'; import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import Eye from '@lucide/svelte/icons/eye'; import X from '@lucide/svelte/icons/x'; import ChevronDown from '@lucide/svelte/icons/chevron-down'; import { cn } from '$lib/utils.js'; + import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js'; + import JobReportTableBlock from './job-report-table-block.svelte'; + import { asnReportColumns, reportRowColumns } from './job-report-columns.js'; + import type { RowData } from '@tanstack/table-core'; type Props = { jobs: JobRow[]; jobsLoading: boolean; + moduleNameById: ReadonlyMap; expandedJobIds: SvelteSet; jobDetailsById: SvelteMap; jobDetailsLoading: SvelteSet; @@ -34,6 +44,7 @@ let { jobs, jobsLoading, + moduleNameById, expandedJobIds, jobDetailsById, jobDetailsLoading, @@ -49,6 +60,12 @@ jobStatusVariant, formatDate }: Props = $props(); + + const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef< + RowData, + unknown + >[]; + const asnCols = asnReportColumns as import('@tanstack/table-core').ColumnDef[]; @@ -57,34 +74,56 @@ Задачи Фоновые задачи (ingest, apply, refresh) - - + {#each jobs as job (job.job_id)} -
+ {@const kindSub = jobKindSubtitle(job, moduleNameById)} +
-
+
-

{job.kind}

-

{job.job_id}

+

{jobKindTitle(job, moduleNameById)}

+ {#if kindSub} +

{kindSub}

+ {/if} +

{job.job_id}

- {#if job.status === 'running' || job.status === 'queued'} @@ -103,19 +142,19 @@
-

Статус

+

Статус

{job.status}
-

Создана

+

Создана

{formatDate(job.created_at)}

-

Запущена

+

Запущена

{formatDate(job.started_at)}

-

Завершена

+

Завершена

{formatDate(job.finished_at)}

@@ -128,16 +167,16 @@ {@const jobReport = jobReportsById.get(job.job_id)} {@const logEntries = getJobLogEntries(detailedJob)} {@const logTotal = getJobLogTotal(detailedJob, logEntries)} -
+
{#if isDetailsLoading} -

Догружаем свежие детали задачи…

+

Догружаем свежие детали задачи…

{/if} {#if detailedJob.error}
-

error

-

{detailedJob.error}

+

error

+

{detailedJob.error}

{/if} @@ -145,54 +184,94 @@

Журнал обработки

-

+

{logEntries.length} записей, всего {logTotal} префиксов

- +
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)} -
-

{entry.message}

+
+

+ {entry.message} +

-
- source -

{entry.source}

+
+ source +

+ {entry.source} +

-
- kind -

{entry.kind}

+
+ kind +

+ {entry.kind} +

-
- community -

{entry.community}

+
+ community +

+ {entry.community} +

-
- prefixes -

{entry.prefix_count}

+
+ prefixes +

+ {entry.prefix_count} +

{#if entry.sample && entry.sample.length > 0}
-

sample

- +

+ sample +

+
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)} -

{sampleValue}

+

+ {sampleValue} +

{/each}
- +
{/if}
{/each}
- +
{/if} {#if isReportLoading} -

Собираем подробный отчёт по источникам и агрегации…

+

+ Собираем подробный отчёт по источникам и агрегации… +

{/if} {#if jobReport} @@ -205,31 +284,35 @@ {jobReport.module.name} {/if} {#if jobReport.revisionId} - {jobReport.revisionId.slice(0, 8)}… + {jobReport.revisionId.slice(0, 8)}… {/if}
-

Агрегация

+

Агрегация

{jobReport.aggregationTotal} префиксов

-

Домены

+

Домены

{jobReport.domains.length}

-

ASN

+

ASN

{jobReport.asn.length}

-

CDN/IP Range

-

{jobReport.cdn.length}/{jobReport.ipRanges.length}

+

CDN/IP Range

+

+ {jobReport.cdn.length}/{jobReport.ipRanges.length} +

{#if jobReport.aggregationByKind.length > 0}
-

Результат агрегации по типам

+

Результат агрегации по типам

{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)} {row.kind}: {row.prefixCount} @@ -239,77 +322,65 @@ {/if}
-
-

Домены: какой домен какие IP/префиксы вернул

- - {#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)} -
-

{row.label}

-

{row.prefixCount} префиксов

-

{row.prefixes.slice(0, 3).join(', ') || '—'}

-
- {:else} -

Нет данных по доменам

- {/each} -
+
+

+ Домены: какой домен какие IP/префиксы вернул +

+
-
+

ASN: сколько префиксов получено по AS

- - {#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)} -
- AS{row.asn} - {row.prefixCount} -
- {:else} -

Нет данных по ASN

- {/each} -
+
-
-

CDN: из каждой ссылки полученные IP/префиксы

- - {#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)} -
-

{row.label}

-

{row.prefixCount} префиксов

-

{row.prefixes.slice(0, 3).join(', ') || '—'}

-
- {:else} -

Нет данных по CDN

- {/each} -
+
+

+ CDN: из каждой ссылки полученные IP/префиксы +

+
-
+

IP range: итог по статическим диапазонам

- - {#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)} -
-

{row.label}

-

{row.prefixCount} диапазонов/префиксов

-

{row.prefixes.slice(0, 3).join(', ') || '—'}

-
- {:else} -

Нет данных по IP range

- {/each} -
+
{/if}
-

Meta (raw JSON)

- -
{JSON.stringify(detailedJob.meta ?? {}, null, 2)}
-
+

Meta (raw JSON)

+
+
{JSON.stringify(
+											detailedJob.meta ?? {},
+											null,
+											2
+										)}
+
{/if}
{:else} -
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
+
+ {jobsLoading ? 'Загрузка…' : 'Нет задач'} +
{/each} diff --git a/web/src/lib/components/operations/job-report-columns.ts b/web/src/lib/components/operations/job-report-columns.ts new file mode 100644 index 0000000..117feab --- /dev/null +++ b/web/src/lib/components/operations/job-report-columns.ts @@ -0,0 +1,42 @@ +import type { ColumnDef } from '@tanstack/table-core'; +import type { ReportRow } from './types.js'; + +export const reportRowColumns: ColumnDef[] = [ + { + accessorKey: 'label', + header: 'Метка', + cell: ({ row }) => { + const v = row.original.label; + return typeof v === 'string' ? v : '—'; + } + }, + { + accessorKey: 'prefixCount', + header: 'Префиксов', + cell: ({ row }) => String(row.original.prefixCount) + }, + { + id: 'prefixes', + header: 'Префиксы', + cell: ({ row }) => { + const p = row.original.prefixes; + if (!p?.length) return '—'; + return p.join(', '); + } + } +]; + +export type AsnReportRow = { asn: string; prefixCount: number }; + +export const asnReportColumns: ColumnDef[] = [ + { + accessorKey: 'asn', + header: 'ASN', + cell: ({ row }) => `AS${row.original.asn}` + }, + { + accessorKey: 'prefixCount', + header: 'Префиксов', + cell: ({ row }) => String(row.original.prefixCount) + } +]; diff --git a/web/src/lib/components/operations/job-report-table-block.svelte b/web/src/lib/components/operations/job-report-table-block.svelte new file mode 100644 index 0000000..3d43ff3 --- /dev/null +++ b/web/src/lib/components/operations/job-report-table-block.svelte @@ -0,0 +1,107 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + {emptyLabel} + + + {/each} + + +
+{#if table.getPageCount() > 1} +
+

+ Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} ({rows.length} строк) +

+ + +
+{/if} diff --git a/web/src/lib/components/ui/data-table/data-table.svelte.ts b/web/src/lib/components/ui/data-table/data-table.svelte.ts new file mode 100644 index 0000000..f3c30a7 --- /dev/null +++ b/web/src/lib/components/ui/data-table/data-table.svelte.ts @@ -0,0 +1,142 @@ +import { + type RowData, + type TableOptions, + type TableOptionsResolved, + type TableState, + type Updater, + createTable, +} from "@tanstack/table-core"; + +/** + * Creates a reactive TanStack table object for Svelte. + * @param options Table options to create the table with. + * @returns A reactive table object. + * @example + * ```svelte + * + * + * + * + * {#each table.getHeaderGroups() as headerGroup} + * + * {#each headerGroup.headers as header} + * + * {/each} + * + * {/each} + * + * + *
+ * + *
+ * ``` + */ +export function createSvelteTable(options: TableOptions) { + const resolvedOptions: TableOptionsResolved = mergeObjects( + { + state: {}, + onStateChange() {}, + renderFallbackValue: null, + mergeOptions: ( + defaultOptions: TableOptions, + options: Partial> + ) => { + return mergeObjects(defaultOptions, options); + }, + }, + options + ); + + const table = createTable(resolvedOptions); + let state = $state(table.initialState); + + function updateOptions() { + table.setOptions(() => { + return mergeObjects(resolvedOptions, options, { + state: mergeObjects(state, options.state || {}), + + onStateChange: (updater: Updater) => { + if (updater instanceof Function) state = updater(state); + else state = mergeObjects(state, updater); + + options.onStateChange?.(updater); + }, + }); + }); + } + + updateOptions(); + + $effect.pre(() => { + updateOptions(); + }); + + return table; +} + +type MaybeThunk = T | (() => T | null | undefined); +type Intersection = (T extends [infer H, ...infer R] + ? H & Intersection + : unknown) & {}; + +/** + * Lazily merges several objects (or thunks) while preserving + * getter semantics from every source. + * + * Proxy-based to avoid known WebKit recursion issue. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mergeObjects[]>( + ...sources: Sources +): Intersection<{ [K in keyof Sources]: Sources[K] }> { + const resolve = (src: MaybeThunk): T | undefined => + typeof src === "function" ? (src() ?? undefined) : src; + + const findSourceWithKey = (key: PropertyKey) => { + for (let i = sources.length - 1; i >= 0; i--) { + const obj = resolve(sources[i]); + if (obj && key in obj) return obj; + } + return undefined; + }; + + return new Proxy(Object.create(null), { + get(_, key) { + const src = findSourceWithKey(key); + + return src?.[key as never]; + }, + + has(_, key) { + return !!findSourceWithKey(key); + }, + + ownKeys(): (string | symbol)[] { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const all = new Set(); + for (const s of sources) { + const obj = resolve(s); + if (obj) { + for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) { + all.add(k); + } + } + } + return [...all]; + }, + + getOwnPropertyDescriptor(_, key) { + const src = findSourceWithKey(key); + if (!src) return undefined; + return { + configurable: true, + enumerable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: (src as any)[key], + writable: true, + }; + }, + }) as Intersection<{ [K in keyof Sources]: Sources[K] }>; +} diff --git a/web/src/lib/components/ui/data-table/flex-render.svelte b/web/src/lib/components/ui/data-table/flex-render.svelte new file mode 100644 index 0000000..ac82a58 --- /dev/null +++ b/web/src/lib/components/ui/data-table/flex-render.svelte @@ -0,0 +1,40 @@ + + +{#if typeof content === "string"} + {content} +{:else if content instanceof Function} + + + {@const result = content(context as any)} + {#if result instanceof RenderComponentConfig} + {@const { component: Component, props } = result} + + {:else if result instanceof RenderSnippetConfig} + {@const { snippet, params } = result} + {@render snippet({ ...params, attach })} + {:else} + {result} + {/if} +{/if} diff --git a/web/src/lib/components/ui/data-table/index.ts b/web/src/lib/components/ui/data-table/index.ts new file mode 100644 index 0000000..5f4e77e --- /dev/null +++ b/web/src/lib/components/ui/data-table/index.ts @@ -0,0 +1,3 @@ +export { default as FlexRender } from "./flex-render.svelte"; +export { renderComponent, renderSnippet } from "./render-helpers.js"; +export { createSvelteTable } from "./data-table.svelte.js"; diff --git a/web/src/lib/components/ui/data-table/render-helpers.ts b/web/src/lib/components/ui/data-table/render-helpers.ts new file mode 100644 index 0000000..fa036d6 --- /dev/null +++ b/web/src/lib/components/ui/data-table/render-helpers.ts @@ -0,0 +1,111 @@ +import type { Component, ComponentProps, Snippet } from "svelte"; + +/** + * A helper class to make it easy to identify Svelte components in + * `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderComponentConfig} + * {@const { component: Component, props } = result} + * + * {/if} + * ``` + */ +export class RenderComponentConfig { + component: TComponent; + props: ComponentProps | Record; + constructor( + component: TComponent, + props: ComponentProps | Record = {} + ) { + this.component = component; + this.props = props; + } +} + +/** + * A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderSnippetConfig} + * {@const { snippet, params } = result} + * {@render snippet(params)} + * {/if} + * ``` + */ +export class RenderSnippetConfig { + snippet: Snippet<[TProps]>; + params: TProps; + constructor(snippet: Snippet<[TProps]>, params: TProps) { + this.snippet = snippet; + this.params = params; + } +} + +/** + * A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties. + * + * This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets. + * + * @param component A Svelte component + * @param props The props to pass to `component` + * @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * header: header => renderComponent(SortHeader, { label: 'Name', header }), + * }), + * columnHelper.accessor('state', { + * header: header => renderComponent(SortHeader, { label: 'State', header }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderComponent< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Component, + Props extends ComponentProps, +>(component: T, props: Props = {} as Props) { + return new RenderComponentConfig(component, props); +} + +/** + * A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties. + * + * The snippet must only take one parameter. + * + * This is only to be used with Snippets - use `renderComponent` for Svelte Components. + * + * @param snippet + * @param params + * @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }), + * }), + * columnHelper.accessor('state', { + * cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderSnippet(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) { + return new RenderSnippetConfig(snippet, params); +} diff --git a/web/src/lib/operations/job-kind-label.ts b/web/src/lib/operations/job-kind-label.ts new file mode 100644 index 0000000..df4fef4 --- /dev/null +++ b/web/src/lib/operations/job-kind-label.ts @@ -0,0 +1,39 @@ +import type { JobRow } from '$lib/api/types.js'; + +function metaString(meta: Record | undefined, key: string): string | null { + if (!meta || typeof meta !== 'object') return null; + const v = meta[key]; + return typeof v === 'string' && v.trim().length > 0 ? v : null; +} + +/** Заголовок задачи для UI (русский), без технического `kind`. */ +export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap): string { + switch (job.kind) { + case 'module_refresh': { + const mid = metaString(job.meta as Record | undefined, 'module_id'); + const name = mid ? moduleNameById?.get(mid) : undefined; + if (name) return `Обновление модуля «${name}»`; + return 'Обновление модуля'; + } + case 'deploy_apply': + return 'Применение конфигурации'; + case 'revision_rollback': + return 'Откат ревизии'; + case 'bird_reload': + return 'Перезагрузка BIRD'; + default: + return job.kind; + } +} + +/** Вторая строка под заголовком: `module_id`, если имя модуля ещё не известно. */ +export function jobKindSubtitle( + job: JobRow, + moduleNameById?: ReadonlyMap +): string | null { + if (job.kind !== 'module_refresh') return null; + const mid = metaString(job.meta as Record | undefined, 'module_id'); + if (!mid) return null; + if (moduleNameById?.has(mid)) return null; + return mid; +} diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 365a59f..faa4628 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -18,8 +18,10 @@ RevisionPrefix, RevisionDiff, JobRow, - JobsResponse + JobsResponse, + ModulesResponse } from '$lib/api/types.js'; + import { jobKindTitle } from '$lib/operations/job-kind-label.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'; @@ -45,7 +47,11 @@ import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte'; import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte'; import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte'; - import type { JobDetailedReport, JobLogEntry, ReportRow } from '$lib/components/operations/types.js'; + import type { + JobDetailedReport, + JobLogEntry, + ReportRow + } from '$lib/components/operations/types.js'; import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte'; import { dialogBodyDocument, @@ -98,6 +104,7 @@ // Jobs let jobs = $state([]); let jobsLoading = $state(false); + let moduleNameById = $state(new Map()); let cancelTarget = $state(null); let cancelling = $state(false); let jobDetailDialog = $state(false); @@ -169,10 +176,24 @@ } } + async function loadModules() { + try { + const r = await apiJSON('/v1/modules?limit=200'); + const m = new Map(); + for (const mod of r.items ?? []) { + m.set(mod.id, mod.name); + } + moduleNameById = m; + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } + } + onMount(() => { loadRevisions(); loadJobs(); loadBirdStatus(); + loadModules(); }); async function openPreview(rev: RevisionRow) { @@ -200,7 +221,10 @@ } async function loadDiff() { - if (!diffRevA || !diffRevB) { toast.error('Выберите две ревизии'); return; } + if (!diffRevA || !diffRevB) { + toast.error('Выберите две ревизии'); + return; + } diffLoading = true; try { diffData = await apiJSON(`/v1/revisions/${diffRevA}/diff/${diffRevB}`); @@ -247,9 +271,7 @@ toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён'); } else { toast.error( - job.error - ? `${job.status}: ${job.error}` - : `Задача завершилась со статусом ${job.status}` + job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}` ); } await loadJobs(); @@ -276,9 +298,7 @@ toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен'); } else { toast.error( - job.error - ? `${job.status}: ${job.error}` - : `Задача завершилась со статусом ${job.status}` + job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}` ); } await loadJobs(); @@ -555,7 +575,9 @@ return new Date(d).toLocaleString('ru'); } - function birdHealthyBadgeVariant(h: boolean | null | undefined): 'default' | 'secondary' | 'outline' | 'destructive' { + function birdHealthyBadgeVariant( + h: boolean | null | undefined + ): 'default' | 'secondary' | 'outline' | 'destructive' { if (h === true) return 'default'; if (h === false) return 'destructive'; return 'outline'; @@ -571,7 +593,9 @@

Операции

-

Деплой конфигурации, управление ревизиями и задачами.

+

+ Деплой конфигурации, управление ревизиями и задачами. +

- Ревизии - Diff - Задачи + Ревизии + Diff + Задачи
@@ -625,6 +649,7 @@ Запустить Apply на всех спикерах? - Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator. + Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator. Отмена - {applying ? 'Apply…' : 'Применить'} + {applying ? 'Apply…' : 'Применить'} @@ -663,39 +692,64 @@ Reload BIRD? - BIRD перезагрузит конфигурацию. Требуется роль operator. + BIRD перезагрузит конфигурацию. Требуется роль operator. Отмена - {reloading ? 'Reload…' : 'Reload'} + {reloading ? 'Reload…' : 'Reload'} - { if (!v) rollbackTarget = null; }}> + { + if (!v) rollbackTarget = null; + }} +> Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…? - Будет создана новая ревизия на основе выбранной. Требуется роль operator. + Будет создана новая ревизия на основе выбранной. Требуется роль operator. (rollbackTarget = null)}>Отмена - {rollingBack ? 'Откат…' : 'Откатить'} + {rollingBack ? 'Откат…' : 'Откатить'} - { if (!v) cancelTarget = null; }}> + { + if (!v) cancelTarget = null; + }} +> Отменить задачу? - Задача: {cancelTarget?.kind} ({cancelTarget?.job_id?.slice(0, 8)}…) + + Задача: {cancelTarget ? jobKindTitle(cancelTarget, moduleNameById) : ''} ({cancelTarget?.job_id?.slice( + 0, + 8 + )}…) + (cancelTarget = null)}>Нет - {cancelling ? 'Отмена…' : 'Отменить'} + {cancelling ? 'Отмена…' : 'Отменить'} @@ -710,7 +764,7 @@ {#if previewLoading} -
Загрузка…
+
Загрузка…
{:else}
@@ -718,16 +772,21 @@ Конфиг BIRD Префиксы - + {@const frags = asPreviewFragments(previewData)} {#if Object.keys(frags).length === 0} -

Нет фрагментов превью (старая ревизия или пустой render).

+

+ Нет фрагментов превью (старая ревизия или пустой render). +

{:else}
- +
-

- Совет: откройте _bird_full_expanded.conf — один текст с - bird.conf и содержимым всех include. +

+ Совет: откройте _bird_full_expanded.conf + — один текст с + bird.conf и содержимым всех include.

{/if}
- -

+ +

Префиксов: {prefixesData.length}

{#each prefixesData as pfx, idx (`${idx}-${pfx}`)}

{pfx}

@@ -773,11 +836,11 @@ Вывод birdc (протоколы) - Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет выравнивание - колонок (горизонтальная прокрутка). + Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет + выравнивание колонок (горизонтальная прокрутка). -
+
- Задача: {jobDetail?.kind} + Задача: {jobDetail ? jobKindTitle(jobDetail, moduleNameById) : ''} {#if jobDetail}
- ID{jobDetail.job_id} - Статус{jobDetail.status} - Создана{formatDate(jobDetail.created_at)} - Начата{formatDate(jobDetail.started_at)} - Завершена{formatDate(jobDetail.finished_at)} + ID{jobDetail.job_id} + Статус{jobDetail.status} + Создана{formatDate(jobDetail.created_at)} + Начата{formatDate(jobDetail.started_at)} + Завершена{formatDate(jobDetail.finished_at)} {#if jobDetail.error} - Ошибка{jobDetail.error} + Ошибка{jobDetail.error} {/if}
{#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0}
-

Meta

- +

Meta

+
{/if}