diff --git a/apps/web/src/components/dashboard/widget-config-dialog.tsx b/apps/web/src/components/dashboard/widget-config-dialog.tsx index e65dc6f9d..dc056fdfe 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 ae2b37fda..fdc98ba79 100644 --- a/apps/web/src/components/dashboard/widgets/server-cards.tsx +++ b/apps/web/src/components/dashboard/widgets/server-cards.tsx @@ -1,32 +1,167 @@ -import { useMemo } from 'react' +import { getCoreRowModel, getSortedRowModel, type SortingState, useReactTable } from '@tanstack/react-table' +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' +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[] } +// 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 +// 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 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 }]) + 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} /> + ) +} + +// 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) + const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]) + + 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 revealed rows), + // measured by the grid to size the cell — never height-capped or scrolled. +
+ + {hasMore && } +
+ ) + } return ( -
- {filtered.map((server) => ( - - ))} - {filtered.length === 0 && ( -
- No servers to display -
- )} + // 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. +
+
+ {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 32a61c073..95244e4f7 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} +
+ )} ) } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c18c5fb81..5a3816faf 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; diff --git a/apps/web/src/lib/widget-types.ts b/apps/web/src/lib/widget-types.ts index 8be171939..f288583cc 100644 --- a/apps/web/src/lib/widget-types.ts +++ b/apps/web/src/lib/widget-types.ts @@ -52,7 +52,7 @@ export const WIDGET_TYPES = [ defaultH: 6, minW: 4, minH: 3, - sizing: { kind: 'free' } + sizing: { kind: 'content-height' } }, { id: 'gauge', @@ -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 041816509..acdb292cc 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 4c9e7d25d..1b9ed5c40 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/index.tsx b/apps/web/src/routes/_authed/index.tsx index a384bf438..9e10b37dd 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() 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 000000000..1479124d9 --- /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 f1e49f9ad..7a33e7daf 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] ) diff --git a/scripts/dev-demo.sh b/scripts/dev-demo.sh index 36cec93cf..50ce1ddd4 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 de741e861..f897bb241 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 2ac0c3f00..cb2838f79 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 }, {