Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
dab4584
docs: spec for servers table density and disk i/o display
ZingerLittleBee Apr 15, 2026
7113d2b
docs: address servers table density spec review feedback
ZingerLittleBee Apr 15, 2026
3b6b016
docs: implementation plan for servers table density
ZingerLittleBee Apr 15, 2026
d0c0e1e
fix(web): align dashboard default star icons
ZingerLittleBee Apr 15, 2026
cda96bf
refactor(web): tighten disk i/o fields to non-optional on ServerMetrics
ZingerLittleBee Apr 15, 2026
a3b51b8
refactor(web): remove stale disk i/o fallbacks from server card
ZingerLittleBee Apr 15, 2026
825f606
refactor(web): extract MiniBar to index.cells.tsx and accept ReactNod…
ZingerLittleBee Apr 15, 2026
d1387c3
fix(web): organize servers index imports for ultracite
ZingerLittleBee Apr 15, 2026
0887790
feat(web): add CpuCell with load1 sub-line
ZingerLittleBee Apr 15, 2026
18c9c4c
fix(web): hoist cpu cell test regex for ultracite
ZingerLittleBee Apr 15, 2026
fb0f5fd
feat(web): add MemoryCell showing used/total
ZingerLittleBee Apr 15, 2026
4bc1d86
feat(web): add DiskCell with online-gated i/o row
ZingerLittleBee Apr 15, 2026
cf11bb7
fix(web): harden disk cell io row rendering
ZingerLittleBee Apr 15, 2026
3ebc292
feat(web): add NetworkCell with offline zeroing and cumulative row
ZingerLittleBee Apr 15, 2026
4e2504b
docs(superpowers): spec for servers table row visual redesign
ZingerLittleBee Apr 16, 2026
5980f5e
docs(superpowers): address spec review feedback for servers table row…
ZingerLittleBee Apr 16, 2026
72f94a9
docs(superpowers): second-round spec review fixes for servers table r…
ZingerLittleBee Apr 16, 2026
702bafa
docs(superpowers): implementation plan for servers table row visual r…
ZingerLittleBee Apr 16, 2026
bca52c4
docs(superpowers): split large chunk into cells vs page-wiring chunks
ZingerLittleBee Apr 16, 2026
3700922
docs(superpowers): address plan review feedback across all 5 chunks
ZingerLittleBee Apr 16, 2026
bc03d99
feat(common): extend ServerStatus with tags and cpu_cores
ZingerLittleBee Apr 17, 2026
940744a
feat(web): add i18n keys for servers table tags and uptime labels
ZingerLittleBee Apr 17, 2026
8e95aec
feat(web): add shared traffic quota helper
ZingerLittleBee Apr 17, 2026
644dce5
refactor(web): ServerCard consumes shared computeTrafficQuota helper
ZingerLittleBee Apr 17, 2026
755abf2
feat(server): include tags and cpu_cores in ServerStatus full_sync
ZingerLittleBee Apr 17, 2026
b1647bd
feat(web): guard static array fields in ServerMetrics merge
ZingerLittleBee Apr 17, 2026
809232b
feat(server): add server_tag service with validation
ZingerLittleBee Apr 17, 2026
a848e60
feat(server): add /api/servers/:id/tags read/write routes
ZingerLittleBee Apr 17, 2026
c01a845
test(server): cover tags CRUD + RBAC and full_sync payload inclusion
ZingerLittleBee Apr 17, 2026
fad3901
feat(web): add StatusDot pulsing indicator
ZingerLittleBee Apr 17, 2026
3c6e54f
feat(web): useServerTags + useUpdateServerTags with optimistic cache
ZingerLittleBee Apr 17, 2026
a74ad0a
feat(web): add TagChipRow with stable-hash palette
ZingerLittleBee Apr 17, 2026
b1e1776
feat(web): ServerEditDialog tags editor with sequential save
ZingerLittleBee Apr 17, 2026
bce24ef
refactor(web): introduce MetricBarRow primitive in servers cells
ZingerLittleBee Apr 17, 2026
fdb76b8
feat(web): CpuCell shows cores + load with Phase A fallback
ZingerLittleBee Apr 17, 2026
f0acbb9
feat(web): MemoryCell shows used/total + swap pct
ZingerLittleBee Apr 17, 2026
17b24e4
feat(web): DiskCell shows usage + disk I/O with lucide icons
ZingerLittleBee Apr 17, 2026
8d58d5c
feat(web): NetworkCell shows traffic quota bar + live speeds
ZingerLittleBee Apr 17, 2026
e86ad47
feat(web): UptimeCell and NameCell with tags support
ZingerLittleBee Apr 17, 2026
d227c13
feat(web): servers table adopts new cells with status-dot column
ZingerLittleBee Apr 17, 2026
452829e
style(web): satisfy ultracite lint on new servers-table primitives
ZingerLittleBee Apr 17, 2026
9c84b79
test(servers): manual QA checklist for table row redesign
ZingerLittleBee Apr 17, 2026
2c84227
Merge remote-tracking branch 'origin/main' into little-rock
ZingerLittleBee Apr 17, 2026
effa530
fix(web): add disk I/O fields to test factories for strict tsc build
ZingerLittleBee Apr 17, 2026
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
22 changes: 18 additions & 4 deletions apps/web/src/components/dashboard/dashboard-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,23 @@ export function DashboardSwitcher({ dashboards, currentId, onSelect, isAdmin }:
<SelectContent>
{dashboards.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.is_default && <Star className="mr-1 inline size-3 text-amber-500" />}
{d.name}
<span className="flex items-center gap-1.5">
{d.is_default && <Star className="size-3 shrink-0 text-amber-500" />}
<span>{d.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>

{isAdmin && !isDefault && (
<Button onClick={handleSetDefault} size="sm" title={t('set_default')} variant="ghost">
<Button
aria-label={t('set_default')}
onClick={handleSetDefault}
size="icon-sm"
title={t('set_default')}
variant="ghost"
>
<Star className="size-4" />
</Button>
)}
Expand All @@ -125,7 +133,13 @@ export function DashboardSwitcher({ dashboards, currentId, onSelect, isAdmin }:
)}

{isAdmin && !isDefault && (
<Button onClick={() => setDeleteDialogOpen(true)} size="sm" variant="ghost">
<Button
aria-label={t('delete_dashboard')}
onClick={() => setDeleteDialogOpen(true)}
size="icon-sm"
title={t('delete_dashboard')}
variant="ghost"
>
<TrashIcon className="size-4 text-destructive" />
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function makeServer(id: string, overrides: Partial<ServerMetrics> = {}): ServerM
swap_total: 0,
disk_used: 20_000_000_000,
disk_total: 40_000_000_000,
disk_read_bytes_per_sec: 0,
disk_write_bytes_per_sec: 0,
net_in_speed: 1024,
net_out_speed: 2048,
net_in_transfer: 1,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/server/server-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ function makeServer(overrides: Partial<Parameters<typeof ServerCard>[0]['server'
mem_total: 8_589_934_592,
disk_used: 21_474_836_480,
disk_total: 53_687_091_200,
disk_read_bytes_per_sec: 0,
disk_write_bytes_per_sec: 0,
swap_used: 536_870_912,
swap_total: 2_147_483_648,
load1: 0.72,
Expand Down
25 changes: 12 additions & 13 deletions apps/web/src/components/server/server-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ServerMetrics } from '@/hooks/use-servers-ws'
import { useTrafficOverview } from '@/hooks/use-traffic-overview'
import { getLatencyBarColor, isLatencyFailure } from '@/lib/network-latency-constants'
import { latencyColorClass } from '@/lib/network-types'
import { computeTrafficQuota } from '@/lib/traffic'
import { countryCodeToFlag, formatBytes, formatSpeed, formatUptime } from '@/lib/utils'
import { useUpgradeJobsStore } from '@/stores/upgrade-jobs-store'
import { buildServerCardNetworkState, type ServerCardMetricPoint } from './server-card-network-data'
Expand Down Expand Up @@ -105,8 +106,6 @@ function formatLoad(load: number): string {
return load.toFixed(2)
}

const DEFAULT_TRAFFIC_LIMIT_BYTES = 1024 ** 4 // 1 TiB fallback when no quota configured

function averageLossRatio(point: ServerCardMetricPoint): number | null {
if (point.targets.length === 0) {
return null
Expand Down Expand Up @@ -184,15 +183,15 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
)

const trafficEntry = trafficOverview?.find((entry) => entry.server_id === server.id)
const trafficUsed = trafficEntry
? trafficEntry.cycle_in + trafficEntry.cycle_out
: server.net_in_transfer + server.net_out_transfer
const trafficLimit =
trafficEntry?.traffic_limit != null && trafficEntry.traffic_limit > 0
? trafficEntry.traffic_limit
: DEFAULT_TRAFFIC_LIMIT_BYTES
const trafficRawPct = trafficLimit > 0 ? (trafficUsed / trafficLimit) * 100 : 0
const trafficRingPct = Math.min(trafficRawPct, 100)
const {
used: trafficUsed,
limit: trafficLimit,
pct: trafficRingPct
} = computeTrafficQuota({
entry: trafficEntry,
netInTransfer: server.net_in_transfer,
netOutTransfer: server.net_out_transfer
})
const trafficDaysRemaining = trafficEntry?.days_remaining ?? null

return (
Expand Down Expand Up @@ -277,12 +276,12 @@ const ServerCardInner = ({ server }: ServerCardProps) => {
<CompactMetric
className="items-center"
label={t('card_disk_read')}
value={formatSpeed(server.disk_read_bytes_per_sec ?? 0)}
value={formatSpeed(server.disk_read_bytes_per_sec)}
/>
<CompactMetric
className="items-center"
label={t('card_disk_write')}
value={formatSpeed(server.disk_write_bytes_per_sec ?? 0)}
value={formatSpeed(server.disk_write_bytes_per_sec)}
/>
<CompactMetric
className="items-center"
Expand Down
129 changes: 103 additions & 26 deletions apps/web/src/components/server/server-edit-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,39 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useServerTags, useUpdateServerTags } from '@/hooks/use-server-tags'
import { api } from '@/lib/api-client'
import type { ServerGroup, ServerResponse, UpdateServerInput } from '@/lib/api-schema'

const TAG_SPLIT_RE = /[\s,]+/
const TAG_VALID_RE = /^[A-Za-z0-9_.-]+$/

function parseTagsInput(raw: string): { tags: string[]; error: string | null } {
const parts = raw
.split(TAG_SPLIT_RE)
.map((t) => t.trim())
.filter(Boolean)
const seen = new Set<string>()
const deduped: string[] = []
for (const tag of parts) {
if (tag.length > 16) {
return { tags: [], error: 'tags_validation_too_long' }
}
if (!TAG_VALID_RE.test(tag)) {
return { tags: [], error: 'tags_validation_invalid_char' }
}
if (seen.has(tag)) {
continue
}
seen.add(tag)
deduped.push(tag)
}
if (deduped.length > 8) {
return { tags: [], error: 'tags_validation_too_many' }
}
return { tags: deduped.sort(), error: null }
}

interface ServerEditDialogProps {
onClose: () => void
open: boolean
Expand All @@ -34,6 +64,8 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
)
const [trafficLimitType, setTrafficLimitType] = useState(server.traffic_limit_type ?? 'sum')
const [billingStartDay, setBillingStartDay] = useState(server.billing_start_day?.toString() ?? '')
const [tagsInput, setTagsInput] = useState('')
const [tagsDirty, setTagsDirty] = useState(false)

const { data: groups } = useQuery<ServerGroup[]>({
queryKey: ['server-groups'],
Expand All @@ -42,6 +74,9 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
enabled: open
})

const { data: initialTags } = useServerTags(server.id, open)
const tagsMutation = useUpdateServerTags(server.id)

useEffect(() => {
if (open) {
setName(server.name)
Expand All @@ -60,6 +95,13 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
}
}, [open, server])

useEffect(() => {
if (open && initialTags) {
setTagsInput(initialTags.join(', '))
setTagsDirty(false)
}
}, [open, initialTags])

const mutation = useMutation({
mutationFn: (payload: UpdateServerInput) => api.put<ServerResponse>(`/api/servers/${server.id}`, payload),
onSuccess: (data) => {
Expand All @@ -68,32 +110,53 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
}
})

const handleSubmit = (e: FormEvent) => {
const buildPayload = (): UpdateServerInput => ({
name,
weight,
hidden,
group_id: groupId || null,
remark: remark || null,
public_remark: publicRemark || null,
price: price ? Number.parseFloat(price) : null,
billing_cycle: billingCycle || null,
currency: currency || null,
expired_at: expiredAt ? `${expiredAt}T00:00:00Z` : null,
traffic_limit: trafficLimit ? Math.round(Number.parseFloat(trafficLimit) * 1024 ** 3) : null,
traffic_limit_type: trafficLimitType || null,
billing_start_day: billingStartDay ? Number.parseInt(billingStartDay, 10) : null
})

const saveTags = async (tags: string[]): Promise<boolean> => {
try {
await tagsMutation.mutateAsync(tags)
return true
} catch (err) {
if (initialTags) {
setTagsInput(initialTags.join(', '))
}
toast.error(err instanceof Error ? err.message : t('tags_save_failed'))
return false
}
}

const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
const payload: UpdateServerInput = {
name,
weight,
hidden,
group_id: groupId || null,
remark: remark || null,
public_remark: publicRemark || null,
price: price ? Number.parseFloat(price) : null,
billing_cycle: billingCycle || null,
currency: currency || null,
expired_at: expiredAt ? `${expiredAt}T00:00:00Z` : null,
traffic_limit: trafficLimit ? Math.round(Number.parseFloat(trafficLimit) * 1024 ** 3) : null,
traffic_limit_type: trafficLimitType || null,
billing_start_day: billingStartDay ? Number.parseInt(billingStartDay, 10) : null
const parsed = parseTagsInput(tagsInput)
if (parsed.error) {
toast.error(t(parsed.error))
return
}
mutation.mutate(payload, {
onSuccess: () => {
toast.success(t('edit_success', { defaultValue: 'Server updated successfully' }))
onClose()
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('edit_failed'))
}
})
try {
await mutation.mutateAsync(buildPayload())
} catch (err) {
toast.error(err instanceof Error ? err.message : t('edit_failed'))
return
}
if (tagsDirty && !(await saveTags(parsed.tags))) {
return
}
toast.success(t('edit_success', { defaultValue: 'Server updated successfully' }))
onClose()
}

return (
Expand Down Expand Up @@ -187,6 +250,20 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
value={publicRemark}
/>
</Field>
<Field label={t('tags_label')}>
<Input
aria-label={t('tags_label')}
name="tags"
onChange={(e) => {
setTagsInput(e.target.value)
setTagsDirty(true)
}}
placeholder={t('tags_placeholder')}
type="text"
value={tagsInput}
/>
<p className="mt-1 text-[11px] text-muted-foreground">{t('tags_hint')}</p>
</Field>
</fieldset>

{/* Billing */}
Expand Down Expand Up @@ -316,8 +393,8 @@ export function ServerEditDialog({ server, open, onClose }: ServerEditDialogProp
<Button onClick={onClose} type="button" variant="outline">
{t('common:cancel')}
</Button>
<Button disabled={mutation.isPending} type="submit">
{mutation.isPending ? t('common:saving') : t('common:save')}
<Button disabled={mutation.isPending || tagsMutation.isPending} type="submit">
{mutation.isPending || tagsMutation.isPending ? t('common:saving') : t('common:save')}
</Button>
</div>
</form>
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/server/status-dot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StatusDot } from './status-dot'

const ANIMATE_PULSE_RE = /animate-pulse/
const BG_EMERALD_RE = /bg-emerald-500/
const BG_MUTED_RE = /bg-muted-foreground/

describe('StatusDot', () => {
it('renders pulsing emerald dot when online', () => {
const { container } = render(<StatusDot online />)
const el = container.querySelector('[data-slot="status-dot"]')
expect(el?.className).toMatch(ANIMATE_PULSE_RE)
expect(el?.className).toMatch(BG_EMERALD_RE)
})

it('renders muted dot without pulse when offline', () => {
const { container } = render(<StatusDot online={false} />)
const el = container.querySelector('[data-slot="status-dot"]')
expect(el?.className).not.toMatch(ANIMATE_PULSE_RE)
expect(el?.className).toMatch(BG_MUTED_RE)
})
})
21 changes: 21 additions & 0 deletions apps/web/src/components/server/status-dot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils'

interface StatusDotProps {
className?: string
online: boolean
}

export function StatusDot({ online, className }: StatusDotProps) {
return (
<span
aria-label={online ? 'online' : 'offline'}
className={cn(
'inline-block size-2 rounded-full',
online ? 'animate-pulse bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]' : 'bg-muted-foreground/60',
className
)}
data-slot="status-dot"
role="img"
/>
)
}
35 changes: 35 additions & 0 deletions apps/web/src/components/server/tag-chip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { TagChipRow } from './tag-chip'

describe('TagChipRow', () => {
it('renders nothing when tags is empty', () => {
const { container } = render(<TagChipRow tags={[]} />)
expect(container.firstChild).toBeNull()
})

it('renders nothing when tags is undefined', () => {
const { container } = render(<TagChipRow tags={undefined} />)
expect(container.firstChild).toBeNull()
})

it('renders a chip per tag', () => {
render(<TagChipRow tags={['prod', 'web']} />)
expect(screen.getByText('prod')).toBeDefined()
expect(screen.getByText('web')).toBeDefined()
})

it('assigns the same palette color to the same tag across renders', () => {
const { container, rerender } = render(<TagChipRow tags={['prod']} />)
const first = container.querySelector('[data-slot="tag-chip"]')?.className
rerender(<TagChipRow tags={['prod']} />)
const second = container.querySelector('[data-slot="tag-chip"]')?.className
expect(first).toBe(second)
})

it('adds title attr on the chip element for tooltip / truncate fallback', () => {
render(<TagChipRow tags={['long-tag-value']} />)
const chip = screen.getByText('long-tag-value')
expect(chip.getAttribute('title')).toBe('long-tag-value')
})
})
Loading
Loading