feat: enhance API client with pagination and new data fetching functions. Implement apiPageAll for paginated API requests, and add functions to fetch all revision prefixes and module source catalogs based on module type. Update type definitions to support new response structures, improving data handling in the application.
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m11s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m12s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-06 20:34:49 +07:00
parent 1c5a9c91bb
commit 9f0b1281f2
5 changed files with 470 additions and 15 deletions
+62 -1
View File
@@ -1,5 +1,14 @@
import { browser } from '$app/environment';
import type { JobRow } from './types.js';
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse
} from './types.js';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
@@ -122,3 +131,55 @@ export async function waitForJob(
}
throw new Error(`Таймаут ожидания задачи ${jobId}`);
}
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
while (true) {
const [basePath, rawQuery = ''] = path.split('?');
const query = new URLSearchParams(rawQuery);
if (!query.has('limit')) query.set('limit', String(limit));
if (cursor) query.set('cursor', cursor);
else query.delete('cursor');
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
`${basePath}?${query.toString()}`
);
items.push(...(page.items ?? []));
if (!page.has_more || !page.next_cursor) break;
cursor = page.next_cursor;
}
return items;
}
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`);
return items;
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`
);
return { domains: entries };
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`
);
return { asns: entries };
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`
);
return { cdnSources: entries };
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`
);
return { ipRanges: entries };
}
return {};
}
+2
View File
@@ -179,6 +179,8 @@ export type RevisionsResponse = Page<RevisionRow>;
export type RevisionPrefix = {
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
prefix: string;
/** Источник материализации (например, domain:<fqdn>, as:<asn>, cdn:<source_id>, ip_range). */
source?: string;
community_id?: string | null;
};
export type RevisionPrefixesResponse = Page<RevisionPrefix>;
+74 -6
View File
@@ -725,6 +725,10 @@
default: return 'as';
}
});
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
</script>
{#if loadingMod}
@@ -795,6 +799,70 @@
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
<CardDescription>
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для refresh/агрегации.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2">
{#if mod.type === 'DOMAINS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
<p class="text-muted-foreground text-xs">После refresh домены резолвятся в IP и конвертируются в префиксы.</p>
<div class="mt-2 space-y-1">
{#each domainEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет доменов</p>
{/each}
</div>
</div>
{:else if mod.type === 'AS_PREFIXES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
<p class="text-muted-foreground text-xs">Счётчик префиксов обновляется после успешного refresh.</p>
<div class="mt-2 space-y-1">
<p class="text-xs">Всего ASN: <span class="font-semibold">{asEntries.length}</span></p>
<p class="text-xs">Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span></p>
</div>
</div>
{:else if mod.type === 'CDN_CIDRS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
<p class="text-muted-foreground text-xs">Каждый URL поставляет список CIDR для агрегации.</p>
<div class="mt-2 space-y-1">
{#each cdnSources.slice(0, 6) as src (src.id)}
<p class="font-mono text-xs break-all">{src.url}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет CDN источников</p>
{/each}
</div>
</div>
{:else if mod.type === 'IP_RANGES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">IP ranges для агрегации</p>
<p class="text-muted-foreground text-xs">Статические CIDR, которые попадают в итоговую ревизию.</p>
<div class="mt-2 space-y-1">
{#each ipEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs">{entry.prefix}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет диапазонов</p>
{/each}
</div>
</div>
{/if}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Результат операции</p>
<p class="text-muted-foreground text-xs">
Подробный результат по конкретному запуску refresh смотрите в `Операции -> Задачи -> module_refresh`:
там отображаются источники, количество префиксов и итог агрегации.
</p>
</div>
</CardContent>
</Card>
<!-- Entries by type -->
{#if mod.type === 'AS_PREFIXES'}
<Card>
@@ -1189,7 +1257,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each communities as c}
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
@@ -1203,7 +1271,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each dohProfiles as d}
{#each dohProfiles as d (d.id)}
<SelectItem value={d.id}>{d.url}</SelectItem>
{/each}
</SelectContent>
@@ -1271,7 +1339,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each communities as c}
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
@@ -1368,7 +1436,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each communities as c}
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
@@ -1455,7 +1523,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each communities as c}
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
@@ -1515,7 +1583,7 @@
{ipForm.community_id ? communityLabel(ipForm.community_id) : 'Выберите community'}
</SelectTrigger>
<SelectContent>
{#each communities as c}
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
+281 -7
View File
@@ -1,12 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { apiJSON, apiMutate, waitForJob } from '$lib/api/client.js';
import {
apiJSON,
apiMutate,
fetchModuleSourceCatalog,
fetchRevisionPrefixesAll,
waitForJob
} from '$lib/api/client.js';
import type {
ModuleRow,
ModuleType,
BirdStatus,
RevisionRow,
RevisionsResponse,
RevisionPrefixesResponse,
RevisionPrefix,
RevisionDiff,
JobRow,
JobsResponse
@@ -109,6 +118,8 @@
let expandedJobIds = new SvelteSet<string>();
let jobDetailsById = new SvelteMap<string, JobRow>();
let jobDetailsLoading = new SvelteSet<string>();
let jobReportsById = new SvelteMap<string, JobDetailedReport>();
let jobReportsLoading = new SvelteSet<string>();
type JobLogEntry = {
kind: string;
@@ -119,6 +130,24 @@
message: string;
};
type ReportRow = {
label: string;
source: string;
prefixes: string[];
prefixCount: number;
};
type JobDetailedReport = {
revisionId: string | null;
module: ModuleRow | null;
aggregationTotal: number;
aggregationByKind: Array<{ kind: string; prefixCount: number }>;
domains: ReportRow[];
asn: Array<{ asn: string; prefixCount: number }>;
cdn: ReportRow[];
ipRanges: ReportRow[];
};
// Global apply / bird reload
let applying = $state(false);
let reloading = $state(false);
@@ -341,6 +370,7 @@
try {
const freshJob = await apiJSON<JobRow>(`/v1/jobs/${jobId}`);
jobDetailsById.set(jobId, freshJob);
await ensureJobReport(freshJob);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
@@ -348,6 +378,144 @@
}
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function getRevisionIdFromJob(job: JobRow): string | null {
const meta = asMetaRecord(job.meta);
return asString(meta?.revision_id);
}
function groupPrefixesBySource(prefixes: RevisionPrefix[]): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const item of prefixes) {
if (!item || typeof item.prefix !== 'string') continue;
const source = typeof item.source === 'string' ? item.source : 'unknown';
const list = out.get(source) ?? [];
list.push(item.prefix);
out.set(source, list);
}
return out;
}
function groupByKind(entries: JobLogEntry[]): Array<{ kind: string; prefixCount: number }> {
const map = new Map<string, number>();
for (const entry of entries) {
map.set(entry.kind, (map.get(entry.kind) ?? 0) + entry.prefix_count);
}
return [...map.entries()]
.map(([kind, prefixCount]) => ({ kind, prefixCount }))
.sort((a, b) => b.prefixCount - a.prefixCount);
}
async function ensureJobReport(job: JobRow) {
const jobId = job.job_id;
if (jobReportsById.has(jobId) || jobReportsLoading.has(jobId)) return;
jobReportsLoading.add(jobId);
try {
const revisionId = getRevisionIdFromJob(job);
const entries = getJobLogEntries(job);
const aggregationTotal = getJobLogTotal(job, entries);
const aggregationByKind = groupByKind(entries);
if (!revisionId) {
jobReportsById.set(jobId, {
revisionId: null,
module: null,
aggregationTotal,
aggregationByKind,
domains: [],
asn: [],
cdn: [],
ipRanges: []
});
return;
}
const [revision, allPrefixes] = await Promise.all([
apiJSON<RevisionRow>(`/v1/revisions/${revisionId}`),
fetchRevisionPrefixesAll(revisionId)
]);
const moduleId = revision.module_id;
let module: ModuleRow | null = null;
if (moduleId) {
module = await apiJSON<ModuleRow>(`/v1/modules/${moduleId}`);
}
const bySource = groupPrefixesBySource(allPrefixes);
const domains: ReportRow[] = [];
const cdn: ReportRow[] = [];
const ipRanges: ReportRow[] = [];
const asnMap = new Map<string, number>();
for (const [source, prefixes] of bySource.entries()) {
if (source.startsWith('domain:')) {
domains.push({
label: source.slice('domain:'.length) || source,
source,
prefixes,
prefixCount: prefixes.length
});
} else if (source.startsWith('cdn:')) {
cdn.push({
label: source.slice('cdn:'.length) || source,
source,
prefixes,
prefixCount: prefixes.length
});
} else if (source === 'ip_range') {
ipRanges.push({
label: 'Статические диапазоны',
source,
prefixes,
prefixCount: prefixes.length
});
} else if (source.startsWith('as:')) {
const asn = source.slice('as:'.length) || source;
asnMap.set(asn, (asnMap.get(asn) ?? 0) + prefixes.length);
}
}
if (module) {
const catalog = await fetchModuleSourceCatalog(module.id, module.type as ModuleType);
if ('cdnSources' in catalog && catalog.cdnSources) {
const urlById = new Map(catalog.cdnSources.map((item) => [item.id, item.url]));
for (const row of cdn) {
const sourceId = row.source.slice('cdn:'.length);
const url = urlById.get(sourceId);
if (url) row.label = url;
}
}
if ('ipRanges' in catalog && catalog.ipRanges && ipRanges.length > 0) {
ipRanges[0] = {
...ipRanges[0],
prefixes: catalog.ipRanges.map((item) => item.prefix),
prefixCount: catalog.ipRanges.length
};
}
}
const asn = [...asnMap.entries()]
.map(([value, prefixCount]) => ({ asn: value, prefixCount }))
.sort((a, b) => b.prefixCount - a.prefixCount);
jobReportsById.set(jobId, {
revisionId,
module,
aggregationTotal,
aggregationByKind,
domains: domains.sort((a, b) => b.prefixCount - a.prefixCount),
asn,
cdn: cdn.sort((a, b) => b.prefixCount - a.prefixCount),
ipRanges
});
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
jobReportsLoading.delete(jobId);
}
}
async function toggleJobExpanded(job: JobRow) {
const jobId = job.job_id;
if (expandedJobIds.has(jobId)) {
@@ -593,13 +761,13 @@
<div class="flex flex-col gap-2 sm:flex-row">
<select bind:value={diffRevA} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
<option value="">Ревизия A</option>
{#each revisions as rev}
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
<select bind:value={diffRevB} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
<option value="">Ревизия B</option>
{#each revisions as rev}
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)} ({formatDate(rev.created_at)})</option>
{/each}
</select>
@@ -612,7 +780,7 @@
<div>
<p class="text-sm font-medium text-green-600 mb-2">+ Добавлено ({diffData.added?.length ?? 0})</p>
<ScrollArea class="max-h-64 rounded border p-2">
{#each diffData.added ?? [] as p}
{#each diffData.added ?? [] as p, idx (`added-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
<p class="font-mono text-xs">{typeof p === 'string' ? p : (p as {prefix?: string}).prefix ?? JSON.stringify(p)}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет изменений</p>
@@ -622,7 +790,7 @@
<div>
<p class="text-sm font-medium text-red-600 mb-2">- Удалено ({diffData.removed?.length ?? 0})</p>
<ScrollArea class="max-h-64 rounded border p-2">
{#each diffData.removed ?? [] as p}
{#each diffData.removed ?? [] as p, idx (`removed-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
<p class="font-mono text-xs">{typeof p === 'string' ? p : (p as {prefix?: string}).prefix ?? JSON.stringify(p)}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет изменений</p>
@@ -717,6 +885,8 @@
<TableCell colspan={5}>
{@const detailedJob = jobDetailsById.get(job.job_id) ?? job}
{@const isDetailsLoading = jobDetailsLoading.has(job.job_id)}
{@const isReportLoading = jobReportsLoading.has(job.job_id)}
{@const jobReport = jobReportsById.get(job.job_id)}
{@const logEntries = getJobLogEntries(detailedJob)}
{@const logTotal = getJobLogTotal(detailedJob, logEntries)}
<div class="space-y-3 py-2">
@@ -798,6 +968,110 @@
</div>
</div>
{/if}
{#if isReportLoading}
<p class="text-muted-foreground text-xs">Собираем подробный отчёт по источникам и агрегации…</p>
{/if}
{#if jobReport}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Операции по модулю</p>
<div class="flex flex-wrap gap-1.5">
{#if jobReport.module}
<Badge variant="outline">{jobReport.module.type}</Badge>
<Badge variant="secondary">{jobReport.module.name}</Badge>
{/if}
{#if jobReport.revisionId}
<Badge variant="outline" class="font-mono text-[10px]">{jobReport.revisionId.slice(0, 8)}</Badge>
{/if}
</div>
</div>
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">Агрегация</p>
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
</div>
<div class="rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">Домены</p>
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
</div>
<div class="rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">ASN</p>
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
</div>
<div class="rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">CDN/IP Range</p>
<p class="text-sm font-semibold">{jobReport.cdn.length}/{jobReport.ipRanges.length}</p>
</div>
</div>
{#if jobReport.aggregationByKind.length > 0}
<div class="space-y-1">
<p class="text-muted-foreground text-xs">Результат агрегации по типам</p>
<div class="flex flex-wrap gap-1.5">
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
{/each}
</div>
</div>
{/if}
<div class="grid gap-3 xl:grid-cols-2">
<div class="space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">Домены: какой домен какие IP/префиксы вернул</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по доменам</p>
{/each}
</ScrollArea>
</div>
<div class="space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
<div class="mb-2 flex items-center justify-between rounded border bg-background p-2">
<span class="font-mono text-xs">AS{row.asn}</span>
<Badge variant="outline">{row.prefixCount}</Badge>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по ASN</p>
{/each}
</ScrollArea>
</div>
<div class="space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">CDN: из каждой ссылки полученные IP/префиксы</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по CDN</p>
{/each}
</ScrollArea>
</div>
<div class="space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} диапазонов/префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по IP range</p>
{/each}
</ScrollArea>
</div>
</div>
</div>
{/if}
<div class="space-y-1">
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
@@ -909,7 +1183,7 @@
class="border-input bg-background ring-offset-background focus-visible:ring-ring max-w-xl min-w-0 flex-1 rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
bind:value={birdPreviewPath}
>
{#each Object.keys(frags).sort() as path}
{#each Object.keys(frags).sort() as path (path)}
<option value={path}>{path}</option>
{/each}
</select>
@@ -933,7 +1207,7 @@
<div
class="border-border bg-muted/40 flex min-h-[10rem] min-w-0 flex-1 flex-col overflow-auto rounded-lg border p-3 [scrollbar-gutter:stable] overscroll-contain"
>
{#each prefixesData as pfx}
{#each prefixesData as pfx, idx (`${idx}-${pfx}`)}
<p class="font-mono text-xs">{pfx}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет префиксов</p>
+51 -1
View File
@@ -70,6 +70,10 @@
if (s === 'failed') return 'destructive';
return 'outline';
}
const refreshJobs = $derived(jobs.filter((j) => j.kind === 'module_refresh'));
const runningJobsCount = $derived(jobs.filter((j) => j.status === 'running' || j.status === 'queued').length);
const failedJobsCount = $derived(jobs.filter((j) => j.status === 'failed').length);
</script>
<div class="space-y-6">
@@ -84,6 +88,21 @@
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<Card class="p-4">
<p class="text-muted-foreground text-xs">Всего задач</p>
<p class="text-lg font-semibold">{jobs.length}</p>
</Card>
<Card class="p-4">
<p class="text-muted-foreground text-xs">В работе</p>
<p class="text-lg font-semibold">{runningJobsCount}</p>
</Card>
<Card class="p-4">
<p class="text-muted-foreground text-xs">С ошибкой</p>
<p class="text-lg font-semibold">{failedJobsCount}</p>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Модули</CardTitle>
@@ -129,7 +148,7 @@
<Card>
<CardHeader>
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>GET /v1/jobs</CardDescription>
<CardDescription>Задачи разложены по статусу и времени запуска.</CardDescription>
</CardHeader>
<CardContent class="p-0">
<Table>
@@ -160,4 +179,35 @@
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Операции обновления модулей</CardTitle>
<CardDescription>Отдельная лента задач `module_refresh` для контроля по модулям.</CardDescription>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>revision_id</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each refreshJobs as j (j.job_id)}
<TableRow>
<TableCell><Badge variant={jobStatusVariant(j.status)}>{j.status}</Badge></TableCell>
<TableCell class="text-xs">{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell>
<TableCell class="font-mono text-xs">{typeof j.meta?.revision_id === 'string' ? `${j.meta.revision_id.slice(0, 12)}` : '—'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет задач module_refresh</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</div>