feat(web): enhance monitoring page with new status tracking and UI components
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m36s
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m36s
- Refactored monitoring page to utilize new UI components from the core library. - Added detailed job status tracking and error handling features. - Improved overall layout and responsiveness of the monitoring interface. - Introduced new derived states for better management of health and job statuses. - Updated imports to streamline component usage and enhance maintainability.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/** Pure-helpers для страницы /monitoring. */
|
||||
|
||||
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
|
||||
|
||||
export type JobsKpi = { running: number; failed: number; total: number };
|
||||
|
||||
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
|
||||
|
||||
export type HealthStatus = { ok: boolean; status?: string; error?: string };
|
||||
|
||||
export type CheckBadge = {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
class?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim() !== '') return error.message;
|
||||
return 'Ошибка запроса';
|
||||
}
|
||||
|
||||
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
|
||||
let running = 0;
|
||||
let failed = 0;
|
||||
for (const row of items) {
|
||||
const status = String(row.status ?? '').toLowerCase();
|
||||
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
|
||||
running += 1;
|
||||
}
|
||||
if (status === 'failed' || status === 'error' || status === 'canceled') {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { running, failed, total: items.length };
|
||||
}
|
||||
|
||||
export function deriveOverallStatus(input: {
|
||||
health: HealthStatus | null;
|
||||
ready: ReadyStatus | null;
|
||||
jobs: JobsKpi | null;
|
||||
birdConfigured: boolean;
|
||||
birdHealthy: boolean | null;
|
||||
}): OverallStatus {
|
||||
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
|
||||
if (health === null && ready === null && jobs === null) return 'unknown';
|
||||
if (!health?.ok) return 'error';
|
||||
if (ready !== null && ready.status !== 'ready') return 'error';
|
||||
if (jobs !== null && jobs.failed > 0) return 'warn';
|
||||
if (birdConfigured && birdHealthy === false) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function overallStatusLabel(status: OverallStatus): string {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return 'В норме';
|
||||
case 'warn':
|
||||
return 'Внимание';
|
||||
case 'error':
|
||||
return 'Ошибка';
|
||||
default:
|
||||
return 'Нет данных';
|
||||
}
|
||||
}
|
||||
|
||||
export function overallStatusHint(
|
||||
status: OverallStatus,
|
||||
input: {
|
||||
healthOk: boolean | undefined;
|
||||
jobsFailed: number;
|
||||
}
|
||||
): string {
|
||||
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
|
||||
if (status === 'error') {
|
||||
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
|
||||
return 'Readiness не в норме: проверьте postgres/store/jobs.';
|
||||
}
|
||||
if (status === 'warn') {
|
||||
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
|
||||
return 'Проверьте BGP-сессии и вывод birdc.';
|
||||
}
|
||||
return 'Критичных отклонений не обнаружено.';
|
||||
}
|
||||
|
||||
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
|
||||
if (status === 'ok') return 'default';
|
||||
if (status === 'warn') return 'secondary';
|
||||
if (status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
export function overallBadgeClass(status: OverallStatus): string | undefined {
|
||||
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
|
||||
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Нормализация значений ready.checks и health/readiness в badge. */
|
||||
export function checkStatusBadge(value: unknown): CheckBadge {
|
||||
const raw = String(value ?? '').trim();
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
|
||||
return {
|
||||
label: 'OK',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
if (lower === 'memory') {
|
||||
return {
|
||||
label: 'In-memory',
|
||||
variant: 'secondary',
|
||||
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
|
||||
};
|
||||
}
|
||||
if (
|
||||
lower === 'failed' ||
|
||||
lower === 'false' ||
|
||||
lower === 'error' ||
|
||||
lower === 'down' ||
|
||||
lower === 'unavailable'
|
||||
) {
|
||||
return { label: 'Ошибка', variant: 'destructive' };
|
||||
}
|
||||
if (raw === '') {
|
||||
return { label: '—', variant: 'outline' };
|
||||
}
|
||||
return { label: raw, variant: 'outline' };
|
||||
}
|
||||
|
||||
/** Человекочитаемое имя проверки readiness. */
|
||||
export function checkDisplayName(key: string): string {
|
||||
switch (key) {
|
||||
case 'postgres':
|
||||
return 'PostgreSQL';
|
||||
case 'store':
|
||||
return 'Хранилище';
|
||||
case 'jobs':
|
||||
return 'Очередь задач';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
export function livenessBadge(health: HealthStatus | null): CheckBadge {
|
||||
if (health === null) return { label: '—', variant: 'outline' };
|
||||
if (health.ok) {
|
||||
return {
|
||||
label: 'В норме',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
return { label: 'Недоступен', variant: 'destructive' };
|
||||
}
|
||||
|
||||
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
|
||||
if (ready === null) return { label: '—', variant: 'outline' };
|
||||
if (ready.status === 'ready') {
|
||||
return {
|
||||
label: 'Готов',
|
||||
variant: 'default',
|
||||
class: 'border-success/30 bg-success/15 text-success'
|
||||
};
|
||||
}
|
||||
return { label: ready.status || 'Не готов', variant: 'destructive' };
|
||||
}
|
||||
@@ -1,21 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||
import type { BirdStatus, JobsResponse } from '$lib/api/types.js';
|
||||
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
|
||||
import {
|
||||
formatVersionHeadline,
|
||||
resolveVersionString,
|
||||
type VersionInfo
|
||||
} from '$lib/api/version-info.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||
import { jobStatusRu } from '$lib/ui-labels.js';
|
||||
import {
|
||||
checkDisplayName,
|
||||
checkStatusBadge,
|
||||
deriveOverallStatus,
|
||||
livenessBadge,
|
||||
overallBadgeClass,
|
||||
overallBadgeVariant,
|
||||
overallStatusHint,
|
||||
overallStatusLabel,
|
||||
readinessBadge,
|
||||
summarizeJobsStatuses,
|
||||
toErrorMessage,
|
||||
type HealthStatus,
|
||||
type JobsKpi,
|
||||
type ReadyStatus
|
||||
} from '$lib/monitoring/status.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/components/ui/card/index.js';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/ui/core/table/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
@@ -23,80 +56,86 @@
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
import Hash from '@lucide/svelte/icons/hash';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import HeartPulse from '@lucide/svelte/icons/heart-pulse';
|
||||
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||
import Database from '@lucide/svelte/icons/database';
|
||||
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||||
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
type ReadyStatus = { status: string; checks?: Record<string, unknown> };
|
||||
type JobsKpi = { running: number; failed: number; total: number };
|
||||
|
||||
let health = $state<{ ok: boolean; status?: string; error?: string } | null>(null);
|
||||
let health = $state<HealthStatus | null>(null);
|
||||
let ready = $state<ReadyStatus | null>(null);
|
||||
let version = $state<VersionInfo | null>(null);
|
||||
let bird = $state<BirdStatus | null>(null);
|
||||
let jobs = $state<JobsKpi | null>(null);
|
||||
let jobItems = $state<JobRow[]>([]);
|
||||
let readyError = $state<string | null>(null);
|
||||
let versionError = $state<string | null>(null);
|
||||
let birdError = $state<string | null>(null);
|
||||
let jobsError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
let loading = $state(false);
|
||||
let initialLoading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim() !== '') return error.message;
|
||||
return 'Ошибка запроса';
|
||||
}
|
||||
|
||||
function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
|
||||
let running = 0;
|
||||
let failed = 0;
|
||||
for (const row of items) {
|
||||
const status = String(row.status ?? '').toLowerCase();
|
||||
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
|
||||
running += 1;
|
||||
}
|
||||
if (status === 'failed' || status === 'error' || status === 'canceled') {
|
||||
failed += 1;
|
||||
}
|
||||
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'
|
||||
},
|
||||
{
|
||||
border: 'border-l-info',
|
||||
bg: 'bg-info/10',
|
||||
iconBg: 'bg-info/15',
|
||||
iconText: 'text-info'
|
||||
}
|
||||
return { running, failed, total: items.length };
|
||||
}
|
||||
] as const;
|
||||
|
||||
function overallBadgeVariant(status: 'ok' | 'warn' | 'error' | 'unknown') {
|
||||
if (status === 'ok') return 'default';
|
||||
if (status === 'warn') return 'secondary';
|
||||
if (status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
const overallStatus = $derived.by(() =>
|
||||
deriveOverallStatus({
|
||||
health,
|
||||
ready,
|
||||
jobs,
|
||||
birdConfigured: bird?.birdc_configured ?? false,
|
||||
birdHealthy: bird?.healthy ?? null
|
||||
})
|
||||
);
|
||||
|
||||
const overallStatus = $derived.by(() => {
|
||||
if (health === null && ready === null && bird === null && jobs === null) return 'unknown';
|
||||
if (!health?.ok) return 'error';
|
||||
if (ready !== null && ready.status !== 'ready') return 'error';
|
||||
if (jobs !== null && jobs.failed > 0) return 'warn';
|
||||
if (bird !== null && bird.birdc_configured && bird.healthy === false) return 'warn';
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
const overallHint = $derived.by(() => {
|
||||
if (overallStatus === 'unknown') return 'Нет данных. Запустите обновление.';
|
||||
if (overallStatus === 'error') {
|
||||
if (!health?.ok) return 'Проверьте доступность API и логи сервиса.';
|
||||
return 'Readiness не в норме: проверьте postgres/store/jobs.';
|
||||
}
|
||||
if (overallStatus === 'warn') {
|
||||
if (jobs && jobs.failed > 0)
|
||||
return 'Есть ошибки в задачах: откройте операции и последние jobs.';
|
||||
return 'Проверьте BGP-сессии и вывод birdc.';
|
||||
}
|
||||
return 'Критичных отклонений не обнаружено.';
|
||||
});
|
||||
const overallHint = $derived.by(() =>
|
||||
overallStatusHint(overallStatus, {
|
||||
healthOk: health?.ok,
|
||||
jobsFailed: jobs?.failed ?? 0
|
||||
})
|
||||
);
|
||||
|
||||
const bgpText = $derived.by(() => {
|
||||
if (!bird) return '—';
|
||||
if (!bird.birdc_configured) return 'birdc не настроен';
|
||||
if (bird.error) return 'ошибка birdc';
|
||||
if (!bird.birdc_configured) return '—';
|
||||
if (bird.error) return '—';
|
||||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||
});
|
||||
|
||||
const bgpRatio = $derived.by(() => {
|
||||
if (!bird?.birdc_configured || bird.bgp_sessions_total <= 0) return null;
|
||||
return Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100);
|
||||
});
|
||||
|
||||
const versionText = $derived(formatVersionHeadline(version));
|
||||
|
||||
const versionFooter = $derived.by(() => {
|
||||
@@ -109,46 +148,156 @@
|
||||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
const failedJobs = $derived.by(() =>
|
||||
jobItems
|
||||
.filter((j) => {
|
||||
const s = String(j.status ?? '').toLowerCase();
|
||||
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||
})
|
||||
.slice(0, 5)
|
||||
);
|
||||
|
||||
const checkIconByKey: Record<string, Component> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo
|
||||
};
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'overall',
|
||||
label: 'Общий статус',
|
||||
value: initialLoading ? '—' : overallStatusLabel(overallStatus),
|
||||
description: overallHint,
|
||||
icon: Server,
|
||||
accent: statAccents[0],
|
||||
badge: overallStatusLabel(overallStatus),
|
||||
badgeVariant: overallBadgeVariant(overallStatus),
|
||||
badgeClass: overallBadgeClass(overallStatus),
|
||||
error: null as string | null,
|
||||
href: undefined
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
label: 'BGP сессии',
|
||||
value: initialLoading ? '—' : bgpText,
|
||||
description: bird?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: (bird?.message ?? 'birdc не настроен на API-хосте'),
|
||||
icon: Bird,
|
||||
accent: statAccents[1],
|
||||
badge: !bird ? '—' : !bird.birdc_configured ? 'N/A' : bird.healthy ? 'В норме' : 'Деградация',
|
||||
badgeVariant: !bird?.birdc_configured
|
||||
? ('outline' as const)
|
||||
: bird?.healthy
|
||||
? ('default' as const)
|
||||
: ('secondary' as const),
|
||||
badgeClass:
|
||||
bird?.birdc_configured && bird?.healthy
|
||||
? 'border-success/30 bg-success/15 text-success'
|
||||
: undefined,
|
||||
error: birdError ?? bird?.error ?? null,
|
||||
href: '/network' as const
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Задачи',
|
||||
value: initialLoading ? '—' : String(jobs?.running ?? '—'),
|
||||
description: `Активных из ${jobs?.total ?? '—'} последних`,
|
||||
icon: Activity,
|
||||
accent: statAccents[2],
|
||||
badge: jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок',
|
||||
badgeVariant: jobs && jobs.failed > 0 ? ('secondary' as const) : ('default' as const),
|
||||
badgeClass:
|
||||
jobs && jobs.failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||
error: jobsError,
|
||||
href: jobs && jobs.failed > 0 ? ('/operations' as const) : undefined
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
label: 'Версия',
|
||||
value: initialLoading ? '—' : versionText,
|
||||
description: versionFooter || 'GET /v1/version',
|
||||
icon: Hash,
|
||||
accent: statAccents[3],
|
||||
badge: version ? 'Загружена' : '—',
|
||||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||
badgeClass: undefined,
|
||||
error: versionError,
|
||||
href: undefined
|
||||
}
|
||||
]);
|
||||
|
||||
async function fetchHealth(): Promise<HealthStatus> {
|
||||
const h = await apiFetch('/v1/health');
|
||||
const hj = (await h.json().catch(() => ({}))) as { status?: string };
|
||||
return { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (initialLoading) {
|
||||
/* keep skeleton */
|
||||
} else {
|
||||
refreshing = true;
|
||||
}
|
||||
readyError = null;
|
||||
versionError = null;
|
||||
birdError = null;
|
||||
jobsError = null;
|
||||
try {
|
||||
const h = await apiFetch('/v1/health');
|
||||
const hj = await h.json().catch(() => ({}));
|
||||
health = { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
|
||||
} catch (error) {
|
||||
health = { ok: false, error: toErrorMessage(error) };
|
||||
|
||||
const [healthRes, readyRes, versionRes, birdRes, jobsRes] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
apiJSON<ReadyStatus>('/v1/ready'),
|
||||
apiJSON<VersionInfo>('/v1/version'),
|
||||
apiJSON<BirdStatus>('/v1/bird/status'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||
]);
|
||||
|
||||
if (healthRes.status === 'fulfilled') {
|
||||
health = healthRes.value;
|
||||
} else {
|
||||
health = { ok: false, error: toErrorMessage(healthRes.reason) };
|
||||
}
|
||||
try {
|
||||
ready = await apiJSON<ReadyStatus>('/v1/ready');
|
||||
} catch (error) {
|
||||
|
||||
if (readyRes.status === 'fulfilled') {
|
||||
ready = readyRes.value;
|
||||
} else {
|
||||
ready = null;
|
||||
readyError = toErrorMessage(error);
|
||||
readyError = toErrorMessage(readyRes.reason);
|
||||
}
|
||||
try {
|
||||
version = await apiJSON<VersionInfo>('/v1/version');
|
||||
} catch (error) {
|
||||
|
||||
if (versionRes.status === 'fulfilled') {
|
||||
version = versionRes.value;
|
||||
} else {
|
||||
version = null;
|
||||
versionError = toErrorMessage(error);
|
||||
versionError = toErrorMessage(versionRes.reason);
|
||||
}
|
||||
try {
|
||||
bird = await apiJSON<BirdStatus>('/v1/bird/status');
|
||||
} catch (error) {
|
||||
|
||||
if (birdRes.status === 'fulfilled') {
|
||||
bird = birdRes.value;
|
||||
} else {
|
||||
bird = null;
|
||||
birdError = toErrorMessage(error);
|
||||
birdError = toErrorMessage(birdRes.reason);
|
||||
}
|
||||
try {
|
||||
const jobsPage = await apiJSON<JobsResponse>('/v1/jobs?limit=100');
|
||||
jobs = summarizeJobsStatuses(jobsPage.items ?? []);
|
||||
} catch (error) {
|
||||
|
||||
if (jobsRes.status === 'fulfilled') {
|
||||
const items = jobsRes.value.items ?? [];
|
||||
jobItems = items;
|
||||
jobs = summarizeJobsStatuses(items);
|
||||
} else {
|
||||
jobItems = [];
|
||||
jobs = null;
|
||||
jobsError = toErrorMessage(error);
|
||||
jobsError = toErrorMessage(jobsRes.reason);
|
||||
}
|
||||
|
||||
lastUpdated = new Date();
|
||||
loading = false;
|
||||
initialLoading = false;
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
function truncateError(error: string | null | undefined, max = 120): string {
|
||||
if (!error) return '';
|
||||
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
@@ -164,189 +313,394 @@
|
||||
iconClass="bg-info/15 text-info"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card class="border-l-4 border-l-chart-1 bg-chart-1/5 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Server class="size-4" />
|
||||
Общий статус
|
||||
</CardTitle>
|
||||
<CardDescription>Liveness + readiness + jobs/BGP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-2xl font-semibold uppercase">{overallStatus}</p>
|
||||
<Badge variant={overallBadgeVariant(overallStatus)}>{overallStatus}</Badge>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{overallHint}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#if !initialLoading}
|
||||
{#if overallStatus === 'ok'}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>Система в норме</AlertTitle>
|
||||
<AlertDescription>{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if overallStatus === 'warn'}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<AlertTriangle class="text-warning" />
|
||||
<AlertTitle>Требуется внимание</AlertTitle>
|
||||
<AlertDescription>{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if overallStatus === 'error'}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>Обнаружена проблема</AlertTitle>
|
||||
<AlertDescription>{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Card class="border-l-4 border-l-chart-2 bg-chart-2/5 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Bird class="size-4" />
|
||||
BGP сессии
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-2xl font-semibold">{bgpText}</p>
|
||||
{#if bird?.birdc_configured}
|
||||
<Badge variant={bird.healthy ? 'default' : 'secondary'}
|
||||
>{bird.healthy ? 'healthy' : 'degraded'}</Badge
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{#if initialLoading}
|
||||
{#each Array(4) 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={cn(
|
||||
'font-bold tabular-nums',
|
||||
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
|
||||
)}
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="outline">n/a</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if birdError}
|
||||
<p class="mt-2 text-xs text-destructive">{birdError}</p>
|
||||
{:else if bird && !bird.birdc_configured}
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{bird.message ?? 'birdc не настроен на API-хосте.'}
|
||||
</p>
|
||||
{:else if bird?.error}
|
||||
<p class="mt-2 text-xs text-destructive">{bird.error}</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-muted-foreground">Established / total на API-хосте.</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-l-4 border-l-chart-4 bg-chart-4/5 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Activity class="size-4" />
|
||||
Задачи
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/jobs?limit=100</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-2xl font-semibold">{jobs ? jobs.running : '—'}</p>
|
||||
<Badge variant={jobs && jobs.failed > 0 ? 'secondary' : 'default'}>
|
||||
{jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок'}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if jobsError}
|
||||
<p class="mt-2 text-xs text-destructive">{jobsError}</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Активных: {jobs?.running ?? '—'} из {jobs?.total ?? '—'} последних.
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-l-4 border-l-info bg-info/10 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Hash class="size-4" />
|
||||
Версия
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/version</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="font-mono text-2xl font-semibold">{versionText}</p>
|
||||
<Badge variant={version ? 'outline' : 'secondary'}>{version ? 'loaded' : '—'}</Badge>
|
||||
</div>
|
||||
{#if versionError}
|
||||
<p class="mt-2 text-xs text-destructive">{versionError}</p>
|
||||
{:else if versionFooter}
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground">{versionFooter}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{card.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
|
||||
</div>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Доступность и готовность</CardTitle>
|
||||
<CardDescription>GET /v1/health и GET /v1/ready</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">Liveness:</span>
|
||||
{#if health === null}
|
||||
<Badge variant="outline">—</Badge>
|
||||
{:else if health.ok}
|
||||
<Badge variant="default">ok</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive">failed</Badge>
|
||||
{#if initialLoading}
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
{:else}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Доступность и готовность</CardTitle>
|
||||
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if health?.error || readyError}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>Ошибка проверки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{#if health?.error}{health.error}{/if}
|
||||
{#if health?.error && readyError}<br />{/if}
|
||||
{#if readyError}{readyError}{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if health?.status}
|
||||
<span class="text-muted-foreground">{health.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">Readiness:</span>
|
||||
{#if ready === null}
|
||||
<Badge variant="outline">—</Badge>
|
||||
{:else}
|
||||
<Badge variant={ready.status === 'ready' ? 'default' : 'destructive'}
|
||||
>{ready.status}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[55%]">Проверка</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{@const liveBadge = livenessBadge(health)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<HeartPulse class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">Liveness</p>
|
||||
<p class="text-xs text-muted-foreground">/v1/health</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={liveBadge.variant} class={liveBadge.class}
|
||||
>{liveBadge.label}</Badge
|
||||
>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{@const readyBadge = readinessBadge(ready)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">Readiness</p>
|
||||
<p class="text-xs text-muted-foreground">/v1/ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={readyBadge.variant} class={readyBadge.class}
|
||||
>{readyBadge.label}</Badge
|
||||
>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#if ready?.checks && Object.keys(ready.checks).length > 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={2} class="bg-muted/30 py-2">
|
||||
<p class="text-xs font-medium text-muted-foreground">Зависимости</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#each Object.entries(ready.checks) as [key, value] (key)}
|
||||
{@const badge = checkStatusBadge(value)}
|
||||
{@const CheckIcon = checkIconByKey[key] ?? ListTodo}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckIcon
|
||||
class="size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{checkDisplayName(key)}</p>
|
||||
<p class="text-xs text-muted-foreground">{key}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge variant={badge.variant} class={badge.class}>{badge.label}</Badge>
|
||||
{#if badge.hint}
|
||||
<p class="text-xs text-muted-foreground">{badge.hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{#if health?.error}
|
||||
<p class="text-xs text-destructive">{health.error}</p>
|
||||
{/if}
|
||||
{#if readyError}
|
||||
<p class="text-xs text-destructive">{readyError}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
HTTP 503 на readiness означает недоступность одной из зависимостей в checks.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if ready?.checks}
|
||||
<div class="space-y-1">
|
||||
{#each Object.entries(ready.checks) as [k, v] (k)}
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-2 text-xs">
|
||||
<span class="text-muted-foreground">{k}</span>
|
||||
<Badge variant="outline" class="justify-start font-normal">{String(v)}</Badge>
|
||||
</div>
|
||||
{/each}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Bird class="size-4" />
|
||||
BGP на API-хосте
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if birdError}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>Ошибка birdc</AlertTitle>
|
||||
<AlertDescription>{birdError}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if bird && !bird.birdc_configured}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
|
||||
</p>
|
||||
{:else if bird}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Established / total</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||||
{#if bgpRatio !== null}
|
||||
<span class="text-muted-foreground">({bgpRatio}%)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if bgpRatio !== null}
|
||||
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
bgpRatio >= 100
|
||||
? 'bg-success'
|
||||
: bgpRatio >= 50
|
||||
? 'bg-warning'
|
||||
: 'bg-destructive'
|
||||
)}
|
||||
style="width: {bgpRatio}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bird.error}
|
||||
<p class="text-xs text-destructive">{bird.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bird.protocols_excerpt}
|
||||
<Separator />
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Вывод birdc (protocols)</p>
|
||||
<ScrollPreBlock variant="preserve" text={bird.protocols_excerpt} class="max-h-48" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<AlertTriangle class="size-4 text-muted-foreground" />
|
||||
Что проверять при деградации
|
||||
</CardTitle>
|
||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-sm">
|
||||
<p>
|
||||
<span class="text-muted-foreground">Если </span><code>health=failed</code> — проверьте доступность
|
||||
процесса API и его логи.
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-muted-foreground">Если </span><code>ready!=ready</code> — сначала `postgres`,
|
||||
затем `store/jobs` в checks.
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-muted-foreground">Если low BGP ratio</span> — проверьте `bird/status`, затем
|
||||
BGP peer состояния.
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-muted-foreground">Если ошибки jobs</span> — откройте операции и проверьте
|
||||
последние неуспешные задачи.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{#if initialLoading}
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
{:else}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Activity class="size-4" />
|
||||
Задачи
|
||||
</CardTitle>
|
||||
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if jobsError}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>Не удалось загрузить задачи</AlertTitle>
|
||||
<AlertDescription>{jobsError}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if jobs}
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Активных</p>
|
||||
<p class="text-2xl font-bold tabular-nums">{jobs.running}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">С ошибками</p>
|
||||
<p
|
||||
class={cn(
|
||||
'text-2xl font-bold tabular-nums',
|
||||
jobs.failed > 0 ? 'text-warning' : 'text-success'
|
||||
)}
|
||||
>
|
||||
{jobs.failed}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">В выборке</p>
|
||||
<p class="text-2xl font-bold tabular-nums">{jobs.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{#if failedJobs.length > 0}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium">Последние ошибки</p>
|
||||
<ul class="space-y-2">
|
||||
{#each failedJobs as job (job.job_id)}
|
||||
<li class="rounded-lg border px-3 py-2 text-sm">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="font-medium">{jobKindTitle(job)}</p>
|
||||
<Badge variant="destructive">{jobStatusRu(job.status)}</Badge>
|
||||
</div>
|
||||
{#if job.error}
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{truncateError(job.error)}
|
||||
</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Критичных сбоев в последних 100 задачах нет.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<AlertTriangle class="size-4 text-muted-foreground" />
|
||||
Что проверять при деградации
|
||||
</CardTitle>
|
||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<Alert>
|
||||
<HeartPulse class="size-4" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если <code class="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||||
его логи.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Database class="size-4" />
|
||||
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сначала <code class="text-xs">postgres</code>, затем
|
||||
<code class="text-xs">store</code> и <code class="text-xs">jobs</code> в checks.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Bird class="size-4" />
|
||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<ListTodo class="size-4" />
|
||||
<AlertTitle>Ошибки задач</AlertTitle>
|
||||
<AlertDescription>
|
||||
Откройте
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}
|
||||
>Операции</Button
|
||||
>
|
||||
и проверьте последние неуспешные jobs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user