|
|
|
|
@@ -1,38 +1,431 @@
|
|
|
|
|
import { Card, Col, Row, Statistic, Typography } from 'antd'
|
|
|
|
|
import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd'
|
|
|
|
|
import {
|
|
|
|
|
ApartmentOutlined, ApiOutlined, BranchesOutlined, ClusterOutlined,
|
|
|
|
|
DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined,
|
|
|
|
|
SafetyCertificateOutlined, ThunderboltOutlined,
|
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
|
|
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
|
|
|
import PageHeader from '../../components/PageHeader'
|
|
|
|
|
import StatusDot from '../../components/StatusDot'
|
|
|
|
|
|
|
|
|
|
const { Text } = Typography
|
|
|
|
|
|
|
|
|
|
// ── Wire shapes ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface Domain { id: number; active: boolean; primary_backend_id?: number | null }
|
|
|
|
|
interface Backend { id: number; active: boolean; name: string }
|
|
|
|
|
interface Iface { id: number; active: boolean; name: string; type: string }
|
|
|
|
|
interface FwRule { id: number; enabled: boolean; action: string }
|
|
|
|
|
interface FwNAT { id: number; enabled: boolean; kind: string }
|
|
|
|
|
interface FwZone { id: number; name: string; builtin: boolean }
|
|
|
|
|
interface TLSCert { id: number; common_name: string; not_after?: string }
|
|
|
|
|
interface ClusterNode { id: string; fqdn: string; role: string }
|
|
|
|
|
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
|
|
|
|
interface WGStatusRow {
|
|
|
|
|
interface: string
|
|
|
|
|
peer_public_key: string
|
|
|
|
|
endpoint?: string
|
|
|
|
|
last_handshake_unix: number
|
|
|
|
|
transfer_rx: number
|
|
|
|
|
transfer_tx: number
|
|
|
|
|
}
|
|
|
|
|
interface ServiceStatus {
|
|
|
|
|
label: string
|
|
|
|
|
unit: string
|
|
|
|
|
active: boolean
|
|
|
|
|
state: string
|
|
|
|
|
since?: string
|
|
|
|
|
}
|
|
|
|
|
interface Resources {
|
|
|
|
|
load_avg_1: number; load_avg_5: number; load_avg_15: number
|
|
|
|
|
mem_total_kb: number; mem_avail_kb: number; mem_used_pct: number
|
|
|
|
|
disk_total_gb: number; disk_free_gb: number; disk_used_pct: number
|
|
|
|
|
conntrack_count: number; conntrack_max: number
|
|
|
|
|
uptime_sec: number; boot_time_unix: number
|
|
|
|
|
}
|
|
|
|
|
interface AuditEntry {
|
|
|
|
|
id: number; actor: string; action: string; subject?: string
|
|
|
|
|
detail?: unknown; created_at: string
|
|
|
|
|
}
|
|
|
|
|
interface HAProxyBackend {
|
|
|
|
|
backend: string; server: string; status: string
|
|
|
|
|
sessions: number; bytes_in: number; bytes_out: number
|
|
|
|
|
last_change_sec: number; health?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Fetchers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function fetchList<T>(url: string, key: string): Promise<T[]> {
|
|
|
|
|
try {
|
|
|
|
|
const r = await apiClient.get(url)
|
|
|
|
|
if (!isEnvelope(r.data)) return []
|
|
|
|
|
return ((r.data.data as Record<string, T[]>)[key]) ?? []
|
|
|
|
|
} catch { return [] }
|
|
|
|
|
}
|
|
|
|
|
async function fetchOne<T>(url: string): Promise<T | null> {
|
|
|
|
|
try {
|
|
|
|
|
const r = await apiClient.get(url)
|
|
|
|
|
return isEnvelope(r.data) ? (r.data.data as T) : null
|
|
|
|
|
} catch { return null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function formatBytes(n: number): string {
|
|
|
|
|
if (!n) return '0 B'
|
|
|
|
|
if (n < 1024) return `${n} B`
|
|
|
|
|
const u = ['KiB', 'MiB', 'GiB', 'TiB']
|
|
|
|
|
let v = n / 1024, i = 0
|
|
|
|
|
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
|
|
|
|
|
return `${v.toFixed(1)} ${u[i]}`
|
|
|
|
|
}
|
|
|
|
|
function relativeTime(unix: number): string {
|
|
|
|
|
if (!unix) return 'nie'
|
|
|
|
|
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
|
|
|
|
|
if (sec < 60) return `vor ${sec}s`
|
|
|
|
|
if (sec < 3600) return `vor ${Math.floor(sec / 60)}m`
|
|
|
|
|
if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h`
|
|
|
|
|
return `vor ${Math.floor(sec / 86400)}d`
|
|
|
|
|
}
|
|
|
|
|
function formatUptime(sec: number): string {
|
|
|
|
|
if (sec < 60) return `${sec}s`
|
|
|
|
|
const days = Math.floor(sec / 86400)
|
|
|
|
|
const h = Math.floor((sec % 86400) / 3600)
|
|
|
|
|
const m = Math.floor((sec % 3600) / 60)
|
|
|
|
|
if (days > 0) return `${days}d ${h}h`
|
|
|
|
|
if (h > 0) return `${h}h ${m}m`
|
|
|
|
|
return `${m}m`
|
|
|
|
|
}
|
|
|
|
|
function relativeFromIso(iso: string): string {
|
|
|
|
|
const t = new Date(iso).getTime()
|
|
|
|
|
if (!t) return ''
|
|
|
|
|
return relativeTime(Math.floor(t / 1000))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Page ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function DashboardPage() {
|
|
|
|
|
const { t } = useTranslation()
|
|
|
|
|
|
|
|
|
|
const { data: health } = useQuery({
|
|
|
|
|
const health = useQuery({
|
|
|
|
|
queryKey: ['system', 'health'],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
const r = await apiClient.get('/system/health')
|
|
|
|
|
if (isEnvelope(r.data)) return r.data.data as { status: string; version: string }
|
|
|
|
|
return null
|
|
|
|
|
},
|
|
|
|
|
queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'),
|
|
|
|
|
refetchInterval: 30_000,
|
|
|
|
|
})
|
|
|
|
|
const services = useQuery({
|
|
|
|
|
queryKey: ['system', 'services'],
|
|
|
|
|
queryFn: () => fetchList<ServiceStatus>('/system/services', 'services'),
|
|
|
|
|
refetchInterval: 10_000,
|
|
|
|
|
})
|
|
|
|
|
const resources = useQuery({
|
|
|
|
|
queryKey: ['system', 'resources'],
|
|
|
|
|
queryFn: () => fetchOne<Resources>('/system/resources'),
|
|
|
|
|
refetchInterval: 10_000,
|
|
|
|
|
})
|
|
|
|
|
const haproxyBackends = useQuery({
|
|
|
|
|
queryKey: ['haproxy', 'stats'],
|
|
|
|
|
queryFn: () => fetchList<HAProxyBackend>('/haproxy/stats', 'backends'),
|
|
|
|
|
refetchInterval: 10_000,
|
|
|
|
|
})
|
|
|
|
|
const auditEntries = useQuery({
|
|
|
|
|
queryKey: ['audit', 'recent'],
|
|
|
|
|
queryFn: () => fetchList<AuditEntry>('/audit/recent?limit=10', 'entries'),
|
|
|
|
|
refetchInterval: 15_000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
|
|
|
|
|
const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList<Backend>('/backends', 'backends') })
|
|
|
|
|
const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList<Iface>('/network-interfaces', 'interfaces') })
|
|
|
|
|
const fwRules = useQuery({ queryKey: ['fw', 'rules'], queryFn: () => fetchList<FwRule>('/firewall/rules', 'rules') })
|
|
|
|
|
const fwNAT = useQuery({ queryKey: ['fw', 'nat'], queryFn: () => fetchList<FwNAT>('/firewall/nat-rules', 'nat_rules') })
|
|
|
|
|
const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList<FwZone>('/firewall/zones', 'zones') })
|
|
|
|
|
const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList<TLSCert>('/tls-certs', 'tls_certs') })
|
|
|
|
|
const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList<ClusterNode>('/cluster/nodes', 'nodes') })
|
|
|
|
|
const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList<WGIface>('/wireguard/interfaces', 'interfaces') })
|
|
|
|
|
const wgStatus = useQuery({
|
|
|
|
|
queryKey: ['wg', 'status'],
|
|
|
|
|
queryFn: () => fetchList<WGStatusRow>('/wireguard/status', 'status'),
|
|
|
|
|
refetchInterval: 10_000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Derived stats (same as before — KPI tiles)
|
|
|
|
|
const activeBackends = (backends.data ?? []).filter(b => b.active).length
|
|
|
|
|
const activeDomains = (domains.data ?? []).filter(d => d.active).length
|
|
|
|
|
const activeIfaces = (ifaces.data ?? []).filter(i => i.active).length
|
|
|
|
|
const activeFwRules = (fwRules.data ?? []).filter(r => r.enabled).length
|
|
|
|
|
const activeNAT = (fwNAT.data ?? []).filter(r => r.enabled).length
|
|
|
|
|
const wgServers = (wgIfaces.data ?? []).filter(i => i.mode === 'server' && i.active).length
|
|
|
|
|
const wgClients = (wgIfaces.data ?? []).filter(i => i.mode === 'client' && i.active).length
|
|
|
|
|
const wgConnected = (wgStatus.data ?? []).filter(s => s.last_handshake_unix > 0
|
|
|
|
|
&& Date.now() / 1000 - s.last_handshake_unix < 180).length
|
|
|
|
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const certsSoon = (tlsCerts.data ?? []).filter(c => {
|
|
|
|
|
if (!c.not_after) return false
|
|
|
|
|
const exp = new Date(c.not_after).getTime()
|
|
|
|
|
return exp - now < 30 * 86_400_000
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Typography.Title level={3}>{t('dashboard.title')}</Typography.Title>
|
|
|
|
|
<Typography.Paragraph type="secondary">{t('dashboard.welcomeHint')}</Typography.Paragraph>
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
<Card>
|
|
|
|
|
<Statistic title="API status" value={health?.status ?? '—'} />
|
|
|
|
|
<PageHeader
|
|
|
|
|
icon={<DashboardOutlined />}
|
|
|
|
|
title={t('dashboard.title')}
|
|
|
|
|
subtitle={t('dashboard.welcomeHint')}
|
|
|
|
|
extra={
|
|
|
|
|
<Space>
|
|
|
|
|
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
|
|
|
|
|
<StatusDot active={health.data?.status === 'ok'} />
|
|
|
|
|
</Space>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* ── KPI tiles (compact strip) ──────────────────── */}
|
|
|
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
|
|
|
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
|
|
|
|
|
<KPI icon={<DatabaseOutlined />} label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} />
|
|
|
|
|
<KPI icon={<ClusterOutlined />} label={t('dashboard.kpi.ifaces')} value={activeIfaces} total={(ifaces.data ?? []).length} />
|
|
|
|
|
<KPI icon={<FireOutlined />} label={t('dashboard.kpi.fwRules')} value={activeFwRules} total={(fwRules.data ?? []).length} />
|
|
|
|
|
<KPI icon={<BranchesOutlined />} label={t('dashboard.kpi.natRules')} value={activeNAT} total={(fwNAT.data ?? []).length} />
|
|
|
|
|
<KPI icon={<ThunderboltOutlined />} label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} />
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
{/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */}
|
|
|
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
|
|
|
<ResourcesCard r={resources.data} />
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
{/* ── Service-health-grid ─────────────────────────── */}
|
|
|
|
|
<Card size="small" title={<><DashboardOutlined /> {t('dashboard.servicesCard.title')}</>} className="mb-12">
|
|
|
|
|
<Row gutter={[8, 8]}>
|
|
|
|
|
{(services.data ?? []).map(s => (
|
|
|
|
|
<Col key={s.unit} xs={12} sm={8} md={6} lg={3}>
|
|
|
|
|
<Tooltip title={s.since ? `${s.state} since ${s.since}` : s.state}>
|
|
|
|
|
<div style={{ padding: '8px 10px', border: '1px solid #E2E8F0', borderRadius: 6, background: '#FFF' }}>
|
|
|
|
|
<Space size={4} direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
<Text style={{ fontSize: 12, fontWeight: 500 }}>{s.label}</Text>
|
|
|
|
|
<StatusDot active={s.active} activeLabel={s.state} inactiveLabel={s.state || 'unknown'} />
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Col>
|
|
|
|
|
))}
|
|
|
|
|
</Row>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
|
|
|
{/* ── Recent activity (audit log) ─────────────────── */}
|
|
|
|
|
<Col xs={24} lg={12}>
|
|
|
|
|
<Card size="small" title={<><ApiOutlined /> {t('dashboard.activityCard.title')}</>} className="h-100">
|
|
|
|
|
{(auditEntries.data ?? []).length === 0 ? (
|
|
|
|
|
<Text type="secondary">{t('dashboard.activityCard.empty')}</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={2}>
|
|
|
|
|
{(auditEntries.data ?? []).map(e => (
|
|
|
|
|
<div key={e.id} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
|
|
|
|
<Space size={6} wrap>
|
|
|
|
|
<Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag>
|
|
|
|
|
<Text style={{ fontSize: 12 }}><b>{e.actor}</b></Text>
|
|
|
|
|
{e.subject && <Text type="secondary" style={{ fontSize: 11 }}>{e.subject}</Text>}
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{relativeFromIso(e.created_at)}</Text>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</Space>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
<Card>
|
|
|
|
|
<Statistic title="Version" value={health?.version ?? '—'} />
|
|
|
|
|
|
|
|
|
|
{/* ── HAProxy backend live health ─────────────────── */}
|
|
|
|
|
<Col xs={24} lg={12}>
|
|
|
|
|
<Card size="small" title={<><DatabaseOutlined /> {t('dashboard.haproxyCard.title')}</>} className="h-100">
|
|
|
|
|
{(haproxyBackends.data ?? []).length === 0 ? (
|
|
|
|
|
<Text type="secondary">{t('dashboard.haproxyCard.empty')}</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={2}>
|
|
|
|
|
{(haproxyBackends.data ?? []).map((b, i) => (
|
|
|
|
|
<div key={i} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
|
|
|
|
<Space size={6} wrap>
|
|
|
|
|
<code style={{ fontSize: 11 }}>{b.backend}/{b.server}</code>
|
|
|
|
|
<Tag
|
|
|
|
|
color={b.status === 'UP' ? 'green' : b.status === 'no check' ? 'default' : 'red'}
|
|
|
|
|
style={{ margin: 0, fontSize: 10 }}
|
|
|
|
|
>{b.status}</Tag>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
|
|
|
{b.sessions} sess · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{formatUptime(b.last_change_sec)}</Text>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</Space>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
{/* ── WireGuard live ─────────────────────────────── */}
|
|
|
|
|
<Col xs={24} lg={12}>
|
|
|
|
|
<Card size="small" title={<><ThunderboltOutlined /> {t('dashboard.wgCard.title')}</>} className="h-100">
|
|
|
|
|
{(wgIfaces.data ?? []).length === 0 ? (
|
|
|
|
|
<Text type="secondary">{t('dashboard.wgCard.empty')}</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
|
|
|
|
{(wgIfaces.data ?? []).map(ifc => {
|
|
|
|
|
const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name)
|
|
|
|
|
return (
|
|
|
|
|
<div key={ifc.id} style={{ borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
|
|
|
|
|
<Space>
|
|
|
|
|
<code>{ifc.name}</code>
|
|
|
|
|
<Tag color={ifc.mode === 'server' ? 'blue' : 'purple'}>{ifc.mode}</Tag>
|
|
|
|
|
<StatusDot active={ifc.active} />
|
|
|
|
|
</Space>
|
|
|
|
|
{status.map(s => (
|
|
|
|
|
<div key={s.peer_public_key} style={{ fontSize: 12, color: '#64748B', marginTop: 2 }}>
|
|
|
|
|
{s.endpoint && <span>{s.endpoint} · </span>}
|
|
|
|
|
<span>{relativeTime(s.last_handshake_unix)}</span>
|
|
|
|
|
{' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</Space>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
{/* ── Cluster ─────────────────────────────────────── */}
|
|
|
|
|
<Col xs={24} lg={12}>
|
|
|
|
|
<Card size="small" title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>} className="h-100">
|
|
|
|
|
<Statistic title={t('dashboard.clusterCard.nodes')} value={(cluster.data ?? []).length} />
|
|
|
|
|
<Space direction="vertical" style={{ marginTop: 6, width: '100%' }} size={2}>
|
|
|
|
|
{(cluster.data ?? []).map(n => (
|
|
|
|
|
<div key={n.id} style={{ fontSize: 12, color: '#334155' }}>
|
|
|
|
|
<code>{n.fqdn}</code> <Tag color={n.role === 'primary' ? 'green' : 'default'}>{n.role}</Tag>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</Space>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
{/* ── Firewall ────────────────────────────────────── */}
|
|
|
|
|
<Col xs={24} md={12} xl={8}>
|
|
|
|
|
<Card size="small" title={<><FireOutlined /> {t('dashboard.firewallCard.title')}</>} className="h-100">
|
|
|
|
|
<Statistic title={t('dashboard.firewallCard.zones')} value={(fwZones.data ?? []).length} />
|
|
|
|
|
<Space wrap style={{ marginTop: 6 }}>
|
|
|
|
|
{(fwZones.data ?? []).map(z => (
|
|
|
|
|
<Tag key={z.id} color={z.builtin ? 'blue' : 'gold'}>{z.name.toUpperCase()}</Tag>
|
|
|
|
|
))}
|
|
|
|
|
</Space>
|
|
|
|
|
<div style={{ marginTop: 12, fontSize: 12, color: '#64748B' }}>
|
|
|
|
|
{t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
{/* ── SSL ─────────────────────────────────────────── */}
|
|
|
|
|
<Col xs={24} md={12} xl={8}>
|
|
|
|
|
<Card size="small" title={<><SafetyCertificateOutlined /> {t('dashboard.sslCard.title')}</>} className="h-100">
|
|
|
|
|
<Statistic title={t('dashboard.sslCard.total')} value={(tlsCerts.data ?? []).length} />
|
|
|
|
|
{certsSoon.length > 0 ? (
|
|
|
|
|
<Alert
|
|
|
|
|
style={{ marginTop: 8 }}
|
|
|
|
|
type="warning"
|
|
|
|
|
showIcon
|
|
|
|
|
message={t('dashboard.sslCard.expiringSoon', { count: certsSoon.length })}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
|
|
|
|
|
{t('dashboard.sslCard.allFresh')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
{/* ── Routing summary ─────────────────────────────── */}
|
|
|
|
|
<Col xs={24} md={12} xl={8}>
|
|
|
|
|
<Card size="small" title={<><GlobalOutlined /> {t('dashboard.routingCard.title')}</>} className="h-100">
|
|
|
|
|
<Row gutter={8}>
|
|
|
|
|
<Col span={12}><Statistic title={t('dashboard.routingCard.domains')} value={(domains.data ?? []).length} /></Col>
|
|
|
|
|
<Col span={12}><Statistic title={t('dashboard.routingCard.backends')} value={(backends.data ?? []).length} /></Col>
|
|
|
|
|
</Row>
|
|
|
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
|
|
|
|
|
{t('dashboard.routingCard.attached', {
|
|
|
|
|
count: (domains.data ?? []).filter(d => d.primary_backend_id).length,
|
|
|
|
|
total: (domains.data ?? []).length,
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Sub-components ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number }
|
|
|
|
|
|
|
|
|
|
function KPI({ icon, label, value, total }: KPIProps) {
|
|
|
|
|
return (
|
|
|
|
|
<Col xs={12} sm={8} md={8} lg={4}>
|
|
|
|
|
<Card size="small">
|
|
|
|
|
<Space direction="vertical" size={0}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
|
|
|
<span style={{ marginRight: 6, color: '#0EA5E9' }}>{icon}</span>{label}
|
|
|
|
|
</Text>
|
|
|
|
|
<div style={{ fontSize: 22, fontWeight: 600, color: '#0F172A' }}>
|
|
|
|
|
{value}{total !== undefined && total !== value && <span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {total}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</Space>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ResourcesCard({ r }: { r?: Resources | null }) {
|
|
|
|
|
const { t } = useTranslation()
|
|
|
|
|
if (!r) return null
|
|
|
|
|
const memUsedGB = ((r.mem_total_kb - r.mem_avail_kb) / 1024 / 1024).toFixed(1)
|
|
|
|
|
const memTotalGB = (r.mem_total_kb / 1024 / 1024).toFixed(1)
|
|
|
|
|
const ctPct = r.conntrack_max > 0 ? (r.conntrack_count * 100 / r.conntrack_max) : 0
|
|
|
|
|
return (
|
|
|
|
|
<Col span={24}>
|
|
|
|
|
<Card size="small">
|
|
|
|
|
<Row gutter={[16, 8]}>
|
|
|
|
|
<Col xs={12} sm={6} md={4}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.load')}</Text>
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 600 }}>
|
|
|
|
|
{r.load_avg_1.toFixed(2)} / {r.load_avg_5.toFixed(2)} / {r.load_avg_15.toFixed(2)}
|
|
|
|
|
</div>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col xs={12} sm={6} md={6}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
|
|
|
|
{t('dashboard.resCard.memory')} ({memUsedGB} / {memTotalGB} GB)
|
|
|
|
|
</Text>
|
|
|
|
|
<Progress percent={Math.round(r.mem_used_pct)} size="small" status={r.mem_used_pct > 90 ? 'exception' : 'normal'} />
|
|
|
|
|
</Col>
|
|
|
|
|
<Col xs={12} sm={6} md={6}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
|
|
|
|
{t('dashboard.resCard.disk')} ({r.disk_free_gb.toFixed(1)} GB {t('dashboard.resCard.free')} / {r.disk_total_gb.toFixed(1)} GB)
|
|
|
|
|
</Text>
|
|
|
|
|
<Progress percent={Math.round(r.disk_used_pct)} size="small" status={r.disk_used_pct > 90 ? 'exception' : 'normal'} />
|
|
|
|
|
</Col>
|
|
|
|
|
<Col xs={12} sm={6} md={4}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
|
|
|
|
|
{t('dashboard.resCard.conntrack')} ({r.conntrack_count} / {r.conntrack_max})
|
|
|
|
|
</Text>
|
|
|
|
|
<Progress percent={Math.round(ctPct)} size="small" status={ctPct > 80 ? 'exception' : 'normal'} />
|
|
|
|
|
</Col>
|
|
|
|
|
<Col xs={12} sm={6} md={4}>
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.uptime')}</Text>
|
|
|
|
|
<div style={{ fontSize: 18, fontWeight: 600 }}>{formatUptime(r.uptime_sec)}</div>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|