diff --git a/Cargo.lock b/Cargo.lock
index 86030cb02..e36af316a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -155,7 +155,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
- "cpufeatures 0.2.17",
+ "cpufeatures",
"password-hash",
]
@@ -610,17 +610,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
-[[package]]
-name = "chacha20"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.3.0",
- "rand_core 0.10.1",
-]
-
[[package]]
name = "chrono"
version = "0.4.44"
@@ -700,16 +689,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
-[[package]]
-name = "combine"
-version = "4.6.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
-dependencies = [
- "bytes",
- "memchr",
-]
-
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -785,15 +764,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "cpufeatures"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "crc"
version = "3.4.0"
@@ -1459,7 +1429,6 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
- "rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -1568,70 +1537,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
-name = "hickory-net"
-version = "0.26.1"
+name = "hickory-proto"
+version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
+checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
+ "enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
- "hickory-proto",
- "idna 1.1.0",
- "ipnet",
- "jni",
- "rand 0.10.1",
- "thiserror 2.0.18",
- "tinyvec",
- "tokio",
- "tracing",
- "url",
-]
-
-[[package]]
-name = "hickory-proto"
-version = "0.26.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
-dependencies = [
- "data-encoding",
"idna 1.1.0",
"ipnet",
- "jni",
"once_cell",
- "prefix-trie",
- "rand 0.10.1",
+ "rand 0.9.4",
"ring",
"thiserror 2.0.18",
"tinyvec",
+ "tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
-version = "0.26.1"
+version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
+checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
dependencies = [
"cfg-if",
"futures-util",
- "hickory-net",
"hickory-proto",
"ipconfig",
- "ipnet",
- "jni",
"moka",
- "ndk-context",
"once_cell",
"parking_lot",
- "rand 0.10.1",
+ "rand 0.9.4",
"resolv-conf",
"smallvec",
- "system-configuration 0.7.0",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2173,55 +2118,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
-[[package]]
-name = "jni"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
-dependencies = [
- "cfg-if",
- "combine",
- "jni-macros",
- "jni-sys",
- "log",
- "simd_cesu8",
- "thiserror 2.0.18",
- "walkdir",
- "windows-link",
-]
-
-[[package]]
-name = "jni-macros"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
-dependencies = [
- "proc-macro2",
- "quote",
- "rustc_version",
- "simd_cesu8",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "jni-sys"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
-dependencies = [
- "jni-sys-macros",
-]
-
-[[package]]
-name = "jni-sys-macros"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
-dependencies = [
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2496,12 +2392,6 @@ dependencies = [
"version_check",
]
-[[package]]
-name = "ndk-context"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
-
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -2970,17 +2860,6 @@ dependencies = [
"zerocopy",
]
-[[package]]
-name = "prefix-trie"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
-dependencies = [
- "either",
- "ipnet",
- "num-traits",
-]
-
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3216,17 +3095,6 @@ dependencies = [
"rand_core 0.9.5",
]
-[[package]]
-name = "rand"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
-dependencies = [
- "chacha20",
- "getrandom 0.4.2",
- "rand_core 0.10.1",
-]
-
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -3265,12 +3133,6 @@ dependencies = [
"getrandom 0.3.4",
]
-[[package]]
-name = "rand_core"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
-
[[package]]
name = "rayon"
version = "1.11.0"
@@ -3396,7 +3258,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
- "system-configuration 0.5.1",
+ "system-configuration",
"tokio",
"tokio-rustls 0.24.1",
"tower-service",
@@ -4229,7 +4091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures 0.2.17",
+ "cpufeatures",
"digest",
]
@@ -4240,7 +4102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures 0.2.17",
+ "cpufeatures",
"digest",
]
@@ -4301,16 +4163,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
-[[package]]
-name = "simd_cesu8"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
-dependencies = [
- "rustc_version",
- "simdutf8",
-]
-
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -4697,18 +4549,7 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
- "system-configuration-sys 0.5.0",
-]
-
-[[package]]
-name = "system-configuration"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
-dependencies = [
- "bitflags 2.11.0",
- "core-foundation",
- "system-configuration-sys 0.6.0",
+ "system-configuration-sys",
]
[[package]]
@@ -4721,16 +4562,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "system-configuration-sys"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
[[package]]
name = "tagptr"
version = "0.2.0"
diff --git a/ENV.md b/ENV.md
index 4d48f6627..2d3dc8b7c 100644
--- a/ENV.md
+++ b/ENV.md
@@ -40,6 +40,12 @@ These variables are for local repo tooling and development workflows. They are n
| `SERVERBEE_LOG__LEVEL` | `log.level` | string | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` |
| `SERVERBEE_LOG__FILE` | `log.file` | string | `""` | Log file path. Empty means stdout only |
+### Development Only
+
+| Environment Variable | TOML Key | Type | Default | Description |
+|---------------------|----------|------|---------|-------------|
+| `SERVERBEE_DEV__DEMO_DATA` | `dev.demo_data` | bool | `false` | Reset and seed the local synthetic demo dataset. Only allowed when `database.path = "dev-demo.db"`; creates `admin` / `admin123` and in-memory demo agents for local development |
+
### OAuth (Optional)
| Environment Variable | TOML Key | Type | Default | Description |
diff --git a/Makefile b/Makefile
index 66948ed3e..7d172096c 100644
--- a/Makefile
+++ b/Makefile
@@ -56,6 +56,8 @@ COMMAND_TARGETS := \
server-dev-prod \
agent-dev \
dev-full \
+ dev-demo \
+ server-dev-demo \
docker-build \
docker-up \
docker-down \
diff --git a/apps/docs/content/docs/cn/configuration.mdx b/apps/docs/content/docs/cn/configuration.mdx
index 65f0a6dc4..da4e89c4a 100644
--- a/apps/docs/content/docs/cn/configuration.mdx
+++ b/apps/docs/content/docs/cn/configuration.mdx
@@ -61,6 +61,12 @@ ServerBee 使用 [figment](https://github.com/SergioBenitez/Figment) 库加载
| `SERVERBEE_LOG__LEVEL` | `info` | 日志级别:`trace`/`debug`/`info`/`warn`/`error` |
| `SERVERBEE_LOG__FILE` | `""` | 日志文件路径,留空输出到 stdout |
+#### 仅本地开发
+
+| 环境变量 | 默认值 | 说明 |
+|----------|--------|------|
+| `SERVERBEE_DEV__DEMO_DATA` | `false` | 重置并写入本地合成 demo 数据集。仅允许与 `SERVERBEE_DATABASE__PATH=dev-demo.db` 一起使用;会创建 `admin` / `admin123` 并启动内存中的 demo agents |
+
#### OAuth(按需配置)
| 环境变量 | 默认值 | 说明 |
@@ -266,6 +272,12 @@ session_ttl = 86400
# 默认: 0
max_servers = 0
+# --- 本地开发 ---
+[dev]
+# 重置并写入合成 demo 数据。仅允许 database.path = "dev-demo.db"。
+# 默认: false
+demo_data = false
+
# --- 速率限制 ---
[rate_limit]
# 登录接口速率限制:每 IP 每分钟最大请求数
diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx
index 1f1fc95ae..514ca0450 100644
--- a/apps/docs/content/docs/en/configuration.mdx
+++ b/apps/docs/content/docs/en/configuration.mdx
@@ -55,6 +55,12 @@ There is no admin username/password environment variable. On first start (when n
| `SERVERBEE_LOG__LEVEL` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` |
| `SERVERBEE_LOG__FILE` | `""` | Log file path. Empty means stdout only |
+#### Development Only
+
+| Environment Variable | Default | Description |
+|---------------------|---------|-------------|
+| `SERVERBEE_DEV__DEMO_DATA` | `false` | Reset and seed the local synthetic demo dataset. Only allowed with `SERVERBEE_DATABASE__PATH=dev-demo.db`; creates `admin` / `admin123` and in-memory demo agents for local development |
+
#### OAuth (Optional)
| Environment Variable | Default | Description |
@@ -239,6 +245,12 @@ Tunes the agent-side security event detectors (SSH login / brute force, port sca
| `max_servers` | u32 | `0` | Maximum servers allowed via enrollment (0 = no limit). Best-effort soft cap |
| `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP |
+### `[dev]` -- Local Development
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| `demo_data` | bool | `false` | Reset and seed the synthetic local development dataset. Guarded to `database.path = "dev-demo.db"` so it cannot accidentally run against normal local or production-copy databases |
+
### `[retention]` -- Data Retention
| Key | Type | Default | Description |
diff --git a/apps/web/openapi.json b/apps/web/openapi.json
index 86896a101..a73deb9ba 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/components/status/layout-toggle.tsx b/apps/web/src/components/status/layout-toggle.tsx
index f3f59b4ad..0bbfdd303 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 2d3c7eaa2..080eff4da 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 && (
+
+ )}
+
- {flag && {flag}}
-
{server.name}
- {server.region && {server.region}}
+ {flag && (
+
+ {flag}
+
+ )}
+ {server.name}
+ {server.region && {server.region}}
{server.in_maintenance && (
@@ -54,31 +126,132 @@ export function ServerSummaryCard({ server, clickable }: Props) {
{t('maintenance')}
)}
-
+
- {server.public_remark &&
{server.public_remark}
}
+ {server.public_remark &&
{server.public_remark}
}
{m ? (
<>
-
-
-
-
+
+
+
+ {formatBytes(m.mem_used)}
+ /
+ {formatBytes(m.mem_total)}
+ >
+ }
+ value={memPct}
+ />
+
+ {formatBytes(m.disk_used)}
+ /
+ {formatBytes(m.disk_total)}
+ >
+ }
+ value={diskPct}
+ />
+
+ {formatBytes(traffic.used)}
+ /
+ {formatBytes(traffic.limit)}
+ >
+ }
+ value={clampPercent(traffic.pct)}
+ />
+
+
+
+
+
+
+
+ R
+
+ {t('card_disk_read', { ns: 'servers' })}
+
+ }
+ value={renderSpeedValue(m.disk_read_bytes_per_sec)}
+ />
+
+
+ W
+
+ {t('card_disk_write', { ns: 'servers' })}
+
+ }
+ value={renderSpeedValue(m.disk_write_bytes_per_sec)}
+ />
-
-
-
{formatSpeed(m.net_in_speed)}
-
{formatSpeed(m.net_out_speed)}
+
+
+
+ {t('uptime')}
+ {formatUptime(m.uptime)}
+
+
+ {t('card_swap', { ns: 'servers' })}
+ {`${swapPct.toFixed(0)}%`}
+
+
+ {t('card_load_trend', { ns: 'servers' })}
+
+ {m.load_5.toFixed(2)}
+ ·
+ {m.load_15.toFixed(2)}
+
+
+
+ {t('card_proc_conn_label', { ns: 'servers' })}
+
+ {`${processCount} / ${tcpConn} / ${udpConn}`}
+
-
{formatUptime(m.uptime)}
>
) : (
-
- {server.os &&
{server.os}}
- {!server.online &&
{t('offline')}}
+
+ {server.os && {server.os}}
+ {server.online ? t('uptime_no_data') : t('offline')}
)}
diff --git a/apps/web/src/components/status/server-summary-row.tsx b/apps/web/src/components/status/server-summary-row.tsx
index c09ecffe9..637615ac1 100644
--- a/apps/web/src/components/status/server-summary-row.tsx
+++ b/apps/web/src/components/status/server-summary-row.tsx
@@ -1,11 +1,14 @@
import { Link } from '@tanstack/react-router'
-import { Wrench } from 'lucide-react'
+import { ArrowDown, ArrowUp, Cpu, HardDrive, MemoryStick, Network, Sigma, Wrench } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
+import { StatusDot } from '@/components/server/status-dot'
import { Badge } from '@/components/ui/badge'
+import { TableCell, TableRow } from '@/components/ui/table'
import { UptimeTimeline } from '@/components/uptime/uptime-timeline'
import type { PublicServerSummary, PublicStatusConfig } from '@/lib/api-schema'
-import { cn } from '@/lib/utils'
+import { computeTrafficQuota } from '@/lib/traffic'
+import { cn, countryCodeToFlag, formatBytes, formatSpeed } from '@/lib/utils'
import { computeAggregateUptime } from '@/lib/widget-helpers'
interface Props {
@@ -14,72 +17,309 @@ interface Props {
thresholds: Pick
}
-function ServerStatusDot({
- inMaintenance,
- online,
- t
-}: {
- inMaintenance: boolean
- online: boolean
- t: (key: string) => string
-}) {
- if (inMaintenance) {
- return
+function clampPercent(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0
}
- if (online) {
- return
+ return Math.min(100, Math.max(0, value))
+}
+
+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 splitValueUnit(formatted: string): { unit: string | null; value: string } {
+ const lastSpace = formatted.lastIndexOf(' ')
+ if (lastSpace < 0) {
+ return { unit: null, value: formatted }
}
- return
+ return { unit: formatted.slice(lastSpace + 1), value: formatted.slice(0, lastSpace) }
}
-export function ServerSummaryRow({ server, clickable, thresholds }: Props) {
- const { t } = useTranslation('status')
- const uptimePct = computeAggregateUptime(server.uptime_daily)
+function valueClassName(value: string): string {
+ return value === '0' ? 'text-xs' : 'font-semibold text-foreground text-xs'
+}
- const inner: ReactNode = (
-
-
-
{server.name}
- {server.group_name && (
-
- {server.group_name}
-
- )}
- {server.region && (
-
{server.region}
- )}
- {server.in_maintenance && (
-
-
- {t('maintenance')}
-
- )}
-
-
+function renderBytesValue(bytes: number): ReactNode {
+ const { value, unit } = splitValueUnit(formatBytes(bytes))
+ if (unit == null) {
+ return
{value}
+ }
+ return (
+ <>
+
{value} {unit}
+ >
+ )
+}
+
+function renderSpeedValue(bytesPerSec: number): ReactNode {
+ if (bytesPerSec <= 0) {
+ return
0
+ }
+ const { value, unit } = splitValueUnit(formatSpeed(bytesPerSec))
+ if (unit == null) {
+ return
{value}
+ }
+ return (
+ <>
+
{value} {unit}
+ >
+ )
+}
+
+function getBarColor(pct: number): string {
+ if (pct > 90) {
+ return 'bg-red-500'
+ }
+ if (pct > 70) {
+ return 'bg-amber-500'
+ }
+ return 'bg-emerald-500'
+}
+
+function getBarTextColor(pct: number): string {
+ if (pct > 90) {
+ return 'text-red-600 dark:text-red-400'
+ }
+ if (pct > 70) {
+ return 'text-amber-600 dark:text-amber-400'
+ }
+ return 'text-foreground'
+}
+
+function PositionIndicator({ pct }: { pct: number }) {
+ const clamped = clampPercent(pct)
+ return (
+
+ )
+}
+
+function EmptyMetric() {
+ return
-
+}
+
+function ResourceMetric({ icon, pct, value }: { icon: ReactNode; pct: number; value: ReactNode }) {
+ const roundedPct = Math.round(clampPercent(pct))
+ return (
+
+
+ {icon}
+ {value}
+ {roundedPct}%
+
+
-
- {uptimePct !== null ? `${uptimePct.toFixed(1)}%` : '—'}
+
+ )
+}
+
+function DiskMetric({ server }: { server: PublicServerSummary }) {
+ const metrics = server.metrics
+ if (!metrics) {
+ return
+ }
+
+ return (
+
+
+
+ {renderBytesValue(metrics.disk_used)}
+
+
+
+ {renderBytesValue(metrics.disk_total)}
+
+
+
+ R
+
+ {renderSpeedValue(metrics.disk_read_bytes_per_sec)}
+
+
+
+ W
+
+ {renderSpeedValue(metrics.disk_write_bytes_per_sec)}
)
+}
- if (clickable) {
- return (
-
- {inner}
-
- )
+function NetworkMetric({ server }: { server: PublicServerSummary }) {
+ const { t } = useTranslation('status')
+ const metrics = server.metrics
+ if (!metrics) {
+ return
}
- return inner
+ const traffic = computeTrafficQuota({
+ entry: undefined,
+ netInTransfer: metrics.net_in_transfer,
+ netOutTransfer: metrics.net_out_transfer
+ })
+
+ return (
+
+
+
+ {renderBytesValue(traffic.used)}
+
+
+
+ {renderBytesValue(traffic.limit)}
+
+
+
+
+
+ {renderSpeedValue(metrics.net_in_speed)}
+
+
+
+
+
+ {renderSpeedValue(metrics.net_out_speed)}
+
+
+ )
+}
+
+function DetailMetric({
+ server,
+ thresholds,
+ uptimePct
+}: {
+ server: PublicServerSummary
+ thresholds: Pick
+ uptimePct: number | null
+}) {
+ const { t } = useTranslation(['status', 'servers'])
+ const metrics = server.metrics
+ const processCount = metrics ? finiteMetric(metrics.process_count) : 0
+ const tcpConn = metrics ? finiteMetric(metrics.tcp_conn) : 0
+ const udpConn = metrics ? finiteMetric(metrics.udp_conn) : 0
+
+ return (
+
+
+
+
+ {uptimePct !== null ? `${uptimePct.toFixed(1)}%` : t('uptime_no_data', { ns: 'status' })}
+
+ {metrics && (
+
+ {t('card_proc_conn_label', { ns: 'servers' })} {processCount} / {tcpConn} / {udpConn}
+
+ )}
+
+
+ )
+}
+
+function ServerName({ server, clickable }: { clickable: boolean; server: PublicServerSummary }) {
+ const { t } = useTranslation('status')
+ const flag = countryCodeToFlag(server.country_code)
+ const title = (
+
+ {flag && (
+
+ {flag}
+
+ )}
+ {server.name}
+
+ )
+
+ return (
+
+
+
+
+ {clickable ? (
+
+ {title}
+
+ ) : (
+ title
+ )}
+ {server.in_maintenance && (
+
+
+ {t('maintenance')}
+
+ )}
+
+
+ {server.group_name && (
+
+ {server.group_name}
+
+ )}
+ {server.region && {server.region}}
+ {server.os && {server.os}}
+
+
+
+ )
+}
+
+export function ServerSummaryRow({ server, clickable, thresholds }: Props) {
+ const metrics = server.metrics
+ const uptimePct = computeAggregateUptime(server.uptime_daily) ?? server.uptime_percent
+ const memoryPct = metrics ? metricPercent(metrics.mem_used, metrics.mem_total) : 0
+
+ return (
+
+
+
+
+
+ {metrics ? (
+ }
+ pct={metrics.cpu}
+ value={`load ${metrics.load_1.toFixed(2)}`}
+ />
+ ) : (
+
+ )}
+
+
+ {metrics ? (
+ }
+ pct={memoryPct}
+ value={
+ <>
+ {renderBytesValue(metrics.mem_used)} / {renderBytesValue(metrics.mem_total)}
+ >
+ }
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
}
diff --git a/apps/web/src/components/status/server-summary-view.test.tsx b/apps/web/src/components/status/server-summary-view.test.tsx
new file mode 100644
index 000000000..d2d63709e
--- /dev/null
+++ b/apps/web/src/components/status/server-summary-view.test.tsx
@@ -0,0 +1,106 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import type { PublicServerSummary, PublicStatusConfig } from '@/lib/api-schema'
+import { ServerSummaryCard } from './server-summary-card'
+import { ServerSummaryRow } from './server-summary-row'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key
+ })
+}))
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children, ...props }: { children?: React.ReactNode; [k: string]: unknown }) => (
+
+ {children}
+
+ )
+}))
+
+const thresholds: Pick = {
+ uptime_red_threshold: 95,
+ uptime_yellow_threshold: 100
+}
+
+const REGEX_PERCENT_LABEL = /%$/
+
+function makeServer(overrides: Partial = {}): PublicServerSummary {
+ return {
+ country_code: 'US',
+ group_name: 'edge',
+ id: 'srv-1',
+ in_maintenance: false,
+ metrics: {
+ cpu: 72,
+ disk_read_bytes_per_sec: 2_100_000,
+ disk_total: 53_687_091_200,
+ disk_used: 21_474_836_480,
+ disk_write_bytes_per_sec: 512_000,
+ load_1: 0.72,
+ load_5: 0.65,
+ load_15: 0.58,
+ mem_total: 8_589_934_592,
+ mem_used: 4_294_967_296,
+ net_in_speed: 12_900_000,
+ net_in_transfer: 1_099_511_627_776,
+ net_out_speed: 4_300_000,
+ net_out_transfer: 549_755_813_888,
+ process_count: 142,
+ swap_total: 2_147_483_648,
+ swap_used: 536_870_912,
+ tcp_conn: 38,
+ udp_conn: 12,
+ uptime: 1_987_200
+ },
+ name: 'status-server',
+ online: true,
+ os: 'Ubuntu 22.04',
+ public_remark: null,
+ region: 'Los Angeles',
+ uptime_daily: [],
+ uptime_percent: 99.8,
+ ...overrides
+ }
+}
+
+describe('ServerSummaryCard', () => {
+ it('uses the same dense card shell as the /servers grid view', () => {
+ const { container } = render()
+
+ const card = container.querySelector('[data-slot="status-server-card"]')
+ expect(card?.className).toContain('min-w-[320px]')
+ expect(card?.className).toContain('max-w-[480px]')
+ expect(card?.className).toContain('p-3')
+ expect(screen.getAllByRole('img', { name: REGEX_PERCENT_LABEL })).toHaveLength(4)
+ expect(screen.getByText('card_net_in_speed')).toBeDefined()
+ expect(screen.getByText('card_net_out_speed')).toBeDefined()
+ expect(screen.getByText('card_disk_read')).toBeDefined()
+ expect(screen.getByText('card_disk_write')).toBeDefined()
+ expect(screen.getByText('card_swap')).toBeDefined()
+ expect(screen.getByText('card_proc_conn_label')).toBeDefined()
+ })
+})
+
+describe('ServerSummaryRow', () => {
+ it('uses the /servers table-density row styling for the public list view', () => {
+ const { container } = render(
+
+ )
+
+ const row = container.querySelector('[data-slot="status-server-row"]')
+ expect(row?.className).toContain('h-[72px]')
+ expect(screen.getByTestId('status-server-link')).toHaveAttribute('href', '/status/server/srv-1')
+ expect(container.textContent ?? '').toContain('72%')
+ expect(container.textContent ?? '').toContain('12.3 MB/s')
+ expect(container.textContent ?? '').toContain('2.0 MB/s')
+ expect(container.textContent ?? '').toContain('142 / 38 / 12')
+ })
+})
diff --git a/apps/web/src/lib/api-schema.ts b/apps/web/src/lib/api-schema.ts
index 7bd089668..c098216d6 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 9fa75818c..5e0e85a19 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/apps/web/src/lib/traffic.ts b/apps/web/src/lib/traffic.ts
index d4696594f..a401729aa 100644
--- a/apps/web/src/lib/traffic.ts
+++ b/apps/web/src/lib/traffic.ts
@@ -15,7 +15,9 @@ interface ComputeInput {
}
export function computeTrafficQuota({ entry, netInTransfer, netOutTransfer }: ComputeInput): TrafficQuota {
- const used = entry ? entry.cycle_in + entry.cycle_out : netInTransfer + netOutTransfer
+ const fallbackIn = Number.isFinite(netInTransfer) ? netInTransfer : 0
+ const fallbackOut = Number.isFinite(netOutTransfer) ? netOutTransfer : 0
+ const used = entry ? entry.cycle_in + entry.cycle_out : fallbackIn + fallbackOut
const rawLimit = entry?.traffic_limit ?? null
const limit = rawLimit != null && rawLimit > 0 ? rawLimit : DEFAULT_TRAFFIC_LIMIT_BYTES
const rawPct = limit > 0 ? (used / limit) * 100 : 0
diff --git a/apps/web/src/routes/status.index.tsx b/apps/web/src/routes/status.index.tsx
index b99c47912..de938b214 100644
--- a/apps/web/src/routes/status.index.tsx
+++ b/apps/web/src/routes/status.index.tsx
@@ -6,6 +6,7 @@ import { LayoutToggle } from '@/components/status/layout-toggle'
import { ServerSummaryCard } from '@/components/status/server-summary-card'
import { ServerSummaryRow } from '@/components/status/server-summary-row'
import { Skeleton } from '@/components/ui/skeleton'
+import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { usePublicStatusConfig } from '@/hooks/use-public-status'
import { api } from '@/lib/api-client'
import type { PublicServerSummary, PublicStatusConfig } from '@/lib/api-schema'
@@ -93,16 +94,32 @@ function PublicStatusIndex() {
{layout === 'grid' ? (
-
+
{servers.map((s) => (
-
+
+
+
))}
) : (
- {servers.map((s) => (
-
- ))}
+
+
+
+ {t('nav_servers')}
+ {t('cpu')}
+ {t('memory')}
+ {t('disk')}
+ {t('network_in')}
+ {t('uptime')}
+
+
+
+ {servers.map((s) => (
+
+ ))}
+
+
)}
diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs
index d4877d093..8e71ed3f5 100644
--- a/crates/server/src/config.rs
+++ b/crates/server/src/config.rs
@@ -39,6 +39,8 @@ pub struct AppConfig {
#[serde(default)]
pub feature: FeatureConfig,
#[serde(default)]
+ pub dev: DevConfig,
+ #[serde(default)]
pub firewall: FirewallConfig,
#[serde(default)]
pub ip_quality: IpQualityConfig,
@@ -64,6 +66,7 @@ impl Default for AppConfig {
mobile: MobileConfig::default(),
resend: ResendConfig::default(),
feature: FeatureConfig::default(),
+ dev: DevConfig::default(),
firewall: FirewallConfig::default(),
ip_quality: IpQualityConfig::default(),
network_probe: NetworkProbeConfig::default(),
@@ -458,7 +461,10 @@ where
fn visit_string
(self, v: String) -> Result {
self.visit_str(&v)
}
- fn visit_seq>(self, mut seq: A) -> Result {
+ fn visit_seq>(
+ self,
+ mut seq: A,
+ ) -> Result {
let mut out = Vec::new();
while let Some(item) = seq.next_element::()? {
out.push(item);
@@ -511,6 +517,12 @@ impl Default for FeatureConfig {
}
}
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct DevConfig {
+ #[serde(default)]
+ pub demo_data: bool,
+}
+
fn default_utc() -> String {
"UTC".to_string()
}
@@ -671,6 +683,22 @@ mod tests {
assert!(cfg.feature.custom_themes);
}
+ #[test]
+ fn dev_demo_data_defaults_to_disabled_and_reads_env_var() {
+ let cfg = AppConfig::default();
+ assert!(!cfg.dev.demo_data);
+
+ figment::Jail::expect_with(|jail| {
+ jail.set_env("SERVERBEE_DEV__DEMO_DATA", "true");
+ let cfg: AppConfig = figment::Figment::new()
+ .merge(figment::providers::Env::prefixed("SERVERBEE_").split("__"))
+ .extract()
+ .expect("dev demo flag should deserialize from env");
+ assert!(cfg.dev.demo_data);
+ Ok(())
+ });
+ }
+
#[test]
fn firewall_allow_list_from_env() {
figment::Jail::expect_with(|jail| {
@@ -772,9 +800,11 @@ mod tests {
ipapi_is: None,
};
let warnings = cfg.validate_warnings();
- assert!(warnings
- .iter()
- .any(|w| w.contains("unknown risk_provider 'scamalytics'")));
+ assert!(
+ warnings
+ .iter()
+ .any(|w| w.contains("unknown risk_provider 'scamalytics'"))
+ );
}
#[test]
diff --git a/crates/server/src/dev_demo.rs b/crates/server/src/dev_demo.rs
new file mode 100644
index 000000000..e381e09bf
--- /dev/null
+++ b/crates/server/src/dev_demo.rs
@@ -0,0 +1,1144 @@
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::sync::Arc;
+use std::time::Duration as StdDuration;
+
+use chrono::{Duration, Timelike, Utc};
+use sea_orm::{
+ ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
+ QueryFilter, Set,
+};
+use tokio::sync::mpsc;
+
+use crate::config::AppConfig;
+use crate::entity::{
+ network_probe_config, network_probe_record, network_probe_record_hourly, network_probe_target,
+ record, record_hourly, server, server_group, server_tag, traffic_daily, traffic_hourly,
+ uptime_daily, user,
+};
+use crate::error::AppError;
+use crate::service::auth::AuthService;
+use crate::service::network_probe::NetworkProbeService;
+use crate::service::server::ServerService;
+use crate::state::AppState;
+use serverbee_common::constants::{CAP_DEFAULT, PROTOCOL_VERSION};
+use serverbee_common::protocol::{BrowserMessage, ServerMessage};
+use serverbee_common::types::{DiskIo, NetworkProbeResultData, SystemReport};
+
+pub const DEMO_SERVER_ID_PREFIX: &str = "dev-demo-";
+pub const DEMO_SERVER_COUNT: usize = 12;
+
+const DEMO_ADMIN_PASSWORD: &str = "admin123";
+const RAW_POINTS_PER_SERVER: usize = 144;
+const HOURLY_POINTS_PER_SERVER: usize = 336;
+const TRAFFIC_DAYS: usize = 30;
+const NETWORK_RAW_POINTS_PER_TARGET: usize = 24;
+const NETWORK_HOURLY_POINTS_PER_TARGET: usize = 72;
+const INSERT_BATCH_SIZE: usize = 40;
+const GIB: i64 = 1024 * 1024 * 1024;
+
+#[derive(Clone, Copy)]
+struct DemoServerSpec {
+ id_suffix: &'static str,
+ name: &'static str,
+ group_id: &'static str,
+ tags: &'static [&'static str],
+ country_code: &'static str,
+ region: &'static str,
+ os: &'static str,
+ cpu_name: &'static str,
+ cpu_cores: i32,
+ mem_gib: i64,
+ swap_gib: i64,
+ disk_gib: i64,
+ ipv4: &'static str,
+ price: f64,
+}
+
+#[derive(Clone, Copy)]
+struct DemoGroupSpec {
+ id: &'static str,
+ name: &'static str,
+ weight: i32,
+}
+
+#[derive(Clone, Copy)]
+struct DemoTargetSpec {
+ id: &'static str,
+ name: &'static str,
+ provider: &'static str,
+ location: &'static str,
+ target: &'static str,
+ probe_type: &'static str,
+}
+
+const DEMO_GROUPS: [DemoGroupSpec; 3] = [
+ DemoGroupSpec {
+ id: "dev-demo-edge",
+ name: "Demo / Edge",
+ weight: 10,
+ },
+ DemoGroupSpec {
+ id: "dev-demo-core",
+ name: "Demo / Core",
+ weight: 20,
+ },
+ DemoGroupSpec {
+ id: "dev-demo-lab",
+ name: "Demo / Lab",
+ weight: 30,
+ },
+];
+
+const DEMO_TARGETS: [DemoTargetSpec; 3] = [
+ DemoTargetSpec {
+ id: "dev-demo-cloudflare",
+ name: "Cloudflare DNS",
+ provider: "cloudflare",
+ location: "global",
+ target: "1.1.1.1",
+ probe_type: "icmp",
+ },
+ DemoTargetSpec {
+ id: "dev-demo-google",
+ name: "Google DNS",
+ provider: "google",
+ location: "global",
+ target: "8.8.8.8",
+ probe_type: "icmp",
+ },
+ DemoTargetSpec {
+ id: "dev-demo-tokyo",
+ name: "Tokyo HTTP",
+ provider: "demo",
+ location: "tokyo",
+ target: "https://example.com",
+ probe_type: "http",
+ },
+];
+
+const DEMO_SERVERS: [DemoServerSpec; DEMO_SERVER_COUNT] = [
+ DemoServerSpec {
+ id_suffix: "01",
+ name: "demo-sfo-edge-01",
+ group_id: "dev-demo-edge",
+ tags: &["demo", "edge", "us"],
+ country_code: "US",
+ region: "California",
+ os: "Ubuntu 24.04 LTS",
+ cpu_name: "AMD EPYC 7B13",
+ cpu_cores: 4,
+ mem_gib: 8,
+ swap_gib: 2,
+ disk_gib: 160,
+ ipv4: "203.0.113.11",
+ price: 12.0,
+ },
+ DemoServerSpec {
+ id_suffix: "02",
+ name: "demo-lax-edge-02",
+ group_id: "dev-demo-edge",
+ tags: &["demo", "edge", "us"],
+ country_code: "US",
+ region: "California",
+ os: "Debian 12",
+ cpu_name: "Intel Xeon Platinum 8370C",
+ cpu_cores: 4,
+ mem_gib: 8,
+ swap_gib: 2,
+ disk_gib: 120,
+ ipv4: "203.0.113.12",
+ price: 10.5,
+ },
+ DemoServerSpec {
+ id_suffix: "03",
+ name: "demo-nrt-edge-01",
+ group_id: "dev-demo-edge",
+ tags: &["demo", "edge", "asia"],
+ country_code: "JP",
+ region: "Tokyo",
+ os: "Ubuntu 22.04 LTS",
+ cpu_name: "Ampere Altra",
+ cpu_cores: 4,
+ mem_gib: 12,
+ swap_gib: 2,
+ disk_gib: 200,
+ ipv4: "198.51.100.21",
+ price: 14.0,
+ },
+ DemoServerSpec {
+ id_suffix: "04",
+ name: "demo-sin-edge-01",
+ group_id: "dev-demo-edge",
+ tags: &["demo", "edge", "asia"],
+ country_code: "SG",
+ region: "Singapore",
+ os: "Ubuntu 24.04 LTS",
+ cpu_name: "AMD EPYC 7763",
+ cpu_cores: 8,
+ mem_gib: 16,
+ swap_gib: 4,
+ disk_gib: 320,
+ ipv4: "198.51.100.22",
+ price: 22.0,
+ },
+ DemoServerSpec {
+ id_suffix: "05",
+ name: "demo-fra-core-01",
+ group_id: "dev-demo-core",
+ tags: &["demo", "core", "eu"],
+ country_code: "DE",
+ region: "Hesse",
+ os: "Debian 12",
+ cpu_name: "AMD EPYC 9354P",
+ cpu_cores: 8,
+ mem_gib: 32,
+ swap_gib: 4,
+ disk_gib: 500,
+ ipv4: "192.0.2.31",
+ price: 32.0,
+ },
+ DemoServerSpec {
+ id_suffix: "06",
+ name: "demo-ams-core-01",
+ group_id: "dev-demo-core",
+ tags: &["demo", "core", "eu"],
+ country_code: "NL",
+ region: "North Holland",
+ os: "Ubuntu 22.04 LTS",
+ cpu_name: "Intel Xeon Gold 6338",
+ cpu_cores: 8,
+ mem_gib: 24,
+ swap_gib: 4,
+ disk_gib: 420,
+ ipv4: "192.0.2.32",
+ price: 28.0,
+ },
+ DemoServerSpec {
+ id_suffix: "07",
+ name: "demo-hkg-core-01",
+ group_id: "dev-demo-core",
+ tags: &["demo", "core", "asia"],
+ country_code: "HK",
+ region: "Hong Kong",
+ os: "AlmaLinux 9",
+ cpu_name: "AMD EPYC 7R13",
+ cpu_cores: 6,
+ mem_gib: 16,
+ swap_gib: 4,
+ disk_gib: 300,
+ ipv4: "198.51.100.41",
+ price: 26.0,
+ },
+ DemoServerSpec {
+ id_suffix: "08",
+ name: "demo-syd-core-01",
+ group_id: "dev-demo-core",
+ tags: &["demo", "core", "oceania"],
+ country_code: "AU",
+ region: "New South Wales",
+ os: "Ubuntu 24.04 LTS",
+ cpu_name: "Ampere Altra",
+ cpu_cores: 4,
+ mem_gib: 16,
+ swap_gib: 4,
+ disk_gib: 260,
+ ipv4: "203.0.113.51",
+ price: 24.0,
+ },
+ DemoServerSpec {
+ id_suffix: "09",
+ name: "demo-nyc-lab-01",
+ group_id: "dev-demo-lab",
+ tags: &["demo", "lab", "us"],
+ country_code: "US",
+ region: "New York",
+ os: "Fedora 40",
+ cpu_name: "Intel Xeon E-2388G",
+ cpu_cores: 2,
+ mem_gib: 4,
+ swap_gib: 1,
+ disk_gib: 80,
+ ipv4: "203.0.113.61",
+ price: 6.0,
+ },
+ DemoServerSpec {
+ id_suffix: "10",
+ name: "demo-tor-lab-01",
+ group_id: "dev-demo-lab",
+ tags: &["demo", "lab", "ca"],
+ country_code: "CA",
+ region: "Ontario",
+ os: "Debian 12",
+ cpu_name: "AMD Ryzen 7950X",
+ cpu_cores: 2,
+ mem_gib: 6,
+ swap_gib: 1,
+ disk_gib: 100,
+ ipv4: "203.0.113.62",
+ price: 7.5,
+ },
+ DemoServerSpec {
+ id_suffix: "11",
+ name: "demo-lon-lab-01",
+ group_id: "dev-demo-lab",
+ tags: &["demo", "lab", "eu"],
+ country_code: "GB",
+ region: "England",
+ os: "Ubuntu 22.04 LTS",
+ cpu_name: "Intel Xeon Silver 4314",
+ cpu_cores: 2,
+ mem_gib: 4,
+ swap_gib: 1,
+ disk_gib: 90,
+ ipv4: "192.0.2.71",
+ price: 8.0,
+ },
+ DemoServerSpec {
+ id_suffix: "12",
+ name: "demo-blr-lab-01",
+ group_id: "dev-demo-lab",
+ tags: &["demo", "lab", "asia"],
+ country_code: "IN",
+ region: "Karnataka",
+ os: "Debian 12",
+ cpu_name: "AMD EPYC 7713",
+ cpu_cores: 2,
+ mem_gib: 4,
+ swap_gib: 1,
+ disk_gib: 100,
+ ipv4: "198.51.100.72",
+ price: 7.0,
+ },
+];
+
+pub fn validate_demo_config(config: &AppConfig) -> anyhow::Result<()> {
+ if !config.dev.demo_data {
+ return Ok(());
+ }
+
+ if config.database.path != "dev-demo.db" {
+ anyhow::bail!(
+ "dev.demo_data is destructive and only allowed with database.path = dev-demo.db"
+ );
+ }
+
+ Ok(())
+}
+
+pub async fn seed_demo_data(db: &DatabaseConnection) -> Result<(), AppError> {
+ let now = current_hour();
+
+ seed_demo_admin(db).await?;
+ delete_existing_demo_data(db).await?;
+ seed_groups(db, now).await?;
+ seed_network_targets(db, now).await?;
+ seed_servers(db, now).await?;
+ seed_metric_records(db, now).await?;
+ seed_traffic_records(db, now).await?;
+ seed_uptime_records(db, now).await?;
+ seed_network_records(db, now).await?;
+
+ tracing::info!(
+ "Seeded local demo data: {} servers, {}h raw metrics, {}d hourly metrics",
+ DEMO_SERVER_COUNT,
+ RAW_POINTS_PER_SERVER / 6,
+ HOURLY_POINTS_PER_SERVER / 24
+ );
+
+ Ok(())
+}
+
+pub async fn start_demo_agents(state: Arc) -> Result<(), AppError> {
+ let demo_servers = server::Entity::find()
+ .filter(server::Column::Id.like(format!("{DEMO_SERVER_ID_PREFIX}%")))
+ .all(&state.db)
+ .await?;
+
+ for (index, demo_server) in demo_servers.into_iter().enumerate() {
+ let spec = DEMO_SERVERS
+ .iter()
+ .find(|spec| demo_server.id == demo_server_id(spec))
+ .copied()
+ .unwrap_or(DEMO_SERVERS[index % DEMO_SERVERS.len()]);
+
+ let (tx, rx) = mpsc::channel::(32);
+ let addr = demo_agent_addr(index);
+ state.agent_manager.add_connection(
+ demo_server.id.clone(),
+ demo_server.name.clone(),
+ tx,
+ addr,
+ );
+ state
+ .agent_manager
+ .update_server_capabilities(&demo_server.id, CAP_DEFAULT);
+ state
+ .agent_manager
+ .update_agent_local_capabilities(&demo_server.id, CAP_DEFAULT);
+ state
+ .agent_manager
+ .set_protocol_version(&demo_server.id, PROTOCOL_VERSION);
+ state.agent_manager.update_agent_platform(
+ &demo_server.id,
+ spec.os.to_string(),
+ "x86_64".to_string(),
+ );
+ state.agent_manager.update_report(
+ &demo_server.id,
+ build_report(spec, live_sample_index(index), 60),
+ );
+
+ spawn_demo_agent_reporter(state.clone(), demo_server.id, spec, rx, index);
+ }
+
+ tracing::info!("Started {} in-memory demo agents", DEMO_SERVER_COUNT);
+ Ok(())
+}
+
+fn spawn_demo_agent_reporter(
+ state: Arc,
+ server_id: String,
+ spec: DemoServerSpec,
+ mut rx: mpsc::Receiver,
+ index: usize,
+) {
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(StdDuration::from_secs(3));
+ loop {
+ tokio::select! {
+ maybe_msg = rx.recv() => {
+ if maybe_msg.is_none() {
+ break;
+ }
+ }
+ _ = interval.tick() => {
+ let sample = live_sample_index(index);
+ state.agent_manager.update_report(&server_id, build_report(spec, sample, 60));
+ let probe_results = build_network_probe_results(index, sample, Utc::now());
+ match NetworkProbeService::save_results(&state.db, &server_id, probe_results.clone()).await {
+ Ok(()) => {
+ let _ = state.browser_tx.send(BrowserMessage::NetworkProbeUpdate {
+ server_id: server_id.clone(),
+ results: probe_results,
+ });
+ }
+ Err(e) => {
+ tracing::warn!("Failed to save demo network probe result for {server_id}: {e}");
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
+async fn seed_demo_admin(db: &DatabaseConnection) -> Result<(), AppError> {
+ let now = Utc::now();
+ let password_hash = AuthService::hash_password(DEMO_ADMIN_PASSWORD)?;
+ let existing = user::Entity::find()
+ .filter(user::Column::Username.eq(AuthService::DEFAULT_ADMIN_USERNAME))
+ .one(db)
+ .await?;
+
+ if let Some(existing) = existing {
+ let mut active: user::ActiveModel = existing.into();
+ active.password_hash = Set(password_hash);
+ active.role = Set("admin".to_string());
+ active.must_change_password = Set(false);
+ active.updated_at = Set(now);
+ active.update(db).await?;
+ return Ok(());
+ }
+
+ user::ActiveModel {
+ id: Set("dev-demo-admin".to_string()),
+ username: Set(AuthService::DEFAULT_ADMIN_USERNAME.to_string()),
+ password_hash: Set(password_hash),
+ role: Set("admin".to_string()),
+ totp_secret: Set(None),
+ must_change_password: Set(false),
+ created_at: Set(now),
+ updated_at: Set(now),
+ }
+ .insert(db)
+ .await?;
+
+ Ok(())
+}
+
+async fn delete_existing_demo_data(db: &DatabaseConnection) -> Result<(), AppError> {
+ let demo_ids = server::Entity::find()
+ .filter(server::Column::Id.like(format!("{DEMO_SERVER_ID_PREFIX}%")))
+ .all(db)
+ .await?
+ .into_iter()
+ .map(|server| server.id)
+ .collect::>();
+
+ if !demo_ids.is_empty() {
+ ServerService::batch_delete(db, &demo_ids).await?;
+ }
+
+ server_group::Entity::delete_many()
+ .filter(server_group::Column::Id.like("dev-demo-%"))
+ .exec(db)
+ .await?;
+ network_probe_target::Entity::delete_many()
+ .filter(network_probe_target::Column::Id.like("dev-demo-%"))
+ .exec(db)
+ .await?;
+
+ Ok(())
+}
+
+async fn seed_groups(db: &DatabaseConnection, now: chrono::DateTime) -> Result<(), AppError> {
+ for group in DEMO_GROUPS {
+ server_group::ActiveModel {
+ id: Set(group.id.to_string()),
+ name: Set(group.name.to_string()),
+ weight: Set(group.weight),
+ created_at: Set(now),
+ }
+ .insert(db)
+ .await?;
+ }
+ Ok(())
+}
+
+async fn seed_network_targets(
+ db: &DatabaseConnection,
+ now: chrono::DateTime,
+) -> Result<(), AppError> {
+ for target in DEMO_TARGETS {
+ network_probe_target::ActiveModel {
+ id: Set(target.id.to_string()),
+ name: Set(target.name.to_string()),
+ provider: Set(target.provider.to_string()),
+ location: Set(target.location.to_string()),
+ target: Set(target.target.to_string()),
+ probe_type: Set(target.probe_type.to_string()),
+ created_at: Set(now),
+ updated_at: Set(now),
+ }
+ .insert(db)
+ .await?;
+ }
+ Ok(())
+}
+
+async fn seed_servers(db: &DatabaseConnection, now: chrono::DateTime) -> Result<(), AppError> {
+ let token_hash = AuthService::hash_password("dev-demo-agent-token")?;
+
+ for (index, spec) in DEMO_SERVERS.iter().enumerate() {
+ let id = demo_server_id(spec);
+ server::ActiveModel {
+ id: Set(id.clone()),
+ token_hash: Set(Some(token_hash.clone())),
+ token_prefix: Set(Some("dev-demo".to_string())),
+ name: Set(spec.name.to_string()),
+ cpu_name: Set(Some(spec.cpu_name.to_string())),
+ cpu_cores: Set(Some(spec.cpu_cores)),
+ cpu_arch: Set(Some("x86_64".to_string())),
+ os: Set(Some(spec.os.to_string())),
+ kernel_version: Set(Some("6.8.0-demo".to_string())),
+ mem_total: Set(Some(spec.mem_gib * GIB)),
+ swap_total: Set(Some(spec.swap_gib * GIB)),
+ disk_total: Set(Some(spec.disk_gib * GIB)),
+ ipv4: Set(Some(spec.ipv4.to_string())),
+ ipv6: Set(None),
+ region: Set(Some(spec.region.to_string())),
+ country_code: Set(Some(spec.country_code.to_string())),
+ virtualization: Set(Some("kvm".to_string())),
+ agent_version: Set(Some(format!(
+ "{}-demo",
+ serverbee_common::constants::VERSION
+ ))),
+ group_id: Set(Some(spec.group_id.to_string())),
+ weight: Set(index as i32),
+ hidden: Set(false),
+ remark: Set(Some("Local synthetic demo server".to_string())),
+ public_remark: Set(None),
+ price: Set(Some(spec.price)),
+ billing_cycle: Set(Some("monthly".to_string())),
+ currency: Set(Some("USD".to_string())),
+ expired_at: Set(None),
+ traffic_limit: Set(Some((spec.mem_gib + spec.disk_gib / 10) * 100 * GIB)),
+ traffic_limit_type: Set(Some("monthly".to_string())),
+ billing_start_day: Set(Some(1 + (index % 27) as i32)),
+ capabilities: Set(CAP_DEFAULT as i32),
+ protocol_version: Set(PROTOCOL_VERSION as i32),
+ features: Set("[]".to_string()),
+ last_remote_addr: Set(Some(spec.ipv4.to_string())),
+ fingerprint: Set(Some(format!("dev-demo-fingerprint-{}", spec.id_suffix))),
+ created_at: Set(now - Duration::days(45 - index as i64)),
+ updated_at: Set(now - Duration::seconds((index % 4) as i64 * 11)),
+ }
+ .insert(db)
+ .await?;
+
+ for tag in spec.tags {
+ server_tag::ActiveModel {
+ server_id: Set(id.clone()),
+ tag: Set((*tag).to_string()),
+ }
+ .insert(db)
+ .await?;
+ }
+
+ for target in DEMO_TARGETS {
+ network_probe_config::ActiveModel {
+ id: Set(format!("{id}-{}", target.id)),
+ server_id: Set(id.clone()),
+ target_id: Set(target.id.to_string()),
+ created_at: Set(now),
+ }
+ .insert(db)
+ .await?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn seed_metric_records(
+ db: &DatabaseConnection,
+ now: chrono::DateTime,
+) -> Result<(), AppError> {
+ let mut raw_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+ let mut hourly_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+
+ for spec in DEMO_SERVERS {
+ let server_id = demo_server_id(&spec);
+
+ for point in 0..RAW_POINTS_PER_SERVER {
+ let age = (RAW_POINTS_PER_SERVER - point) as i64 * 10;
+ let time = now - Duration::minutes(age);
+ let report = build_report(spec, point, 600);
+ raw_batch.push(record_model(&server_id, time, report)?);
+ if raw_batch.len() >= INSERT_BATCH_SIZE {
+ flush_record_batch(db, &mut raw_batch).await?;
+ }
+ }
+
+ for point in 0..HOURLY_POINTS_PER_SERVER {
+ let age = (HOURLY_POINTS_PER_SERVER - point) as i64;
+ let time = now - Duration::hours(age);
+ let report = build_report(spec, point, 3600);
+ hourly_batch.push(record_hourly_model(&server_id, time, report)?);
+ if hourly_batch.len() >= INSERT_BATCH_SIZE {
+ flush_record_hourly_batch(db, &mut hourly_batch).await?;
+ }
+ }
+ }
+
+ flush_record_batch(db, &mut raw_batch).await?;
+ flush_record_hourly_batch(db, &mut hourly_batch).await?;
+ Ok(())
+}
+
+async fn seed_traffic_records(
+ db: &DatabaseConnection,
+ now: chrono::DateTime,
+) -> Result<(), AppError> {
+ let mut hourly_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+ let mut daily_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+
+ for spec in DEMO_SERVERS {
+ let server_id = demo_server_id(&spec);
+ for hour_index in 0..(7 * 24) {
+ let age = (7 * 24 - hour_index) as i64;
+ hourly_batch.push(traffic_hourly::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.clone()),
+ hour: Set(now - Duration::hours(age)),
+ bytes_in: Set(traffic_bytes(spec, hour_index, 23)),
+ bytes_out: Set(traffic_bytes(spec, hour_index, 17)),
+ });
+ if hourly_batch.len() >= INSERT_BATCH_SIZE {
+ flush_traffic_hourly_batch(db, &mut hourly_batch).await?;
+ }
+ }
+
+ for day_index in 0..TRAFFIC_DAYS {
+ let age = (TRAFFIC_DAYS - day_index) as i64;
+ daily_batch.push(traffic_daily::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.clone()),
+ date: Set((now - Duration::days(age)).date_naive()),
+ bytes_in: Set(traffic_bytes(spec, day_index, 251) * 12),
+ bytes_out: Set(traffic_bytes(spec, day_index, 193) * 9),
+ });
+ if daily_batch.len() >= INSERT_BATCH_SIZE {
+ flush_traffic_daily_batch(db, &mut daily_batch).await?;
+ }
+ }
+ }
+
+ flush_traffic_hourly_batch(db, &mut hourly_batch).await?;
+ flush_traffic_daily_batch(db, &mut daily_batch).await?;
+ Ok(())
+}
+
+async fn seed_uptime_records(
+ db: &DatabaseConnection,
+ now: chrono::DateTime,
+) -> Result<(), AppError> {
+ let mut batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+
+ for (server_index, spec) in DEMO_SERVERS.iter().enumerate() {
+ let server_id = demo_server_id(spec);
+ for day_index in 0..TRAFFIC_DAYS {
+ let total = 1440;
+ let wobble = ((server_index * 13 + day_index * 7) % 19) as i32;
+ let incident = i32::from((server_index + day_index).is_multiple_of(17));
+ batch.push(uptime_daily::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.clone()),
+ date: Set((now - Duration::days((TRAFFIC_DAYS - day_index) as i64)).date_naive()),
+ total_minutes: Set(total),
+ online_minutes: Set(total - wobble - incident * 45),
+ downtime_incidents: Set(incident),
+ });
+ if batch.len() >= INSERT_BATCH_SIZE {
+ flush_uptime_batch(db, &mut batch).await?;
+ }
+ }
+ }
+
+ flush_uptime_batch(db, &mut batch).await?;
+ Ok(())
+}
+
+async fn seed_network_records(
+ db: &DatabaseConnection,
+ now: chrono::DateTime,
+) -> Result<(), AppError> {
+ let mut raw_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+ let mut hourly_batch = Vec::with_capacity(INSERT_BATCH_SIZE);
+ let raw_now = Utc::now();
+
+ for (server_index, spec) in DEMO_SERVERS.iter().enumerate() {
+ let server_id = demo_server_id(spec);
+ for (target_index, target) in DEMO_TARGETS.iter().enumerate() {
+ for point in 0..NETWORK_RAW_POINTS_PER_TARGET {
+ let latency = latency_ms(server_index, target_index, point);
+ let loss = packet_loss(server_index, target_index, point);
+ raw_batch.push(network_probe_record::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.clone()),
+ target_id: Set(target.id.to_string()),
+ avg_latency: Set(Some(latency)),
+ min_latency: Set(Some((latency * 0.72).max(1.0))),
+ max_latency: Set(Some(latency * 1.35)),
+ packet_loss: Set(loss),
+ packet_sent: Set(10),
+ packet_received: Set(packet_received_from_loss(loss) as i32),
+ timestamp: Set(raw_now
+ - Duration::minutes(
+ (NETWORK_RAW_POINTS_PER_TARGET - 1 - point) as i64 * 3,
+ )),
+ });
+ if raw_batch.len() >= INSERT_BATCH_SIZE {
+ flush_network_raw_batch(db, &mut raw_batch).await?;
+ }
+ }
+
+ for point in 0..NETWORK_HOURLY_POINTS_PER_TARGET {
+ let latency = latency_ms(server_index, target_index, point);
+ hourly_batch.push(network_probe_record_hourly::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.clone()),
+ target_id: Set(target.id.to_string()),
+ avg_latency: Set(Some(latency)),
+ min_latency: Set(Some((latency * 0.74).max(1.0))),
+ max_latency: Set(Some(latency * 1.42)),
+ avg_packet_loss: Set(packet_loss(server_index, target_index, point)),
+ sample_count: Set(4),
+ hour: Set(
+ now - Duration::hours((NETWORK_HOURLY_POINTS_PER_TARGET - point) as i64)
+ ),
+ });
+ if hourly_batch.len() >= INSERT_BATCH_SIZE {
+ flush_network_hourly_batch(db, &mut hourly_batch).await?;
+ }
+ }
+ }
+ }
+
+ flush_network_raw_batch(db, &mut raw_batch).await?;
+ flush_network_hourly_batch(db, &mut hourly_batch).await?;
+ Ok(())
+}
+
+fn build_network_probe_results(
+ server_index: usize,
+ sample: usize,
+ timestamp: chrono::DateTime,
+) -> Vec {
+ DEMO_TARGETS
+ .iter()
+ .enumerate()
+ .map(|(target_index, target)| {
+ let latency = latency_ms(server_index, target_index, sample);
+ let loss = packet_loss(server_index, target_index, sample);
+ NetworkProbeResultData {
+ target_id: target.id.to_string(),
+ avg_latency: Some(latency),
+ min_latency: Some((latency * 0.72).max(1.0)),
+ max_latency: Some(latency * 1.35),
+ packet_loss: loss,
+ packet_sent: 10,
+ packet_received: packet_received_from_loss(loss),
+ timestamp,
+ }
+ })
+ .collect()
+}
+
+fn record_model(
+ server_id: &str,
+ time: chrono::DateTime,
+ report: SystemReport,
+) -> Result {
+ Ok(record::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.to_string()),
+ time: Set(time),
+ cpu: Set(report.cpu),
+ mem_used: Set(report.mem_used),
+ swap_used: Set(report.swap_used),
+ disk_used: Set(report.disk_used),
+ net_in_speed: Set(report.net_in_speed),
+ net_out_speed: Set(report.net_out_speed),
+ net_in_transfer: Set(report.net_in_transfer),
+ net_out_transfer: Set(report.net_out_transfer),
+ load1: Set(report.load1),
+ load5: Set(report.load5),
+ load15: Set(report.load15),
+ tcp_conn: Set(report.tcp_conn),
+ udp_conn: Set(report.udp_conn),
+ process_count: Set(report.process_count),
+ temperature: Set(report.temperature),
+ gpu_usage: Set(report.gpu.as_ref().map(|gpu| gpu.average_usage)),
+ disk_io_json: Set(serialize_disk_io(&report)?),
+ })
+}
+
+fn record_hourly_model(
+ server_id: &str,
+ time: chrono::DateTime,
+ report: SystemReport,
+) -> Result {
+ Ok(record_hourly::ActiveModel {
+ id: NotSet,
+ server_id: Set(server_id.to_string()),
+ time: Set(time),
+ cpu: Set(report.cpu),
+ mem_used: Set(report.mem_used),
+ swap_used: Set(report.swap_used),
+ disk_used: Set(report.disk_used),
+ net_in_speed: Set(report.net_in_speed),
+ net_out_speed: Set(report.net_out_speed),
+ net_in_transfer: Set(report.net_in_transfer),
+ net_out_transfer: Set(report.net_out_transfer),
+ load1: Set(report.load1),
+ load5: Set(report.load5),
+ load15: Set(report.load15),
+ tcp_conn: Set(report.tcp_conn),
+ udp_conn: Set(report.udp_conn),
+ process_count: Set(report.process_count),
+ temperature: Set(report.temperature),
+ gpu_usage: Set(report.gpu.as_ref().map(|gpu| gpu.average_usage)),
+ disk_io_json: Set(serialize_disk_io(&report)?),
+ })
+}
+
+fn serialize_disk_io(report: &SystemReport) -> Result