feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s

Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation.
This commit is contained in:
Denozordec
2026-07-09 12:09:08 +07:00
parent 452f6b2db0
commit e04fea657c
32 changed files with 1561 additions and 202 deletions
@@ -0,0 +1,32 @@
import type { SpeakerRow } from '@/types/api'
import type { DeploymentProgress } from './types'
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
if (speakers.length === 0) {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
).length
return {
percent: Math.round((synced / withRevision.length) * 100),
synced,
total: withRevision.length,
mode: 'revision',
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
total: speakers.length,
mode: 'online',
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './types'
export * from './job-status-breakdown'
export * from './module-type-breakdown'
export * from './peer-capacity-bars'
export * from './deployment-progress'
export * from './readiness-breakdown'
export * from './peer-session-breakdown'
export * from './recent-platform-activity'
@@ -0,0 +1,52 @@
import type { JobRow } from '@/types/api'
import type { BreakdownSlice } from './types'
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
]
function bucketForStatus(status: string): string {
const s = status.toLowerCase()
for (const bucket of STATUS_BUCKETS) {
if (bucket.keys.includes(s)) return bucket.label
}
return 'Прочее'
}
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
const counts = new Map<string, number>()
for (const job of jobs) {
const label = bucketForStatus(job.status)
counts.set(label, (counts.get(label) ?? 0) + 1)
}
const slices: BreakdownSlice[] = []
for (const bucket of STATUS_BUCKETS) {
const count = counts.get(bucket.label) ?? 0
if (count > 0) {
slices.push({
key: bucket.label,
label: bucket.label,
count,
color: bucket.color,
})
}
}
const other = counts.get('Прочее') ?? 0
if (other > 0) {
slices.push({
key: 'other',
label: 'Прочее',
count: other,
color: 'var(--color-chart-4)',
})
}
return slices
}
export function jobStatusTotal(jobs: JobRow[]): number {
return jobs.length
}
@@ -0,0 +1,26 @@
import type { ModuleRow, ModuleType } from '@/types/api'
import type { BreakdownSlice } from './types'
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
}
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
const counts = new Map<ModuleType, number>()
for (const mod of modules) {
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
}
return (Object.keys(TYPE_META) as ModuleType[])
.map((type) => ({
key: type,
label: TYPE_META[type].label,
count: counts.get(type) ?? 0,
color: TYPE_META[type].color,
}))
.filter((slice) => slice.count > 0)
}
@@ -0,0 +1,42 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import type { CapacityBar } from './types'
function peerLabel(peer: PeerRow): string {
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
}
function speakerLabel(speaker: SpeakerRow): string {
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
}
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
}
})
}
export function capacityUtilization(bars: CapacityBar[]): number {
if (bars.length === 0) return 0
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
return Math.round(sum / bars.length)
}
@@ -0,0 +1,41 @@
import type { PeerRow } from '@/types/api'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
).length
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
if (established > 0) {
slices.push({
key: 'established',
label: 'Established',
count: established,
color: 'var(--color-chart-2)',
})
}
if (pending > 0) {
slices.push({
key: 'pending',
label: 'Не Established',
count: pending,
color: 'var(--color-warning)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
label: 'Выключены',
count: disabled,
color: 'var(--color-chart-4)',
})
}
return slices.length > 0
? slices
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
}
@@ -0,0 +1,71 @@
import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
if (typeof value === 'boolean') return value
if (value && typeof value === 'object') return value.ok === true
return false
}
export function readinessBreakdown(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
): BreakdownSlice[] {
if (!healthOk) {
return [
{
key: 'health-fail',
label: 'API недоступен',
count: 1,
color: 'var(--color-destructive)',
},
]
}
const checks = ready?.checks ?? {}
let okCount = 0
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
else failCount += 1
}
const slices: BreakdownSlice[] = [
{
key: 'health',
label: 'Health OK',
count: 1,
color: 'var(--color-chart-2)',
},
]
if (okCount > 0) {
slices.push({
key: 'checks-ok',
label: 'Checks OK',
count: okCount,
color: 'var(--color-chart-1)',
})
}
if (failCount > 0) {
slices.push({
key: 'checks-fail',
label: 'Checks fail',
count: failCount,
color: 'var(--color-warning)',
})
}
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Ready' : 'Ready pending',
count: 1,
color: 'var(--color-chart-4)',
})
}
return slices
}
@@ -0,0 +1,64 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import type { PlatformActivityItem } from './types'
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
apply: 'Применение конфигурации',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
}
function jobMessage(job: JobRow): string {
const kind = JOB_KIND_RU[job.kind] ?? job.kind
return `${kind} · ${job.status}`
}
export function recentPlatformActivity(
jobs: JobRow[],
revisions: RevisionRow[],
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 5,
): PlatformActivityItem[] {
const items: PlatformActivityItem[] = []
for (const job of jobs.slice(0, 3)) {
items.push({
id: `job-${job.job_id}`,
message: jobMessage(job),
status: job.status,
kind: 'job',
})
}
for (const rev of revisions.slice(0, 2)) {
items.push({
id: `rev-${rev.id}`,
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
status: 'ok',
statusLabel: 'Создана',
kind: 'revision',
})
}
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
items.push({
id: `peer-${peer.id}`,
message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`,
status: 'mismatch',
kind: 'network',
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}
return items.slice(0, limit)
}
+28
View File
@@ -0,0 +1,28 @@
export type BreakdownSlice = {
key: string
label: string
count: number
color: string
}
export type CapacityBar = {
id: string
name: string
/** 0100 utilization */
value: number
}
export type PlatformActivityItem = {
id: string
message: string
status: string
statusLabel?: string
kind: 'job' | 'revision' | 'network'
}
export type DeploymentProgress = {
percent: number
synced: number
total: number
mode: 'revision' | 'online'
}