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} - /> + <> +
+ + 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 }) => ( + + ), + 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 }) => ( - - ), - 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.
- + + {hasMore && ) } 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 ( +
+
+ ) +} + 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 && ) } @@ -147,7 +161,7 @@ export function ServerCardsWidget({ config, servers }: ServerCardsWidgetProps) {
))}
- {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;