('/api/agent/enrollments', {}),
- onSuccess: (data) => {
- setIssuedCode(data.code)
- queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] })
- },
- onError: () => toast.error(t('enrollment.generate_failed'))
- })
-
- const deleteMutation = useMutation({
- mutationFn: (id: string) => api.delete(`/api/agent/enrollments/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] })
- toast.success(t('enrollment.deleted'))
- },
- onError: () => toast.error(t('enrollment.delete_failed'))
- })
-
- const installCommand = issuedCode
- ? `curl -fsSL ${window.location.origin}/install.sh | sudo bash -s -- --server-url '${window.location.origin}' --enrollment-code '${issuedCode}'`
- : ''
-
- const copy = async (value: string) => {
- try {
- await navigator.clipboard.writeText(value)
- toast.success(t('copied'))
- } catch {
- // Clipboard access denied
- }
- }
return (
{t('title')}
-
-
{t('enrollment.title')}
-
{t('enrollment.description')}
-
-
generateMutation.mutate()}>
-
- {generateMutation.isPending ? t('enrollment.generating') : t('enrollment.generate')}
-
-
- {issuedCode ? (
-
-
{t('enrollment.code_once_warning')}
-
-
-
{t('enrollment.code_label')}
-
-
- {issuedCode}
-
- copy(issuedCode)}
- size="icon"
- variant="outline"
- >
-
-
-
-
-
-
-
{t('enrollment.install_command')}
-
-
- {installCommand}
-
- copy(installCommand)}
- size="icon"
- variant="outline"
- >
-
-
-
-
-
- ) : null}
-
-
-
{t('enrollment.list_title')}
- {(() => {
- if (isLoading) {
- return
- }
- if (!enrollments || enrollments.length === 0) {
- return
{t('enrollment.empty')}
- }
- return (
-
-
- {enrollments.map((item) => {
- const status = enrollmentStatus(item)
- return (
-
-
-
- {item.code_prefix}…
- {item.label ? (
- {item.label}
- ) : null}
-
-
- {t('enrollment.expires_at', {
- date: new Date(item.expires_at).toLocaleString()
- })}
-
-
- {t(`enrollment.status_${status}`)}
-
-
-
-
- }
- />
-
-
- {t('enrollment.delete_confirm_title')}
-
- {t('enrollment.delete_confirm_description')}
-
-
-
- {t('common:cancel')}
- deleteMutation.mutate(item.id)} variant="destructive">
- {t('enrollment.delete')}
-
-
-
-
-
- )
- })}
-
-
- )
- })()}
-
-
-
From 3475ed708327e35a04c338753e9f17bf1668bb9c Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sun, 17 May 2026 23:31:01 +0800
Subject: [PATCH 33/43] fix(web): right-align the Add Server button in the
servers header
Restore the header's space-between layout so the admin Add Server
action sits at the top-right of the page, matching the placement of
primary actions elsewhere.
---
apps/web/src/routes/_authed/servers/index.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx
index a05afc9f..adc5be3e 100644
--- a/apps/web/src/routes/_authed/servers/index.tsx
+++ b/apps/web/src/routes/_authed/servers/index.tsx
@@ -339,7 +339,7 @@ function ServersListPage() {
return (
-
+
{t('title')}
@@ -347,7 +347,7 @@ function ServersListPage() {
{isAdmin && (
-
setAddOpen(true)}>
+ setAddOpen(true)}>
{t('add_server.button')}
From 7f36a9166bba6fed686e670715247fb1f68f39fe Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sun, 17 May 2026 23:56:50 +0800
Subject: [PATCH 34/43] fix(web): correct network square grid tooltip styling
The tooltip overrode only the popup background (bg-background) while
the base tooltip's text (text-background) and arrow (bg-foreground)
were left intact. That made the body text the same color as its
background and left a mismatched light arrow on a dark bubble in dark
mode. Drop the partial override and use the shared tooltip surface,
matching every other tooltip in the app and staying theme-correct in
both light and dark.
---
apps/web/src/components/server/network-square-grid.tsx | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/apps/web/src/components/server/network-square-grid.tsx b/apps/web/src/components/server/network-square-grid.tsx
index dffbee69..4abc5a7b 100644
--- a/apps/web/src/components/server/network-square-grid.tsx
+++ b/apps/web/src/components/server/network-square-grid.tsx
@@ -138,10 +138,7 @@ export function NetworkSquareGrid({ points, kind }: NetworkSquareGridProps) {
/>
}
/>
-
+
From d601289885a8adeab68d4c7fc7cab4f30767c613 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:00:39 +0800
Subject: [PATCH 35/43] fix(web): make tooltips follow the theme instead of
inverting
Tooltips used bg-foreground/text-background, so in dark mode they
rendered as a bright pill instead of a dark surface. Switch the
tooltip popup and arrow to the popover tokens (bg-popover/
text-popover-foreground with a border), so tooltips are dark in dark
mode and light in light mode, with the arrow matching the bubble.
---
apps/web/src/components/ui/tooltip.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index cfdb78bb..98a0f0ee 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -35,14 +35,14 @@ function TooltipContent({
>
{children}
-
+
From e7a7cf0ee5bc676dd0e8b80dc13b8d35731719b4 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:19:36 +0800
Subject: [PATCH 36/43] refactor(web): use two-column grid for server card
footer stats
Convert the bottom footer stats row from a centered wrapping flex to a
two-column label/value grid so it matches the card's other 2-column
sections. Traffic days-remaining now renders in the second column and the
cost footnote spans both columns centered below.
---
.../web/src/components/server/server-card.tsx | 54 ++++++++++---------
apps/web/src/locales/en/servers.json | 3 +-
apps/web/src/locales/zh/servers.json | 3 +-
3 files changed, 33 insertions(+), 27 deletions(-)
diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx
index 0b65316f..6a492220 100644
--- a/apps/web/src/components/server/server-card.tsx
+++ b/apps/web/src/components/server/server-card.tsx
@@ -289,34 +289,38 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)}
-
-
- {t('col_uptime')}{' '}
+
+
+ {t('col_uptime')}
{formatUptime(server.uptime)}
-
- ·
-
- {t('card_swap')} {`${swapPct.toFixed(0)}%`}
-
- ·
-
- {t('card_processes')} {server.process_count}
-
- ·
-
- {t('card_tcp')} {server.tcp_conn}
-
- ·
-
- {t('card_udp')} {server.udp_conn}
-
+
+
+ {t('card_swap')}
+ {`${swapPct.toFixed(0)}%`}
+
+
+ {t('card_processes')}
+ {server.process_count}
+
+
+ {t('card_tcp')}
+ {server.tcp_conn}
+
+
+ {t('card_udp')}
+ {server.udp_conn}
+
{trafficDaysRemaining != null && (
- <>
-
·
-
{t('card_traffic_days_left', { count: trafficDaysRemaining })}
- >
+
+ {t('card_traffic_days_left_label')}
+
+ {t('card_traffic_days_value', { count: trafficDaysRemaining })}
+
+
)}
-
+
+
+
diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json
index dc749e4f..3d28479f 100644
--- a/apps/web/src/locales/en/servers.json
+++ b/apps/web/src/locales/en/servers.json
@@ -50,7 +50,8 @@
"card_disk_write": "↻ Write",
"card_load_trend": "Load 5m·15m",
"card_traffic_quota": "Traffic",
- "card_traffic_days_left": "{{count}}d left",
+ "card_traffic_days_left_label": "Days left",
+ "card_traffic_days_value": "{{count}}d",
"servers_online": "{{online}} of {{total}} servers online",
"search_placeholder": "Search servers\u2026",
"delete_selected": "Delete {{count}}",
diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json
index a595d0a9..97db1912 100644
--- a/apps/web/src/locales/zh/servers.json
+++ b/apps/web/src/locales/zh/servers.json
@@ -50,7 +50,8 @@
"card_disk_write": "↻ 写",
"card_load_trend": "负载 5m·15m",
"card_traffic_quota": "流量",
- "card_traffic_days_left": "剩 {{count}} 天",
+ "card_traffic_days_left_label": "剩余天数",
+ "card_traffic_days_value": "{{count}} 天",
"servers_online": "{{total}} 台服务器中 {{online}} 台在线",
"search_placeholder": "搜索服务器\u2026",
"delete_selected": "删除 {{count}} 项",
From 3be75cc95d18728a96e775aefd9b0e8fb06f55d0 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:24:56 +0800
Subject: [PATCH 37/43] refactor(web): merge proc/tcp/udp and align cost in
card footer
Combine processes, TCP and UDP into a single footer row and render the
cost as a regular label/value cell so it conforms to the two-column grid
instead of a centered footnote.
---
.../src/components/server/cost-footnote.tsx | 5 ++--
.../components/server/server-card.test.tsx | 4 +---
.../web/src/components/server/server-card.tsx | 23 ++++++++-----------
apps/web/src/locales/en/servers.json | 5 ++--
apps/web/src/locales/zh/servers.json | 5 ++--
5 files changed, 18 insertions(+), 24 deletions(-)
diff --git a/apps/web/src/components/server/cost-footnote.tsx b/apps/web/src/components/server/cost-footnote.tsx
index f175f8a1..6ac579d4 100644
--- a/apps/web/src/components/server/cost-footnote.tsx
+++ b/apps/web/src/components/server/cost-footnote.tsx
@@ -5,9 +5,10 @@ import { cn } from '@/lib/utils'
interface CostFootnoteProps {
entry?: ServerCostOverview
+ inline?: boolean
}
-export function CostFootnote({ entry }: CostFootnoteProps) {
+export function CostFootnote({ entry, inline = false }: CostFootnoteProps) {
const { t } = useTranslation('servers')
if (!entry) {
@@ -16,7 +17,7 @@ export function CostFootnote({ entry }: CostFootnoteProps) {
return (
- ·
+ {!inline && · }
{entry.configured ? (
) : (
diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx
index 4d87740d..17ddd8a9 100644
--- a/apps/web/src/components/server/server-card.test.tsx
+++ b/apps/web/src/components/server/server-card.test.tsx
@@ -122,9 +122,7 @@ describe('ServerCard', () => {
renderCard(makeServer())
expect(screen.getByText('col_uptime')).toBeDefined()
expect(screen.getByText('card_swap')).toBeDefined()
- expect(screen.getByText('card_processes')).toBeDefined()
- expect(screen.getByText('card_tcp')).toBeDefined()
- expect(screen.getByText('card_udp')).toBeDefined()
+ expect(screen.getByText('card_proc_conn_label')).toBeDefined()
})
it('renders compact cost footnote when cost overview is available', () => {
diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx
index 6a492220..16cee4f3 100644
--- a/apps/web/src/components/server/server-card.tsx
+++ b/apps/web/src/components/server/server-card.tsx
@@ -299,16 +299,10 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
{`${swapPct.toFixed(0)}%`}
- {t('card_processes')}
- {server.process_count}
-
-
- {t('card_tcp')}
- {server.tcp_conn}
-
-
- {t('card_udp')}
- {server.udp_conn}
+ {t('card_proc_conn_label')}
+
+ {`${server.process_count} / ${server.tcp_conn} / ${server.udp_conn}`}
+
{trafficDaysRemaining != null && (
@@ -318,9 +312,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)}
-
-
-
+ {costEntry && (
+
+ {t('card_cost_label')}
+
+
+ )}
diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json
index 3d28479f..b67e6033 100644
--- a/apps/web/src/locales/en/servers.json
+++ b/apps/web/src/locales/en/servers.json
@@ -40,10 +40,7 @@
"card_net_total": "Total",
"card_network_quality": "Network Quality",
"card_packet_loss": "Loss",
- "card_processes": "Processes",
"card_swap": "Swap",
- "card_tcp": "TCP",
- "card_udp": "UDP",
"card_net_in_speed": "↓ In",
"card_net_out_speed": "↑ Out",
"card_disk_read": "↺ Read",
@@ -52,6 +49,8 @@
"card_traffic_quota": "Traffic",
"card_traffic_days_left_label": "Days left",
"card_traffic_days_value": "{{count}}d",
+ "card_proc_conn_label": "Proc / TCP / UDP",
+ "card_cost_label": "Cost",
"servers_online": "{{online}} of {{total}} servers online",
"search_placeholder": "Search servers\u2026",
"delete_selected": "Delete {{count}}",
diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json
index 97db1912..d3cb147d 100644
--- a/apps/web/src/locales/zh/servers.json
+++ b/apps/web/src/locales/zh/servers.json
@@ -40,10 +40,7 @@
"card_net_total": "总流量",
"card_network_quality": "网络质量",
"card_packet_loss": "丢包",
- "card_processes": "进程",
"card_swap": "Swap",
- "card_tcp": "TCP",
- "card_udp": "UDP",
"card_net_in_speed": "↓ 入站",
"card_net_out_speed": "↑ 出站",
"card_disk_read": "↺ 读",
@@ -52,6 +49,8 @@
"card_traffic_quota": "流量",
"card_traffic_days_left_label": "剩余天数",
"card_traffic_days_value": "{{count}} 天",
+ "card_proc_conn_label": "进程 / TCP / UDP",
+ "card_cost_label": "费用",
"servers_online": "{{total}} 台服务器中 {{online}} 台在线",
"search_placeholder": "搜索服务器\u2026",
"delete_selected": "删除 {{count}} 项",
From f41b53a3f804f58e39cb226e4ad48b013faba307 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:29:42 +0800
Subject: [PATCH 38/43] refactor(web): circular R/W badges and move load to
card footer
Show full read/write labels prefixed with circular R/W badges, and
relocate the load trend from the compact metrics grid into the bottom
two-column footer stats so the compact grid is a clean 2x2.
---
.../web/src/components/server/server-card.tsx | 38 +++++++++++--------
apps/web/src/locales/en/servers.json | 4 +-
apps/web/src/locales/zh/servers.json | 4 +-
3 files changed, 26 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx
index 16cee4f3..230424d9 100644
--- a/apps/web/src/components/server/server-card.tsx
+++ b/apps/web/src/components/server/server-card.tsx
@@ -237,32 +237,32 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
- R
+
+
+ R
+
+ {t('card_disk_read')}
}
value={renderSpeedValue(server.disk_read_bytes_per_sec)}
/>
- W
+
+
+ W
+
+ {t('card_disk_write')}
}
value={renderSpeedValue(server.disk_write_bytes_per_sec)}
/>
-
{hasNetworkData && (
@@ -298,6 +298,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
{t('card_swap')}
{`${swapPct.toFixed(0)}%`}
+
+ {t('card_load_trend')}
+
+ {`${formatLoad(server.load5)}·${formatLoad(server.load15)}`}
+
+
{t('card_proc_conn_label')}
diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json
index b67e6033..efc6de6b 100644
--- a/apps/web/src/locales/en/servers.json
+++ b/apps/web/src/locales/en/servers.json
@@ -43,8 +43,8 @@
"card_swap": "Swap",
"card_net_in_speed": "↓ In",
"card_net_out_speed": "↑ Out",
- "card_disk_read": "↺ Read",
- "card_disk_write": "↻ Write",
+ "card_disk_read": "Read",
+ "card_disk_write": "Write",
"card_load_trend": "Load 5m·15m",
"card_traffic_quota": "Traffic",
"card_traffic_days_left_label": "Days left",
diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json
index d3cb147d..fb51f2de 100644
--- a/apps/web/src/locales/zh/servers.json
+++ b/apps/web/src/locales/zh/servers.json
@@ -43,8 +43,8 @@
"card_swap": "Swap",
"card_net_in_speed": "↓ 入站",
"card_net_out_speed": "↑ 出站",
- "card_disk_read": "↺ 读",
- "card_disk_write": "↻ 写",
+ "card_disk_read": "读",
+ "card_disk_write": "写",
"card_load_trend": "负载 5m·15m",
"card_traffic_quota": "流量",
"card_traffic_days_left_label": "剩余天数",
From 40764d2b6bec721cf289326ec3b38bd04cb2b936 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:33:25 +0800
Subject: [PATCH 39/43] refactor(web): show monthly cost instead of value grade
in footnote
Replace the value-score grade label in the configured cost footnote with
the monthly-equivalent total cost so the card surfaces an absolute spend
figure alongside the hourly rate.
---
apps/web/src/components/server/cost-footnote.tsx | 9 ++++-----
.../web/src/components/server/server-card.test.tsx | 14 +++++---------
2 files changed, 9 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/components/server/cost-footnote.tsx b/apps/web/src/components/server/cost-footnote.tsx
index 6ac579d4..2f2dc929 100644
--- a/apps/web/src/components/server/cost-footnote.tsx
+++ b/apps/web/src/components/server/cost-footnote.tsx
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'
import type { ServerCostOverview } from '@/lib/api-schema'
-import { formatCostRate, getCostGradeClassName } from '@/lib/cost'
-import { cn } from '@/lib/utils'
+import { formatCostRate } from '@/lib/cost'
interface CostFootnoteProps {
entry?: ServerCostOverview
@@ -39,11 +38,11 @@ function ConfiguredFootnote({ entry }: { entry: ServerCostOverview }) {
{formatCostRate(entry.cost_per_hour, entry.currency, 'h', { maximumFractionDigits: 4 })}
- {entry.value_score && (
+ {entry.cost_per_month_equivalent != null && (
<>
·
-
- {t(`cost_grade_${entry.value_score.grade}`)}
+
+ {formatCostRate(entry.cost_per_month_equivalent, entry.currency, 'mo', { maximumFractionDigits: 2 })}
>
)}
diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx
index 17ddd8a9..1501889b 100644
--- a/apps/web/src/components/server/server-card.test.tsx
+++ b/apps/web/src/components/server/server-card.test.tsx
@@ -7,6 +7,7 @@ import { CostFootnote } from './cost-footnote'
import { ServerCard } from './server-card'
const REGEX_COST_PER_HOUR = /0\.01\/h/
+const REGEX_COST_PER_MONTH = /7\.30\/mo/
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -133,15 +134,10 @@ describe('ServerCard', () => {
{
configured: true,
cost_per_hour: 0.01,
+ cost_per_month_equivalent: 7.3,
currency: 'USD',
name: 'test-server',
- server_id: 'srv-1',
- value_score: {
- confidence: 'high',
- grade: 'good',
- reasons: [],
- score: 82
- }
+ server_id: 'srv-1'
}
]
} satisfies CostOverviewResponse
@@ -150,8 +146,8 @@ describe('ServerCard', () => {
renderCard(makeServer())
expect(screen.getByText(REGEX_COST_PER_HOUR)).toBeDefined()
- expect(screen.getByText('cost_grade_good')).toBeDefined()
- expect(screen.queryByText('82')).toBeNull()
+ expect(screen.getByText(REGEX_COST_PER_MONTH)).toBeDefined()
+ expect(screen.queryByText('cost_grade_good')).toBeNull()
})
it('renders compact unconfigured cost footnote labels', () => {
From ba7592c2fc396cb7f6fc19cca6425e99b3329646 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Mon, 18 May 2026 00:38:13 +0800
Subject: [PATCH 40/43] refactor(web): reserve footer slots and align load
separator
Keep the days-left and cost grid slots occupied with invisible
placeholders when unset so the card footer height stays stable, and
render the load trend with the same dot separator spacing as the cost row.
---
apps/web/src/components/server/server-card.tsx | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx
index 230424d9..ab415d8e 100644
--- a/apps/web/src/components/server/server-card.tsx
+++ b/apps/web/src/components/server/server-card.tsx
@@ -300,8 +300,10 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
{t('card_load_trend')}
-
- {`${formatLoad(server.load5)}·${formatLoad(server.load15)}`}
+
+ {formatLoad(server.load5)}
+ ·
+ {formatLoad(server.load15)}
@@ -310,7 +312,11 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
{`${server.process_count} / ${server.tcp_conn} / ${server.udp_conn}`}
- {trafficDaysRemaining != null && (
+ {trafficDaysRemaining == null ? (
+
+ —
+
+ ) : (
{t('card_traffic_days_left_label')}
@@ -318,11 +324,15 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)}
- {costEntry && (
+ {costEntry?.configured ? (
{t('card_cost_label')}
+ ) : (
+
+ —
+
)}