feat(web): enhance overview page with improved state management and UI components
CI / changes (push) Successful in 9s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 42s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m57s
CI / changes (push) Successful in 9s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 42s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m57s
- Refactored state management for modules, revisions, peers, speakers, and jobs, enhancing loading and error handling. - Updated imports to utilize core UI components for better maintainability and consistency. - Introduced derived states for KPI cards, providing real-time insights into system metrics. - Improved loading function to fetch data efficiently and handle errors gracefully. - Enhanced UI layout with new icons and dynamic descriptions based on last updated timestamps.
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { JobRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
|
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: JobRow[];
|
||||||
|
moduleNameById: ReadonlyMap<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
moduleNameById,
|
||||||
|
loading = false,
|
||||||
|
initialLoading = false,
|
||||||
|
error = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
label: 'Статус',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j: JobRow) => j.status
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
label: 'Создана',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j: JobRow) => j.created_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'actions', label: '', class: 'w-10' }
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">Последние задачи</CardTitle>
|
||||||
|
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
|
||||||
|
Все
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={items}
|
||||||
|
rowKey={(j) => j.job_id}
|
||||||
|
loading={initialLoading || loading}
|
||||||
|
{error}
|
||||||
|
emptyTitle="Нет задач"
|
||||||
|
emptyDescription="Задачи появятся после refresh или деплоя."
|
||||||
|
>
|
||||||
|
{#snippet cell({ row: j, column })}
|
||||||
|
{#if column.id === 'kind'}
|
||||||
|
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||||
|
{:else if column.id === 'status'}
|
||||||
|
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||||
|
{:else if column.id === 'created'}
|
||||||
|
<span class="text-xs whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(j.created_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
|
||||||
|
<ExternalLink class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { RevisionRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: RevisionRow[];
|
||||||
|
loading?: boolean;
|
||||||
|
initialLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { items, loading = false, initialLoading = false, error = null }: 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: 'actions', label: '', class: 'w-10' }
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">Последние ревизии</CardTitle>
|
||||||
|
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||||
|
Все
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={items}
|
||||||
|
rowKey={(rev) => rev.id}
|
||||||
|
loading={initialLoading || loading}
|
||||||
|
{error}
|
||||||
|
emptyTitle="Нет ревизий"
|
||||||
|
emptyDescription="Ревизии появятся после обновления модулей."
|
||||||
|
>
|
||||||
|
{#snippet cell({ row: rev, column })}
|
||||||
|
{#if column.id === 'id'}
|
||||||
|
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||||
|
{:else if column.id === 'created'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(rev.created_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'prefixes'}
|
||||||
|
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
|
||||||
|
<ExternalLink class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
+237
-122
@@ -1,25 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiJSON, apiFetch, apiPageAll } from '$lib/api/client.js';
|
import { resolve } from '$app/paths';
|
||||||
|
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||||
import type {
|
import type {
|
||||||
|
ModuleRow,
|
||||||
ModulesResponse,
|
ModulesResponse,
|
||||||
|
RevisionRow,
|
||||||
RevisionsResponse,
|
RevisionsResponse,
|
||||||
|
PeerRow,
|
||||||
PeersResponse,
|
PeersResponse,
|
||||||
|
SpeakerRow,
|
||||||
SpeakersResponse,
|
SpeakersResponse,
|
||||||
|
JobRow,
|
||||||
JobsResponse
|
JobsResponse
|
||||||
} from '$lib/api/types.js';
|
} 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||||
|
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const resolve = (path: string) => path as any;
|
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
@@ -29,49 +40,33 @@
|
|||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Tags from '@lucide/svelte/icons/tags';
|
||||||
|
import Share2 from '@lucide/svelte/icons/share-2';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
|
|
||||||
let healthy = $state<boolean | null>(null);
|
let healthy = $state<boolean | null>(null);
|
||||||
let modules = $state(0);
|
let moduleItems = $state<ModuleRow[]>([]);
|
||||||
let revisions = $state(0);
|
let modulesHasMore = $state(false);
|
||||||
let peers = $state(0);
|
let revisionItems = $state<RevisionRow[]>([]);
|
||||||
let speakers = $state(0);
|
let revisionsHasMore = $state(false);
|
||||||
|
let peerItems = $state<PeerRow[]>([]);
|
||||||
|
let peersHasMore = $state(false);
|
||||||
|
let speakerItems = $state<SpeakerRow[]>([]);
|
||||||
|
let speakersHasMore = $state(false);
|
||||||
|
let jobItems = $state<JobRow[]>([]);
|
||||||
|
let recentJobs = $state<JobRow[]>([]);
|
||||||
|
let recentRevisions = $state<RevisionRow[]>([]);
|
||||||
let runningJobs = $state(0);
|
let runningJobs = $state(0);
|
||||||
let loading = $state(true);
|
let initialLoading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let lastUpdated = $state<Date | null>(null);
|
||||||
|
|
||||||
async function countAll(path: string): Promise<number> {
|
const moduleNameById = $derived(new Map(moduleItems.map((m) => [m.id, m.name])));
|
||||||
const items = await apiPageAll<unknown>(path, 500);
|
|
||||||
return items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const h = await apiFetch('/v1/health');
|
|
||||||
healthy = h.ok;
|
|
||||||
} catch {
|
|
||||||
healthy = false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const [mCount, rCount, pCount, sCount, j] = await Promise.all([
|
|
||||||
countAll('/v1/modules'),
|
|
||||||
countAll('/v1/revisions'),
|
|
||||||
countAll('/v1/peers'),
|
|
||||||
countAll('/v1/speakers'),
|
|
||||||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
|
||||||
]);
|
|
||||||
modules = mCount;
|
|
||||||
revisions = rCount;
|
|
||||||
peers = pCount;
|
|
||||||
speakers = sCount;
|
|
||||||
runningJobs = (j.items ?? []).filter(
|
|
||||||
(i) => i.status === 'running' || i.status === 'queued'
|
|
||||||
).length;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const statAccents = [
|
const statAccents = [
|
||||||
{
|
{
|
||||||
@@ -106,123 +101,243 @@
|
|||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const stats = $derived([
|
function countBadge(count: number, hasMore: boolean, suffix: string) {
|
||||||
|
if (hasMore) return '200+';
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
{
|
{
|
||||||
|
id: 'modules',
|
||||||
label: 'Модули',
|
label: 'Модули',
|
||||||
value: modules,
|
value: initialLoading ? '—' : String(moduleItems.length),
|
||||||
href: '/modules',
|
href: '/modules' as const,
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
description: 'AS, CDN, домены, IP',
|
description: 'AS, CDN, домены, IP',
|
||||||
accent: statAccents[0]
|
accent: statAccents[0],
|
||||||
|
badge: countBadge(moduleItems.length, modulesHasMore, 'в системе')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'peers',
|
||||||
label: 'Пиры',
|
label: 'Пиры',
|
||||||
value: peers,
|
value: initialLoading ? '—' : String(peerItems.length),
|
||||||
href: '/network',
|
href: '/network' as const,
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
description: 'BGP-соседи',
|
description: 'BGP-соседи',
|
||||||
accent: statAccents[1]
|
accent: statAccents[1],
|
||||||
|
badge: countBadge(peerItems.length, peersHasMore, 'peers')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'speakers',
|
||||||
label: 'Спикеры',
|
label: 'Спикеры',
|
||||||
value: speakers,
|
value: initialLoading ? '—' : String(speakerItems.length),
|
||||||
href: '/network',
|
href: '/network' as const,
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
description: 'BIRD-агенты',
|
description: 'BIRD-агенты',
|
||||||
accent: statAccents[2]
|
accent: statAccents[2],
|
||||||
|
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'revisions',
|
||||||
label: 'Ревизии',
|
label: 'Ревизии',
|
||||||
value: revisions,
|
value: initialLoading ? '—' : String(revisionItems.length),
|
||||||
href: '/operations',
|
href: '/operations' as const,
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
description: 'История конфигураций',
|
description: 'История конфигураций',
|
||||||
accent: statAccents[3]
|
accent: statAccents[3],
|
||||||
|
badge: countBadge(revisionItems.length, revisionsHasMore, 'configs')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'jobs',
|
||||||
label: 'Активных задач',
|
label: 'Активных задач',
|
||||||
value: runningJobs,
|
value: initialLoading ? '—' : String(runningJobs),
|
||||||
href: '/operations',
|
href: '/operations?tab=jobs' as const,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
description: 'Выполняются сейчас',
|
description: 'queued и running в выборке',
|
||||||
accent: statAccents[4]
|
accent: statAccents[4],
|
||||||
|
badge: 'running'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!initialLoading) refreshing = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const [h, m, p, s, r, j] = await Promise.all([
|
||||||
|
apiFetch('/v1/health'),
|
||||||
|
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
|
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
||||||
|
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
|
||||||
|
apiJSON<RevisionsResponse>('/v1/revisions?limit=200'),
|
||||||
|
apiJSON<JobsResponse>('/v1/jobs?limit=20')
|
||||||
|
]);
|
||||||
|
|
||||||
|
healthy = h.ok;
|
||||||
|
moduleItems = m.items ?? [];
|
||||||
|
modulesHasMore = m.has_more;
|
||||||
|
peerItems = p.items ?? [];
|
||||||
|
peersHasMore = p.has_more;
|
||||||
|
speakerItems = s.items ?? [];
|
||||||
|
speakersHasMore = s.has_more;
|
||||||
|
revisionItems = r.items ?? [];
|
||||||
|
revisionsHasMore = r.has_more;
|
||||||
|
jobItems = j.items ?? [];
|
||||||
|
recentJobs = jobItems.slice(0, 10);
|
||||||
|
recentRevisions = revisionItems.slice(0, 10);
|
||||||
|
runningJobs = jobItems.filter((i) => i.status === 'running' || i.status === 'queued').length;
|
||||||
|
lastUpdated = new Date();
|
||||||
|
} catch (e) {
|
||||||
|
healthy = false;
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
initialLoading = false;
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Обзор"
|
title="Обзор"
|
||||||
description="Состояние панели управления EvoBGP."
|
description={lastUpdated
|
||||||
|
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Состояние панели управления EvoBGP.'}
|
||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
iconClass="bg-primary/10 text-primary"
|
iconClass="bg-primary/10 text-primary"
|
||||||
/>
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Health -->
|
<Alert class="border-info/30 bg-info/5">
|
||||||
<Card>
|
<Info class="text-info" />
|
||||||
<CardContent class="flex items-center gap-3 py-4">
|
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||||
{#if healthy === null}
|
<AlertDescription>
|
||||||
<div class="size-3 animate-pulse rounded-full bg-muted"></div>
|
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
|
||||||
<span class="text-sm text-muted-foreground">Проверка…</span>
|
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
|
||||||
{:else if healthy}
|
ревизии —
|
||||||
<CheckCircle class="size-5 text-green-500" />
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||||
<span class="font-medium text-green-700 dark:text-green-400">API работает</span>
|
здоровье системы —
|
||||||
{:else}
|
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||||
<XCircle class="size-5 text-red-500" />
|
</AlertDescription>
|
||||||
<span class="font-medium text-red-700 dark:text-red-400">API недоступен</span>
|
</Alert>
|
||||||
{/if}
|
|
||||||
</CardContent>
|
{#if healthy === null}
|
||||||
</Card>
|
<Alert>
|
||||||
|
<Skeleton class="size-5 rounded-full" />
|
||||||
|
<AlertTitle>Проверка API…</AlertTitle>
|
||||||
|
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if healthy}
|
||||||
|
<Alert class="border-success/30 bg-success/5">
|
||||||
|
<CheckCircle class="text-success" />
|
||||||
|
<AlertTitle>API работает</AlertTitle>
|
||||||
|
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
|
||||||
|
<XCircle class="text-destructive" />
|
||||||
|
<AlertTitle>API недоступен</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Не удалось получить ответ от сервера. Проверьте подключение и статус API.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Stats grid -->
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each stats as stat (stat.label)}
|
{#if initialLoading}
|
||||||
{@const Icon = stat.icon}
|
{#each Array(5) as _, i (i)}
|
||||||
{@const a = stat.accent}
|
<CardSkeleton />
|
||||||
<Card
|
{/each}
|
||||||
class={cn(
|
{:else}
|
||||||
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
{#each kpiCards as card (card.id)}
|
||||||
a.border,
|
{@const Icon = card.icon}
|
||||||
a.bg
|
{@const a = card.accent}
|
||||||
)}
|
<Card
|
||||||
>
|
class={cn(
|
||||||
<CardHeader class="pb-2">
|
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
||||||
<div class="flex items-center justify-between gap-2">
|
a.border,
|
||||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
a.bg
|
||||||
<span
|
)}
|
||||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
>
|
||||||
aria-hidden="true"
|
<CardHeader class="pb-2">
|
||||||
>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Icon class={cn('size-4', a.iconText)} />
|
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||||
</span>
|
<span
|
||||||
<span class="truncate">{stat.label}</span>
|
class={cn(
|
||||||
</CardDescription>
|
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||||
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
|
a.iconBg
|
||||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
)}
|
||||||
</Button>
|
aria-hidden="true"
|
||||||
</div>
|
>
|
||||||
<CardTitle class="text-3xl font-bold tabular-nums">
|
<Icon class={cn('size-4', a.iconText)} />
|
||||||
{loading ? '—' : stat.value}
|
</span>
|
||||||
</CardTitle>
|
<span class="truncate">{card.label}</span>
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||||
<p class="text-xs text-muted-foreground">{stat.description}</p>
|
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</div>
|
||||||
{/each}
|
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-2">
|
||||||
|
<Badge variant="outline">{card.badge}</Badge>
|
||||||
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<OverviewRecentJobsCard
|
||||||
|
items={recentJobs}
|
||||||
|
{moduleNameById}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
error={loadError}
|
||||||
|
/>
|
||||||
|
<OverviewRecentRevisionsCard
|
||||||
|
items={recentRevisions}
|
||||||
|
loading={refreshing}
|
||||||
|
{initialLoading}
|
||||||
|
error={loadError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick links -->
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="border-b py-3">
|
||||||
<CardTitle class="text-base">Быстрые действия</CardTitle>
|
<CardTitle class="text-base">Быстрые действия</CardTitle>
|
||||||
|
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex flex-wrap gap-2">
|
<CardContent class="flex flex-wrap gap-2 p-4 pt-4">
|
||||||
<Button variant="outline" size="sm" href={resolve('/modules')}>Создать модуль</Button>
|
<Button variant="outline" size="sm" href={resolve('/modules')}>
|
||||||
<Button variant="outline" size="sm" href={resolve('/directories')}>Добавить community</Button>
|
<Plus class="size-4" />
|
||||||
<Button variant="outline" size="sm" href={resolve('/network')}>Добавить пира</Button>
|
Создать модуль
|
||||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Деплой (Apply)</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>Мониторинг</Button>
|
<Button variant="outline" size="sm" href={resolve('/directories')}>
|
||||||
|
<Tags class="size-4" />
|
||||||
|
Добавить community
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network')}>
|
||||||
|
<Share2 class="size-4" />
|
||||||
|
Добавить пира
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||||
|
<Play class="size-4" />
|
||||||
|
Деплой (Apply)
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||||
|
<Gauge class="size-4" />
|
||||||
|
Мониторинг
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user