From c714ed640a282c81c2da853513fa956caba2ac7d Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 28 May 2026 00:04:59 +0800 Subject: [PATCH 1/6] fix(status): expose richer public server metrics --- apps/web/openapi.json | 56 ++++++++++++- apps/web/src/lib/api-schema.ts | 9 ++ apps/web/src/lib/api-types.ts | 18 ++++ crates/server/src/service/public_status.rs | 53 ++++++++---- .../server/tests/public_status_anonymous.rs | 82 +++++++++++++------ 5 files changed, 179 insertions(+), 39 deletions(-) diff --git a/apps/web/openapi.json b/apps/web/openapi.json index 86896a10..a73deb9b 100644 --- a/apps/web/openapi.json +++ b/apps/web/openapi.json @@ -11740,13 +11740,22 @@ "cpu", "mem_used", "mem_total", + "swap_used", + "swap_total", "disk_used", "disk_total", + "disk_read_bytes_per_sec", + "disk_write_bytes_per_sec", "net_in_speed", "net_out_speed", + "net_in_transfer", + "net_out_transfer", "load_1", "load_5", "load_15", + "tcp_conn", + "udp_conn", + "process_count", "uptime" ], "properties": { @@ -11754,6 +11763,11 @@ "type": "number", "format": "double" }, + "disk_read_bytes_per_sec": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, "disk_total": { "type": "integer", "format": "int64", @@ -11764,6 +11778,11 @@ "format": "int64", "minimum": 0 }, + "disk_write_bytes_per_sec": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, "load_1": { "type": "number", "format": "double" @@ -11791,11 +11810,46 @@ "format": "int64", "minimum": 0 }, + "net_in_transfer": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, "net_out_speed": { "type": "integer", "format": "int64", "minimum": 0 }, + "net_out_transfer": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "process_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "swap_total": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "swap_used": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "tcp_conn": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "udp_conn": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, "uptime": { "type": "integer", "format": "int64", @@ -15800,4 +15854,4 @@ "description": "ASN MMDB database management for traceroute enrichment (DB-IP Lite ASN)" } ] -} \ No newline at end of file +} diff --git a/apps/web/src/lib/api-schema.ts b/apps/web/src/lib/api-schema.ts index 7bd08966..c098216d 100644 --- a/apps/web/src/lib/api-schema.ts +++ b/apps/web/src/lib/api-schema.ts @@ -180,15 +180,24 @@ export interface PublicStatusConfig { export interface PublicMetricsSummary { cpu: number + disk_read_bytes_per_sec: number disk_total: number disk_used: number + disk_write_bytes_per_sec: number load_1: number load_5: number load_15: number mem_total: number mem_used: number net_in_speed: number + net_in_transfer: number net_out_speed: number + net_out_transfer: number + process_count: number + swap_total: number + swap_used: number + tcp_conn: number + udp_conn: number uptime: number } diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 9fa75818..5e0e85a1 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -3269,9 +3269,13 @@ export interface components { /** Format: double */ cpu: number; /** Format: int64 */ + disk_read_bytes_per_sec: number; + /** Format: int64 */ disk_total: number; /** Format: int64 */ disk_used: number; + /** Format: int64 */ + disk_write_bytes_per_sec: number; /** Format: double */ load_1: number; /** Format: double */ @@ -3285,8 +3289,22 @@ export interface components { /** Format: int64 */ net_in_speed: number; /** Format: int64 */ + net_in_transfer: number; + /** Format: int64 */ net_out_speed: number; /** Format: int64 */ + net_out_transfer: number; + /** Format: int32 */ + process_count: number; + /** Format: int64 */ + swap_total: number; + /** Format: int64 */ + swap_used: number; + /** Format: int32 */ + tcp_conn: number; + /** Format: int32 */ + udp_conn: number; + /** Format: int64 */ uptime: number; }; PublicNetworkOverview: { diff --git a/crates/server/src/service/public_status.rs b/crates/server/src/service/public_status.rs index 84a9a225..93847154 100644 --- a/crates/server/src/service/public_status.rs +++ b/crates/server/src/service/public_status.rs @@ -19,6 +19,7 @@ use crate::entity::{ incident, incident_update, maintenance, server, server_group, status_page, unlock_service, }; use crate::error::AppError; +use crate::service::agent_manager::aggregate_disk_io; use crate::service::ip_quality::IpQualityService; use crate::service::record::{QueryHistoryResult, RecordService}; use crate::service::uptime::{UptimeDailyEntry, UptimeService}; @@ -48,13 +49,22 @@ pub struct PublicMetricsSummary { pub cpu: f64, pub mem_used: u64, pub mem_total: u64, + pub swap_used: u64, + pub swap_total: u64, pub disk_used: u64, pub disk_total: u64, + pub disk_read_bytes_per_sec: u64, + pub disk_write_bytes_per_sec: u64, pub net_in_speed: u64, pub net_out_speed: u64, + pub net_in_transfer: u64, + pub net_out_transfer: u64, pub load_1: f64, pub load_5: f64, pub load_15: f64, + pub tcp_conn: u32, + pub udp_conn: u32, + pub process_count: u32, pub uptime: u64, } @@ -360,19 +370,30 @@ fn build_summary( fn report_to_metrics( report: &serverbee_common::types::SystemReport, mem_total: i64, + swap_total: i64, disk_total: i64, ) -> PublicMetricsSummary { + let (disk_read_bytes_per_sec, disk_write_bytes_per_sec) = aggregate_disk_io(report); PublicMetricsSummary { cpu: report.cpu, mem_used: report.mem_used.max(0) as u64, mem_total: mem_total.max(0) as u64, + swap_used: report.swap_used.max(0) as u64, + swap_total: swap_total.max(0) as u64, disk_used: report.disk_used.max(0) as u64, disk_total: disk_total.max(0) as u64, + disk_read_bytes_per_sec, + disk_write_bytes_per_sec, net_in_speed: report.net_in_speed.max(0) as u64, net_out_speed: report.net_out_speed.max(0) as u64, + net_in_transfer: report.net_in_transfer.max(0) as u64, + net_out_transfer: report.net_out_transfer.max(0) as u64, load_1: report.load1, load_5: report.load5, load_15: report.load15, + tcp_conn: report.tcp_conn.max(0) as u32, + udp_conn: report.udp_conn.max(0) as u32, + process_count: report.process_count.max(0) as u32, uptime: report.uptime, } } @@ -455,13 +476,14 @@ pub async fn list_servers( .as_deref() .and_then(|g| group_lookup.get(g).cloned()); - let metrics = if online { - agent_manager.get_latest_report(&srv.id).map(|r| { - report_to_metrics(&r, srv.mem_total.unwrap_or(0), srv.disk_total.unwrap_or(0)) - }) - } else { - None - }; + let metrics = agent_manager.get_latest_report(&srv.id).map(|r| { + report_to_metrics( + &r, + srv.mem_total.unwrap_or(0), + srv.swap_total.unwrap_or(0), + srv.disk_total.unwrap_or(0), + ) + }); // 90-day uptime band (canonical for the public surface). let uptime_daily = UptimeService::get_daily_filled(db, &srv.id, 90) @@ -514,15 +536,16 @@ pub async fn get_server_detail( None }; - let latest = if online { - agent_manager.get_latest_report(&srv.id) - } else { - None - }; + let latest = agent_manager.get_latest_report(&srv.id); - let metrics = latest - .as_ref() - .map(|r| report_to_metrics(r, srv.mem_total.unwrap_or(0), srv.disk_total.unwrap_or(0))); + let metrics = latest.as_ref().map(|r| { + report_to_metrics( + r, + srv.mem_total.unwrap_or(0), + srv.swap_total.unwrap_or(0), + srv.disk_total.unwrap_or(0), + ) + }); let uptime_daily = UptimeService::get_daily_filled(db, &srv.id, 90) .await diff --git a/crates/server/tests/public_status_anonymous.rs b/crates/server/tests/public_status_anonymous.rs index 873358ab..b0293cc6 100644 --- a/crates/server/tests/public_status_anonymous.rs +++ b/crates/server/tests/public_status_anonymous.rs @@ -248,7 +248,18 @@ async fn public_status_endpoints_return_200_when_fully_enabled() { SystemReport { cpu: 12.5, mem_used: 8_000_000_000, + swap_used: 2_000_000_000, disk_used: 30_000_000_000, + net_in_transfer: 123_000_000, + net_out_transfer: 456_000_000, + disk_io: Some(vec![serverbee_common::types::DiskIo { + name: "vda".to_string(), + read_bytes_per_sec: 1_000, + write_bytes_per_sec: 2_000, + }]), + tcp_conn: 42, + udp_conn: 7, + process_count: 88, ..Default::default() }, ); @@ -260,36 +271,39 @@ async fn public_status_endpoints_return_200_when_fully_enabled() { let to = now.to_rfc3339(); let endpoints: Vec<(&str, String)> = vec![ - ("/api/status/config", format!("{}/api/status/config", base_url)), + ( + "/api/status/config", + format!("{}/api/status/config", base_url), + ), ("/api/status", format!("{}/api/status", base_url)), ( "/api/status/servers/{id}", format!("{}/api/status/servers/{}", base_url, server_id), ), - ( - "/api/status/servers/{id}/metrics", - { - // reqwest will URL-encode the `:` etc. in the ISO-8601 - // strings via the `query()` builder; constructing a raw URL - // string would break with `400 Bad Request` on the chrono - // serde format. We build the encoded URL via `Url::parse_with_params`. - let mut url = reqwest::Url::parse(&format!( - "{}/api/status/servers/{}/metrics", - base_url, server_id - )) - .unwrap(); - url.query_pairs_mut() - .append_pair("from", &from) - .append_pair("to", &to) - .append_pair("interval", "raw"); - url.to_string() - }, - ), + ("/api/status/servers/{id}/metrics", { + // reqwest will URL-encode the `:` etc. in the ISO-8601 + // strings via the `query()` builder; constructing a raw URL + // string would break with `400 Bad Request` on the chrono + // serde format. We build the encoded URL via `Url::parse_with_params`. + let mut url = reqwest::Url::parse(&format!( + "{}/api/status/servers/{}/metrics", + base_url, server_id + )) + .unwrap(); + url.query_pairs_mut() + .append_pair("from", &from) + .append_pair("to", &to) + .append_pair("interval", "raw"); + url.to_string() + }), ( "/api/status/servers/{id}/uptime-daily", format!("{}/api/status/servers/{}/uptime-daily", base_url, server_id), ), - ("/api/status/network", format!("{}/api/status/network", base_url)), + ( + "/api/status/network", + format!("{}/api/status/network", base_url), + ), ( "/api/status/network/{id}", format!("{}/api/status/network/{}", base_url, server_id), @@ -338,6 +352,28 @@ async fn public_status_endpoints_return_200_when_fully_enabled() { assert!(body["data"]["active"].is_array()); assert!(body["data"]["recent"].is_array()); - // Suppress unused: json! import path is used elsewhere in the suite. - let _ = json!({}); + // Summary cards use the same runtime surface as the authenticated + // `/servers` card where it is safe for public status. A cached report + // should be rendered even when the agent is not currently connected, under + // the same offline overlay the authenticated page uses. + let resp = client + .get(format!("{}/api/status", base_url)) + .send() + .await + .unwrap(); + let body: serde_json::Value = resp.json().await.unwrap(); + let metrics = &body["data"][0]["metrics"]; + assert!( + metrics.is_object(), + "summary metrics should be populated: {body:?}" + ); + assert_eq!(metrics["swap_used"], json!(2_000_000_000u64)); + assert_eq!(metrics["swap_total"], json!(4_000_000_000u64)); + assert_eq!(metrics["net_in_transfer"], json!(123_000_000u64)); + assert_eq!(metrics["net_out_transfer"], json!(456_000_000u64)); + assert_eq!(metrics["disk_read_bytes_per_sec"], json!(1_000u64)); + assert_eq!(metrics["disk_write_bytes_per_sec"], json!(2_000u64)); + assert_eq!(metrics["tcp_conn"], json!(42u32)); + assert_eq!(metrics["udp_conn"], json!(7u32)); + assert_eq!(metrics["process_count"], json!(88u32)); } From a014961fd57d6232a8c27f7c3fe4fa9de1b1d9a4 Mon Sep 17 00:00:00 2001 From: ZingerLittleBee <6970999@gmail.com> Date: Thu, 28 May 2026 00:05:20 +0800 Subject: [PATCH 2/6] fix(status): match server grid and list layouts --- .../src/components/status/layout-toggle.tsx | 41 +- .../components/status/server-summary-card.tsx | 237 ++++++++++-- .../components/status/server-summary-row.tsx | 358 +++++++++++++++--- .../status/server-summary-view.test.tsx | 106 ++++++ apps/web/src/lib/traffic.ts | 4 +- apps/web/src/routes/status.index.tsx | 27 +- 6 files changed, 654 insertions(+), 119 deletions(-) create mode 100644 apps/web/src/components/status/server-summary-view.test.tsx diff --git a/apps/web/src/components/status/layout-toggle.tsx b/apps/web/src/components/status/layout-toggle.tsx index f3f59b4a..0bbfdd30 100644 --- a/apps/web/src/components/status/layout-toggle.tsx +++ b/apps/web/src/components/status/layout-toggle.tsx @@ -1,7 +1,6 @@ -import { LayoutGrid, List } from 'lucide-react' +import { LayoutGrid, Table2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' interface Props { onChange: (next: 'list' | 'grid') => void @@ -11,25 +10,23 @@ interface Props { export function LayoutToggle({ value, onChange }: Props) { const { t } = useTranslation('status') return ( -
- - -
+ + ) } diff --git a/apps/web/src/components/status/server-summary-card.tsx b/apps/web/src/components/status/server-summary-card.tsx index 2d3c7eaa..080eff4d 100644 --- a/apps/web/src/components/status/server-summary-card.tsx +++ b/apps/web/src/components/status/server-summary-card.tsx @@ -2,21 +2,70 @@ import { Link } from '@tanstack/react-router' import { Wrench } from 'lucide-react' import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { CompactMetric } from '@/components/server/compact-metric' import { StatusBadge } from '@/components/server/status-badge' import { Badge } from '@/components/ui/badge' +import { RingChart } from '@/components/ui/ring-chart' import type { PublicServerSummary } from '@/lib/api-schema' -import { cn, countryCodeToFlag, formatSpeed, formatUptime } from '@/lib/utils' +import { computeTrafficQuota } from '@/lib/traffic' +import { cn, countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils' -function ProgressBar({ value, label, color }: { color: string; label: string; value: number }) { - const pct = Math.min(100, Math.max(0, value)) +function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + return Math.min(100, Math.max(0, value)) +} + +function getRingColor(pct: number, brandColor: string): string { + if (pct > 90) { + return '#ef4444' + } + if (pct > 70) { + return '#f59e0b' + } + return brandColor +} + +function metricPercent(used: number, total: number): number { + return total > 0 ? (used / total) * 100 : 0 +} + +function finiteMetric(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function renderSpeedValue(bytesPerSec: number): ReactNode { + if (bytesPerSec <= 0) { + return '0' + } + const formatted = formatSpeed(bytesPerSec) + const lastSpace = formatted.lastIndexOf(' ') + if (lastSpace < 0) { + return formatted + } return ( -
-
- {label} - {pct.toFixed(1)}% -
-
-
+ <> + {formatted.slice(0, lastSpace)} + {formatted.slice(lastSpace + 1)} + + ) +} + +interface RingMetricProps { + color: string + label: string + subText: ReactNode + value: number +} + +function RingMetric({ color, label, subText, value }: RingMetricProps) { + return ( +
+ +
+ {label} + {subText}
) @@ -28,24 +77,47 @@ interface Props { } export function ServerSummaryCard({ server, clickable }: Props) { - const { t } = useTranslation('status') + const { t } = useTranslation(['status', 'servers']) const m = server.metrics - const memPct = m && m.mem_total > 0 ? (m.mem_used / m.mem_total) * 100 : 0 - const diskPct = m && m.disk_total > 0 ? (m.disk_used / m.disk_total) * 100 : 0 + const memPct = m ? metricPercent(m.mem_used, m.mem_total) : 0 + const diskPct = m ? metricPercent(m.disk_used, m.disk_total) : 0 + const swapPct = m ? metricPercent(m.swap_used, m.swap_total) : 0 + const processCount = m ? finiteMetric(m.process_count) : 0 + const tcpConn = m ? finiteMetric(m.tcp_conn) : 0 + const udpConn = m ? finiteMetric(m.udp_conn) : 0 + const traffic = m + ? computeTrafficQuota({ + entry: undefined, + netInTransfer: m.net_in_transfer, + netOutTransfer: m.net_out_transfer + }) + : { limit: 0, pct: 0, used: 0 } const flag = countryCodeToFlag(server.country_code) + const status = server.online ? 'online' : 'offline' const body: ReactNode = (
-
+ {!server.online && ( +