From 4ddc3c620a0130a93b4accf17effbbe4ebb2f5cb Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sat, 30 May 2026 12:21:56 +0800
Subject: [PATCH 1/6] chore(scripts): use localhost instead of 127.0.0.1 in dev
scripts
---
scripts/dev-demo.sh | 4 ++--
scripts/dev-full.sh | 2 +-
scripts/make-menu.ts | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/scripts/dev-demo.sh b/scripts/dev-demo.sh
index 36cec93c..50ce1ddd 100755
--- a/scripts/dev-demo.sh
+++ b/scripts/dev-demo.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
-SERVER_URL="http://127.0.0.1:9527"
+SERVER_URL="http://localhost:9527"
ADMIN_USER="admin"
ADMIN_PASS="admin123"
SERVER_PID=""
@@ -38,7 +38,7 @@ fi
echo ""
echo "=========================================="
echo " Demo data is ready"
-echo " Web: http://127.0.0.1:5173"
+echo " Web: http://localhost:5173"
echo " API: ${SERVER_URL}"
echo " Login: ${ADMIN_USER} / ${ADMIN_PASS}"
echo " Database: data/dev-demo.db"
diff --git a/scripts/dev-full.sh b/scripts/dev-full.sh
index de741e86..f897bb24 100755
--- a/scripts/dev-full.sh
+++ b/scripts/dev-full.sh
@@ -4,7 +4,7 @@ set -euo pipefail
# Start server + web dev, and print agent startup command with a freshly minted one-time enrollment code.
ADMIN_PASS="admin123"
-SERVER_URL="http://127.0.0.1:9527"
+SERVER_URL="http://localhost:9527"
echo "Building web assets (required by rust-embed)..."
(cd apps/web && bun install --silent && bun run build)
diff --git a/scripts/make-menu.ts b/scripts/make-menu.ts
index 2ac0c3f0..cb2838f7 100644
--- a/scripts/make-menu.ts
+++ b/scripts/make-menu.ts
@@ -402,7 +402,7 @@ const COMMANDS: CommandDefinition[] = [
name: 'agent:dev',
category: 'Rust',
description: 'Run agent connecting to local server (set SERVERBEE_ENROLLMENT_CODE)',
- command: 'SERVERBEE_SERVER_URL=http://127.0.0.1:9527 cargo run -p serverbee-agent',
+ command: 'SERVERBEE_SERVER_URL=http://localhost:9527 cargo run -p serverbee-agent',
featured: true
},
{
From c8abaa32e0fc433a228fbd8585386eb790d0e79c Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sat, 30 May 2026 13:38:34 +0800
Subject: [PATCH 2/6] feat(web): size server-cards widget to its content height
Switch the server-cards widget from the fixed-height 'free' sizing strategy
to 'content-height' so the grid cell grows with the number of cards instead
of capping at a fixed height and scrolling. Drop the inner h-full/overflow-auto
and tag the grid container with data-measure so the dashboard grid measures the
natural content height.
---
apps/web/src/components/dashboard/widgets/server-cards.tsx | 6 +++++-
apps/web/src/lib/widget-types.ts | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/dashboard/widgets/server-cards.tsx b/apps/web/src/components/dashboard/widgets/server-cards.tsx
index ae2b37fd..908d2b91 100644
--- a/apps/web/src/components/dashboard/widgets/server-cards.tsx
+++ b/apps/web/src/components/dashboard/widgets/server-cards.tsx
@@ -13,8 +13,12 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
const filtered = useMemo(() => filterByIds(servers, config.server_ids, (s) => s.id), [servers, config.server_ids])
return (
+ // data-measure: natural content height (grows with the number of cards),
+ // measured by the grid to size the cell so the widget is never height-capped
+ // or scrolled — it always hugs exactly as many rows of cards as it renders.
Date: Sat, 30 May 2026 13:39:18 +0800
Subject: [PATCH 3/6] feat(web): add grid/list layout toggle to server-cards
widget
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a layout switch (grid/list) to the server-cards widget config dialog.
The new list layout reuses the servers page table verbatim — the column
definitions are extracted into a shared buildServerColumns() consumed by both
/servers?view=table and the widget — so the two render identically. The widget
builds the table with local (non-URL-synced) state, no pagination, and hides
the selection/actions/group/status-dot columns.
---
.../dashboard/widget-config-dialog.tsx | 39 +++-
.../dashboard/widgets/server-cards.tsx | 81 +++++++-
apps/web/src/lib/widget-types.ts | 3 +
apps/web/src/locales/en/dashboard.json | 5 +-
apps/web/src/locales/zh/dashboard.json | 5 +-
.../servers/components/server-columns.tsx | 192 ++++++++++++++++++
apps/web/src/routes/_authed/servers/index.tsx | 178 ++--------------
7 files changed, 323 insertions(+), 180 deletions(-)
create mode 100644 apps/web/src/routes/_authed/servers/components/server-columns.tsx
diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx
index e65dc6f9..dc056fdf 100644
--- a/apps/web/src/components/dashboard/widget-config-dialog.tsx
+++ b/apps/web/src/components/dashboard/widget-config-dialog.tsx
@@ -1,4 +1,5 @@
import { renderConfigForm } from '@serverbee/widget-sdk'
+import { LayoutGrid, List } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -7,6 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { ServerMetrics } from '@/hooks/use-servers-ws'
import { renderMarkdown } from '@/lib/markdown'
import { parseConfig } from '@/lib/widget-helpers'
@@ -24,6 +26,7 @@ import type {
NetworkOverviewConfig,
NetworkQualityConfig,
ServerCardsConfig,
+ ServerCardsLayout,
StatNumberConfig,
TopNConfig,
TrafficBarConfig,
@@ -483,14 +486,36 @@ function ServerCardsForm({
servers: ServerMetrics[]
t: (key: string) => string
}) {
+ const layout: ServerCardsLayout = config.layout ?? 'grid'
return (
-
onChange({ ...config, server_ids: ids })}
- selected={config.server_ids ?? []}
- servers={servers}
- />
+ <>
+
+ {t('widgets.common.labels.layout')}
+ value.length > 0 && onChange({ ...config, layout: value[0] as ServerCardsLayout })}
+ value={[layout]}
+ variant="outline"
+ >
+
+
+ {t('widgets.common.labels.layoutGrid')}
+
+
+
+ {t('widgets.common.labels.layoutList')}
+
+
+
+ onChange({ ...config, server_ids: ids })}
+ selected={config.server_ids ?? []}
+ servers={servers}
+ />
+ >
)
}
diff --git a/apps/web/src/components/dashboard/widgets/server-cards.tsx b/apps/web/src/components/dashboard/widgets/server-cards.tsx
index 908d2b91..f9cc7c49 100644
--- a/apps/web/src/components/dashboard/widgets/server-cards.tsx
+++ b/apps/web/src/components/dashboard/widgets/server-cards.tsx
@@ -1,17 +1,91 @@
-import { useMemo } from 'react'
+import { getCoreRowModel, getSortedRowModel, type SortingState, useReactTable } from '@tanstack/react-table'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { DataTable } from '@/components/data-table/data-table'
import { ServerCard } from '@/components/server/server-card'
+import { useCostOverview } from '@/hooks/use-cost'
import type { ServerMetrics } from '@/hooks/use-servers-ws'
+import { useTrafficOverview } from '@/hooks/use-traffic-overview'
import { filterByIds } from '@/lib/widget-helpers'
import type { ServerCardsConfig } from '@/lib/widget-types'
+import { buildServerColumns } from '@/routes/_authed/servers/components/server-columns'
interface ServerCardsWidgetProps {
config: ServerCardsConfig
servers: ServerMetrics[]
}
+// Columns the list layout hides relative to the full servers page: selection and
+// per-row edit actions are page-only interactions, and group/status-dot are
+// hidden on the servers page by default too. The remaining data columns render
+// identically to /servers?view=table.
+const HIDDEN_LIST_COLUMNS = { select: false, 'status-dot': false, group: false, actions: false }
+
+// Reuses the exact servers-page table (DataTable + shared columns) so the list
+// layout is pixel-identical to /servers?view=table. State is kept local (no URL
+// sync) and pagination is omitted — the widget grows to fit all rows.
+function ServerListTable({ servers }: { servers: ServerMetrics[] }) {
+ const { t } = useTranslation(['servers'])
+ const [sorting, setSorting] = useState([{ id: 'name', desc: false }])
+ const { data: trafficOverview = [] } = useTrafficOverview()
+ const { data: costOverview } = useCostOverview()
+
+ const costByServerId = useMemo(() => {
+ const entries = costOverview?.servers ?? []
+ return new Map(entries.map((entry) => [entry.server_id, entry]))
+ }, [costOverview])
+
+ const columns = useMemo(
+ () =>
+ buildServerColumns({
+ t,
+ costByServerId,
+ groupMap: new Map(),
+ groupOptions: [],
+ statusOptions: [],
+ selectMode: false,
+ onEdit: () => undefined,
+ trafficOverview
+ }),
+ [t, costByServerId, trafficOverview]
+ )
+
+ const table = useReactTable({
+ data: servers,
+ columns,
+ state: { sorting, columnVisibility: HIDDEN_LIST_COLUMNS },
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getRowId: (row) => row.id
+ })
+
+ return !row.original.online && 'opacity-45 grayscale'} table={table} />
+}
+
export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
const filtered = useMemo(() => filterByIds(servers, config.server_ids, (s) => s.id), [servers, config.server_ids])
+ if (filtered.length === 0) {
+ return (
+ // data-measure: empty-state height is measured the same as the populated
+ // layouts so the grid cell shrinks to fit instead of leaving dead space.
+
+ No servers to display
+
+ )
+ }
+
+ if (config.layout === 'list') {
+ return (
+ // data-measure: natural content height (grows with the number of rows),
+ // measured by the grid to size the cell — never height-capped or scrolled.
+
+
+
+ )
+ }
+
return (
// data-measure: natural content height (grows with the number of cards),
// measured by the grid to size the cell so the widget is never height-capped
@@ -26,11 +100,6 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
{filtered.map((server) => (
))}
- {filtered.length === 0 && (
-
- No servers to display
-
- )}
)
}
diff --git a/apps/web/src/lib/widget-types.ts b/apps/web/src/lib/widget-types.ts
index b9c2b443..f288583c 100644
--- a/apps/web/src/lib/widget-types.ts
+++ b/apps/web/src/lib/widget-types.ts
@@ -241,8 +241,11 @@ export interface MetricCardConfig {
server_id: string
}
+export type ServerCardsLayout = 'grid' | 'list'
+
export interface ServerCardsConfig {
columns?: number
+ layout?: ServerCardsLayout
server_ids?: string[]
}
diff --git a/apps/web/src/locales/en/dashboard.json b/apps/web/src/locales/en/dashboard.json
index 04181650..acdb292c 100644
--- a/apps/web/src/locales/en/dashboard.json
+++ b/apps/web/src/locales/en/dashboard.json
@@ -185,7 +185,10 @@
"markdownContent": "Markdown Content",
"days": "Days",
"titleOptional": "Title (optional)",
- "labelOptional": "Label (optional)"
+ "labelOptional": "Label (optional)",
+ "layout": "Layout",
+ "layoutGrid": "Grid",
+ "layoutList": "List"
},
"placeholders": {
"selectServer": "Select server",
diff --git a/apps/web/src/locales/zh/dashboard.json b/apps/web/src/locales/zh/dashboard.json
index 4c9e7d25..1b9ed5c4 100644
--- a/apps/web/src/locales/zh/dashboard.json
+++ b/apps/web/src/locales/zh/dashboard.json
@@ -185,7 +185,10 @@
"markdownContent": "Markdown 内容",
"days": "天数",
"titleOptional": "标题(可选)",
- "labelOptional": "标签(可选)"
+ "labelOptional": "标签(可选)",
+ "layout": "布局",
+ "layoutGrid": "网格",
+ "layoutList": "列表"
},
"placeholders": {
"selectServer": "选择服务器",
diff --git a/apps/web/src/routes/_authed/servers/components/server-columns.tsx b/apps/web/src/routes/_authed/servers/components/server-columns.tsx
new file mode 100644
index 00000000..1479124d
--- /dev/null
+++ b/apps/web/src/routes/_authed/servers/components/server-columns.tsx
@@ -0,0 +1,192 @@
+import type { ColumnDef } from '@tanstack/react-table'
+import { CircleDot, ExternalLink, Tag } from 'lucide-react'
+import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header'
+import { CostCell } from '@/components/server/cost-cell'
+import { deriveServerStatus, StatusDot } from '@/components/server/status-dot'
+import { UpgradeJobBadge } from '@/components/server/upgrade-job-badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import type { ServerMetrics } from '@/hooks/use-servers-ws'
+import type { TrafficOverviewItem } from '@/hooks/use-traffic-overview'
+import type { ServerCostOverview } from '@/lib/api-schema'
+import { cn } from '@/lib/utils'
+import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store'
+import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from './index-cells'
+
+export function UpgradeBadgeCell({ serverId }: { serverId: string }) {
+ const job = useUpgradeJobsStore((state) => state.jobs.get(serverId))
+ return
+}
+
+const arrayIncludesFilter = (row: { getValue: (id: string) => unknown }, id: string, value: unknown) => {
+ if (!Array.isArray(value) || value.length === 0) {
+ return true
+ }
+ return value.includes(String(row.getValue(id) ?? ''))
+}
+
+interface ServerColumnsOptions {
+ costByServerId: Map
+ groupMap: Map
+ groupOptions: { label: string; value: string }[]
+ onEdit: (id: string) => void
+ selectMode: boolean
+ statusOptions: { label: string; value: string }[]
+ t: (key: string) => string
+ trafficOverview: TrafficOverviewItem[]
+}
+
+// Shared column definitions for the servers table. Used by both the dedicated
+// servers page (full interactivity) and the dashboard server-cards widget in
+// list layout, so the two stay pixel-identical.
+export function buildServerColumns({
+ t,
+ costByServerId,
+ groupMap,
+ groupOptions,
+ statusOptions,
+ selectMode,
+ onEdit,
+ trafficOverview
+}: ServerColumnsOptions): ColumnDef[] {
+ return [
+ {
+ id: 'select',
+ enableSorting: false,
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!checked)}
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!checked)}
+ />
+ ),
+ minSize: 0,
+ size: selectMode ? 36 : 0,
+ meta: {
+ className: cn('overflow-hidden transition-[width,padding] duration-200', !selectMode && 'px-0!')
+ }
+ },
+ {
+ id: 'status-dot',
+ accessorFn: (row) => (row.online ? 'online' : 'offline'),
+ enableSorting: false,
+ header: () => null,
+ cell: ({ row }) => ,
+ filterFn: arrayIncludesFilter,
+ enableColumnFilter: true,
+ size: 36,
+ meta: {
+ className: 'w-9',
+ label: t('col_status'),
+ variant: 'select',
+ options: statusOptions,
+ icon: CircleDot
+ }
+ },
+ {
+ accessorKey: 'name',
+ id: 'name',
+ header: ({ column }) => ,
+ cell: ({ row }) => } server={row.original} />,
+ size: 240,
+ meta: { className: 'min-w-[200px]', label: t('col_name') }
+ },
+ {
+ accessorKey: 'cpu',
+ id: 'cpu',
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ size: 180,
+ meta: { className: 'w-[180px]', cellClassName: 'align-top', label: t('col_cpu') }
+ },
+ {
+ accessorFn: (row) => (row.mem_total > 0 ? row.mem_used / row.mem_total : 0),
+ id: 'memory',
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ size: 180,
+ meta: { className: 'w-[180px]', cellClassName: 'align-top', label: t('col_memory') }
+ },
+ {
+ accessorFn: (row) => (row.disk_total > 0 ? row.disk_used / row.disk_total : 0),
+ id: 'disk',
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ size: 184,
+ meta: { className: 'w-[184px]', cellClassName: 'align-top', label: t('col_disk') }
+ },
+ {
+ id: 'network',
+ enableSorting: false,
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const entry = trafficOverview.find((e) => e.server_id === row.original.id)
+ return
+ },
+ size: 184,
+ meta: { className: 'hidden lg:table-cell lg:w-[184px]', cellClassName: 'lg:align-top', label: t('col_network') }
+ },
+ {
+ id: 'cost',
+ accessorFn: (row) => {
+ const entry = costByServerId.get(row.id)
+ return entry?.cost_per_month_equivalent ?? entry?.cost_per_day ?? -1
+ },
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ size: 172,
+ meta: { className: 'hidden xl:table-cell xl:w-[172px]', cellClassName: 'xl:align-top', label: t('col_cost') }
+ },
+ {
+ accessorKey: 'uptime',
+ id: 'uptime',
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ size: 196,
+ meta: { className: 'hidden xl:table-cell xl:w-[196px]', label: t('col_uptime') }
+ },
+ {
+ id: 'group',
+ accessorFn: (row) => row.group_id ?? '',
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const s = row.original
+ return (
+ {s.group_id ? (groupMap.get(s.group_id) ?? '-') : '-'}
+ )
+ },
+ filterFn: arrayIncludesFilter,
+ enableColumnFilter: true,
+ size: 140,
+ meta: {
+ className: 'hidden xl:table-cell xl:w-[140px]',
+ label: t('col_group'),
+ variant: 'multiSelect',
+ options: groupOptions,
+ icon: Tag
+ }
+ },
+ {
+ id: 'actions',
+ enableSorting: false,
+ cell: ({ row }) => (
+ onEdit(row.original.id)}
+ type="button"
+ >
+
+
+ ),
+ size: 40,
+ meta: { className: 'w-10' }
+ }
+ ]
+}
diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx
index f1e49f9a..7a33e7da 100644
--- a/apps/web/src/routes/_authed/servers/index.tsx
+++ b/apps/web/src/routes/_authed/servers/index.tsx
@@ -1,19 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
-import { CircleDot, ExternalLink, LayoutGrid, ListChecks, Plus, Search, Table2, Tag, Trash2 } from 'lucide-react'
+import { LayoutGrid, ListChecks, Plus, Search, Table2, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTable } from '@/components/data-table/data-table'
-import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { AddServerDialog } from '@/components/server/add-server-dialog'
-import { CostCell } from '@/components/server/cost-cell'
import { ServerCard } from '@/components/server/server-card'
import { ServerEditDialog } from '@/components/server/server-edit-dialog'
-import { deriveServerStatus, StatusDot } from '@/components/server/status-dot'
-import { UpgradeJobBadge } from '@/components/server/upgrade-job-badge'
import {
AlertDialog,
AlertDialogAction,
@@ -26,7 +22,6 @@ import {
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
@@ -43,14 +38,8 @@ import type { ServerGroup, ServerResponse } from '@/lib/api-schema'
import { withMockServers } from '@/lib/dev-mock-servers'
import { countCleanupCandidates } from '@/lib/orphan-server-utils'
import { cn } from '@/lib/utils'
-import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store'
-import { CpuCell, DiskCell, MemoryCell, NameCell, NetworkCell, UptimeCell } from './components/index-cells'
import { getInitialServersView } from './components/mobile-view'
-
-function UpgradeBadgeCell({ serverId }: { serverId: string }) {
- const job = useUpgradeJobsStore((state) => state.jobs.get(serverId))
- return
-}
+import { buildServerColumns } from './components/server-columns'
export const Route = createFileRoute('/_authed/servers/')({
component: ServersListPage,
@@ -61,13 +50,6 @@ export const Route = createFileRoute('/_authed/servers/')({
})
})
-const arrayIncludesFilter = (row: { getValue: (id: string) => unknown }, id: string, value: unknown) => {
- if (!Array.isArray(value) || value.length === 0) {
- return true
- }
- return value.includes(String(row.getValue(id) ?? ''))
-}
-
function ServersListPage() {
const { t } = useTranslation(['servers', 'common'])
const queryClient = useQueryClient()
@@ -160,151 +142,17 @@ function ServersListPage() {
)
const columns = useMemo[]>(
- () => [
- {
- id: 'select',
- enableSorting: false,
- header: ({ table }) => (
- table.toggleAllPageRowsSelected(!!checked)}
- />
- ),
- cell: ({ row }) => (
- row.toggleSelected(!!checked)}
- />
- ),
- minSize: 0,
- size: selectMode ? 36 : 0,
- meta: {
- className: cn('overflow-hidden transition-[width,padding] duration-200', !selectMode && 'px-0!')
- }
- },
- {
- id: 'status-dot',
- accessorFn: (row) => (row.online ? 'online' : 'offline'),
- enableSorting: false,
- header: () => null,
- cell: ({ row }) => ,
- filterFn: arrayIncludesFilter,
- enableColumnFilter: true,
- size: 36,
- meta: {
- className: 'w-9',
- label: t('col_status'),
- variant: 'select',
- options: statusOptions,
- icon: CircleDot
- }
- },
- {
- accessorKey: 'name',
- id: 'name',
- header: ({ column }) => ,
- cell: ({ row }) => (
- } server={row.original} />
- ),
- size: 240,
- meta: { className: 'min-w-[200px]', label: t('col_name') }
- },
- {
- accessorKey: 'cpu',
- id: 'cpu',
- header: ({ column }) => ,
- cell: ({ row }) => ,
- size: 180,
- meta: { className: 'w-[180px]', cellClassName: 'align-top', label: t('col_cpu') }
- },
- {
- accessorFn: (row) => (row.mem_total > 0 ? row.mem_used / row.mem_total : 0),
- id: 'memory',
- header: ({ column }) => ,
- cell: ({ row }) => ,
- size: 180,
- meta: { className: 'w-[180px]', cellClassName: 'align-top', label: t('col_memory') }
- },
- {
- accessorFn: (row) => (row.disk_total > 0 ? row.disk_used / row.disk_total : 0),
- id: 'disk',
- header: ({ column }) => ,
- cell: ({ row }) => ,
- size: 184,
- meta: { className: 'w-[184px]', cellClassName: 'align-top', label: t('col_disk') }
- },
- {
- id: 'network',
- enableSorting: false,
- header: ({ column }) => ,
- cell: ({ row }) => {
- const entry = trafficOverview.find((e) => e.server_id === row.original.id)
- return
- },
- size: 184,
- meta: { className: 'hidden lg:table-cell lg:w-[184px]', cellClassName: 'lg:align-top', label: t('col_network') }
- },
- {
- id: 'cost',
- accessorFn: (row) => {
- const entry = costByServerId.get(row.id)
- return entry?.cost_per_month_equivalent ?? entry?.cost_per_day ?? -1
- },
- header: ({ column }) => ,
- cell: ({ row }) => ,
- size: 172,
- meta: { className: 'hidden xl:table-cell xl:w-[172px]', cellClassName: 'xl:align-top', label: t('col_cost') }
- },
- {
- accessorKey: 'uptime',
- id: 'uptime',
- header: ({ column }) => ,
- cell: ({ row }) => ,
- size: 196,
- meta: { className: 'hidden xl:table-cell xl:w-[196px]', label: t('col_uptime') }
- },
- {
- id: 'group',
- accessorFn: (row) => row.group_id ?? '',
- header: ({ column }) => ,
- cell: ({ row }) => {
- const s = row.original
- return (
-
- {s.group_id ? (groupMap.get(s.group_id) ?? '-') : '-'}
-
- )
- },
- filterFn: arrayIncludesFilter,
- enableColumnFilter: true,
- size: 140,
- meta: {
- className: 'hidden xl:table-cell xl:w-[140px]',
- label: t('col_group'),
- variant: 'multiSelect',
- options: groupOptions,
- icon: Tag
- }
- },
- {
- id: 'actions',
- enableSorting: false,
- cell: ({ row }) => (
- setEditingId(row.original.id)}
- type="button"
- >
-
-
- ),
- size: 40,
- meta: { className: 'w-10' }
- }
- ],
+ () =>
+ buildServerColumns({
+ t,
+ costByServerId,
+ groupMap,
+ groupOptions,
+ statusOptions,
+ selectMode,
+ onEdit: setEditingId,
+ trafficOverview
+ }),
[t, costByServerId, groupMap, groupOptions, statusOptions, trafficOverview, selectMode]
)
From 1e967780025d00b1962e3a364ea27282ff3e8cb0 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sat, 30 May 2026 14:10:46 +0800
Subject: [PATCH 4/6] feat(web): reveal server-cards rows on scroll instead of
paginating
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The list layout reused the servers DataTable, which surfaced a page-level
pagination footer inside the widget — wrong for a content-sized dashboard
tile (the footer also mis-reported pages since rows were never paginated).
Add a hidePagination prop to DataTable and use it here.
Replace pagination with incremental reveal: render the first 50 servers and
load the next batch as the dashboard scrolls near the widget bottom (page-scroll
driven via IntersectionObserver, compatible with the content-height sizing — no
inner scroll container). The grid layout gets the same batching plus
content-visibility:auto so off-screen cards skip layout/paint. Applies to both
grid and list layouts.
---
.../dashboard/widgets/server-cards.tsx | 80 +++++++++++++++----
.../src/components/data-table/data-table.tsx | 14 +++-
2 files changed, 74 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/dashboard/widgets/server-cards.tsx b/apps/web/src/components/dashboard/widgets/server-cards.tsx
index f9cc7c49..842b3a36 100644
--- a/apps/web/src/components/dashboard/widgets/server-cards.tsx
+++ b/apps/web/src/components/dashboard/widgets/server-cards.tsx
@@ -1,5 +1,5 @@
import { getCoreRowModel, getSortedRowModel, type SortingState, useReactTable } from '@tanstack/react-table'
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DataTable } from '@/components/data-table/data-table'
import { ServerCard } from '@/components/server/server-card'
@@ -15,6 +15,45 @@ interface ServerCardsWidgetProps {
servers: ServerMetrics[]
}
+// Soft cap on the first render. The widget grows to fit its content (no inner
+// scroll), so rendering hundreds of cards/rows up front would jank. Instead we
+// reveal the first batch and load the next as the user scrolls the dashboard
+// near the widget's bottom (page-scroll driven, compatible with content-height).
+const REVEAL_STEP = 50
+
+// Reveals `step` items at a time, loading the next batch when the sentinel
+// scrolls into view (page-scroll driven, no inner scroll container needed).
+function useIncrementalReveal(total: number, step = REVEAL_STEP) {
+ const [count, setCount] = useState(step)
+ const sentinelRef = useRef(null)
+ const visibleCount = Math.min(count, total)
+ const hasMore = visibleCount < total
+
+ // Re-subscribe on every batch: re-observing re-fires the initial notification
+ // if the sentinel is still on-screen (IntersectionObserver won't re-notify a
+ // sentinel that stays intersecting), so short lists keep filling until the
+ // sentinel is pushed off-screen or everything is revealed.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: visibleCount is an intentional re-subscribe trigger, not used in the body
+ useEffect(() => {
+ const el = sentinelRef.current
+ if (!hasMore || el === null || typeof IntersectionObserver === 'undefined') {
+ return
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setCount((c) => c + step)
+ }
+ },
+ { rootMargin: '300px' }
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [hasMore, step, visibleCount])
+
+ return { visibleCount, hasMore, sentinelRef }
+}
+
// Columns the list layout hides relative to the full servers page: selection and
// per-row edit actions are page-only interactions, and group/status-dot are
// hidden on the servers page by default too. The remaining data columns render
@@ -23,7 +62,7 @@ const HIDDEN_LIST_COLUMNS = { select: false, 'status-dot': false, group: false,
// Reuses the exact servers-page table (DataTable + shared columns) so the list
// layout is pixel-identical to /servers?view=table. State is kept local (no URL
-// sync) and pagination is omitted — the widget grows to fit all rows.
+// sync) and the pagination footer is hidden — the widget reveals rows on scroll.
function ServerListTable({ servers }: { servers: ServerMetrics[] }) {
const { t } = useTranslation(['servers'])
const [sorting, setSorting] = useState([{ id: 'name', desc: false }])
@@ -60,11 +99,15 @@ function ServerListTable({ servers }: { servers: ServerMetrics[] }) {
getRowId: (row) => row.id
})
- return !row.original.online && 'opacity-45 grayscale'} table={table} />
+ return (
+ !row.original.online && 'opacity-45 grayscale'} table={table} />
+ )
}
export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
const filtered = useMemo(() => filterByIds(servers, config.server_ids, (s) => s.id), [servers, config.server_ids])
+ const { visibleCount, hasMore, sentinelRef } = useIncrementalReveal(filtered.length)
+ const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount])
if (filtered.length === 0) {
return (
@@ -78,28 +121,33 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
if (config.layout === 'list') {
return (
- // data-measure: natural content height (grows with the number of rows),
+ // data-measure: natural content height (grows with the revealed rows),
// measured by the grid to size the cell — never height-capped or scrolled.
)
}
return (
- // data-measure: natural content height (grows with the number of cards),
+ // data-measure: natural content height (grows with the revealed cards),
// measured by the grid to size the cell so the widget is never height-capped
// or scrolled — it always hugs exactly as many rows of cards as it renders.
-
- {filtered.map((server) => (
-
- ))}
+
+
+ {visible.map((server) => (
+ // content-visibility:auto skips layout/paint for off-screen cards;
+ // contain-intrinsic-size reserves their height so scrolling stays smooth.
+
+
+
+ ))}
+
+ {hasMore &&
}
)
}
diff --git a/apps/web/src/components/data-table/data-table.tsx b/apps/web/src/components/data-table/data-table.tsx
index 32a61c07..95244e4f 100644
--- a/apps/web/src/components/data-table/data-table.tsx
+++ b/apps/web/src/components/data-table/data-table.tsx
@@ -9,6 +9,9 @@ interface DataTableProps
extends React.ComponentProps<'div'> {
actionBar?: React.ReactNode
/** Fill remaining vertical space with a sticky header and internally scrollable body. */
fillHeight?: boolean
+ /** Hide the pagination/action-bar footer entirely. Use when the table renders
+ * all rows (e.g. embedded in a content-sized dashboard widget). */
+ hidePagination?: boolean
/** Optional per-row className, e.g. to dim disabled/offline rows. */
rowClassName?: (row: Row) => string | false | undefined
table: TanstackTable
@@ -20,6 +23,7 @@ export function DataTable({
children,
className,
fillHeight = false,
+ hidePagination = false,
rowClassName,
...props
}: DataTableProps) {
@@ -86,10 +90,12 @@ export function DataTable({
-
-
- {actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar}
-
+ {!hidePagination && (
+
+
+ {actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar}
+
+ )}
)
}
From 261c5a523e9603e2e5aaea1328857952eedee8b4 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sat, 30 May 2026 14:24:19 +0800
Subject: [PATCH 5/6] feat(web): show load-more spinner on dashboard
server-cards widget
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the bare scroll sentinel with a spinner + localized loading label so
both grid and list layouts show a loading… indicator at the bottom while more
servers reveal on scroll. Inject DEV-only mock servers into the dashboard route
to exercise the incremental reveal at scale.
---
.../dashboard/widgets/server-cards.tsx | 20 ++++++++++++++++---
apps/web/src/routes/_authed/index.tsx | 6 ++++--
2 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/dashboard/widgets/server-cards.tsx b/apps/web/src/components/dashboard/widgets/server-cards.tsx
index 842b3a36..fdc98ba7 100644
--- a/apps/web/src/components/dashboard/widgets/server-cards.tsx
+++ b/apps/web/src/components/dashboard/widgets/server-cards.tsx
@@ -1,5 +1,6 @@
import { getCoreRowModel, getSortedRowModel, type SortingState, useReactTable } from '@tanstack/react-table'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { Loader2 } from 'lucide-react'
+import { type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DataTable } from '@/components/data-table/data-table'
import { ServerCard } from '@/components/server/server-card'
@@ -104,6 +105,19 @@ function ServerListTable({ servers }: { servers: ServerMetrics[] }) {
)
}
+// The sentinel doubles as the load-more indicator: it sits at the bottom of the
+// table/grid, triggers the next batch when scrolled into view, and shows a
+// spinner while more rows remain. It unmounts once everything is revealed.
+function LoadMoreSentinel({ sentinelRef }: { sentinelRef: RefObject }) {
+ const { t } = useTranslation('common')
+ return (
+
+
+ {t('loading')}
+
+ )
+}
+
export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
const filtered = useMemo(() => filterByIds(servers, config.server_ids, (s) => s.id), [servers, config.server_ids])
const { visibleCount, hasMore, sentinelRef } = useIncrementalReveal(filtered.length)
@@ -125,7 +139,7 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
// measured by the grid to size the cell — never height-capped or scrolled.
- {hasMore &&
}
+ {hasMore &&
}
)
}
@@ -147,7 +161,7 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
))}
- {hasMore &&
}
+ {hasMore && }
)
}
diff --git a/apps/web/src/routes/_authed/index.tsx b/apps/web/src/routes/_authed/index.tsx
index a384bf43..9e10b37d 100644
--- a/apps/web/src/routes/_authed/index.tsx
+++ b/apps/web/src/routes/_authed/index.tsx
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import { DashboardEditorView } from '@/components/dashboard/dashboard-editor-view'
import { useAuth } from '@/hooks/use-auth'
import { useDashboard, useDashboards, useDefaultDashboard, useUpdateDashboard } from '@/hooks/use-dashboard'
import type { ServerMetrics } from '@/hooks/use-servers-ws'
+import { withMockServers } from '@/lib/dev-mock-servers'
export const Route = createFileRoute('/_authed/')({
component: DashboardPage
@@ -14,13 +15,14 @@ export function DashboardPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
- const { data: servers = [] } = useQuery({
+ const { data: rawServers = [] } = useQuery({
queryKey: ['servers'],
queryFn: () => [],
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
refetchOnWindowFocus: false
})
+ const servers = useMemo(() => withMockServers(rawServers), [rawServers])
const { data: dashboards = [] } = useDashboards()
const { data: defaultDashboard } = useDefaultDashboard()
From dfa8b74fe17c52ab34908c0d4f54c7782c9fc972 Mon Sep 17 00:00:00 2001
From: ZingerLittleBee <6970999@gmail.com>
Date: Sat, 30 May 2026 15:10:51 +0800
Subject: [PATCH 6/6] fix(web): apply content-height widget growth instantly to
stop overlap
Dashboard grid items carry react-grid-layout's default 0.2s transform/height
transition. When a content-height widget grows (e.g. the server-cards widget
revealing more rows on scroll), its height and the translateY of the items below
animate on slightly different timelines, so the growing widget briefly overlaps
the widget beneath it. Disable the transition outside edit mode so content-driven
height changes apply in a single frame; keep it while editing for drag/resize
reflow.
---
apps/web/src/index.css | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index c18c5fb8..5a3816fa 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -144,6 +144,16 @@
opacity: 0.15 !important;
}
+/* Content-driven height changes (live data, scroll-loading more rows) must apply
+ instantly. RGL's default 0.2s transform/height transition animates the growing
+ content-height widget and the items below it on slightly different timelines,
+ so the growing widget briefly overlaps the widget beneath it. Keep the
+ transition only while editing, where it smooths the drag/resize reflow.
+ !important overrides react-grid-layout/css/styles.css loaded later. */
+.dashboard-grid:not(.dashboard-grid--editing) > .react-grid-item {
+ transition: none !important;
+}
+
/* Dashboard resize handle overrides the library's hard-coded dark marker */
.dashboard-grid .dashboard-resize-handle::after {
content: none !important;