Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions apps/web/src/components/dashboard/widget-config-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -24,6 +26,7 @@ import type {
NetworkOverviewConfig,
NetworkQualityConfig,
ServerCardsConfig,
ServerCardsLayout,
StatNumberConfig,
TopNConfig,
TrafficBarConfig,
Expand Down Expand Up @@ -483,14 +486,36 @@ function ServerCardsForm({
servers: ServerMetrics[]
t: (key: string) => string
}) {
const layout: ServerCardsLayout = config.layout ?? 'grid'
return (
<ServerMultiSelect
emptyMessage={t('widgets.common.empty.noServers')}
label={t('widgets.common.labels.servers')}
onChange={(ids) => onChange({ ...config, server_ids: ids })}
selected={config.server_ids ?? []}
servers={servers}
/>
<>
<div className="space-y-1.5">
<Label>{t('widgets.common.labels.layout')}</Label>
<ToggleGroup
className="w-full"
multiple={false}
onValueChange={(value) => value.length > 0 && onChange({ ...config, layout: value[0] as ServerCardsLayout })}
value={[layout]}
variant="outline"
>
<ToggleGroupItem className="flex-1" value="grid">
<LayoutGrid className="size-4" />
{t('widgets.common.labels.layoutGrid')}
</ToggleGroupItem>
<ToggleGroupItem className="flex-1" value="list">
<List className="size-4" />
{t('widgets.common.labels.layoutList')}
</ToggleGroupItem>
</ToggleGroup>
</div>
<ServerMultiSelect
emptyMessage={t('widgets.common.empty.noServers')}
label={t('widgets.common.labels.servers')}
onChange={(ids) => onChange({ ...config, server_ids: ids })}
selected={config.server_ids ?? []}
servers={servers}
/>
</>
)
}

Expand Down
165 changes: 150 additions & 15 deletions apps/web/src/components/dashboard/widgets/server-cards.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<SortingState>([{ 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 (
<DataTable hidePagination rowClassName={(row) => !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<HTMLDivElement | null> }) {
const { t } = useTranslation('common')
return (
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground text-sm" ref={sentinelRef}>
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
{t('loading')}
</div>
)
}

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.
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm" data-measure>
No servers to display
</div>
)
}

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.
<div data-measure>
<ServerListTable servers={visible} />
{hasMore && <LoadMoreSentinel sentinelRef={sentinelRef} />}
</div>
)
}

return (
<div
className="grid h-full content-start gap-4 overflow-auto"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))'
}}
>
{filtered.map((server) => (
<ServerCard key={server.id} server={server} />
))}
{filtered.length === 0 && (
<div className="col-span-full flex items-center justify-center py-8 text-muted-foreground text-sm">
No servers to display
</div>
)}
// 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.
<div data-measure>
<div
className="grid content-start gap-4"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}
>
{visible.map((server) => (
// content-visibility:auto skips layout/paint for off-screen cards;
// contain-intrinsic-size reserves their height so scrolling stays smooth.
<div className="[contain-intrinsic-size:auto_280px] [content-visibility:auto]" key={server.id}>
<ServerCard server={server} />
</div>
))}
</div>
{hasMore && <LoadMoreSentinel sentinelRef={sentinelRef} />}
</div>
)
}
14 changes: 10 additions & 4 deletions apps/web/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ interface DataTableProps<TData> 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<TData>) => string | false | undefined
table: TanstackTable<TData>
Expand All @@ -20,6 +23,7 @@ export function DataTable<TData>({
children,
className,
fillHeight = false,
hidePagination = false,
rowClassName,
...props
}: DataTableProps<TData>) {
Expand Down Expand Up @@ -86,10 +90,12 @@ export function DataTable<TData>({
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-2.5">
<DataTablePagination table={table} />
{actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar}
</div>
{!hidePagination && (
<div className="flex flex-col gap-2.5">
<DataTablePagination table={table} />
{actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar}
</div>
)}
</div>
)
}
10 changes: 10 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/widget-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const WIDGET_TYPES = [
defaultH: 6,
minW: 4,
minH: 3,
sizing: { kind: 'free' }
sizing: { kind: 'content-height' }
},
{
id: 'gauge',
Expand Down Expand Up @@ -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[]
}

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/locales/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/locales/zh/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@
"markdownContent": "Markdown 内容",
"days": "天数",
"titleOptional": "标题(可选)",
"labelOptional": "标签(可选)"
"labelOptional": "标签(可选)",
"layout": "布局",
"layoutGrid": "网格",
"layoutList": "列表"
},
"placeholders": {
"selectServer": "选择服务器",
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/routes/_authed/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,13 +15,14 @@ export function DashboardPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'

const { data: servers = [] } = useQuery<ServerMetrics[]>({
const { data: rawServers = [] } = useQuery<ServerMetrics[]>({
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()
Expand Down
Loading
Loading