feat: Warm Precision UI redesign — new design system, task-based navigation, interaction system

30 commits delivering:
- Warm Precision token system (warm olive/stone palette, no blue)
- Task-based sidebar (Operate/Act replacing Fleet/Config/Admin)
- ContextStrip removed, features moved to sidebar
- Device workspace header with breadcrumb and metadata
- 50ms interaction system (hover/focus/active/press states)
- Skeleton loaders replaced with honest loading states
- Needs Attention dashboard panel with device links
- Joined metrics strip
- UI scale selector (100%/110%/125%)
- All hardcoded colors tokenized
- 143 files changed, net -34 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 15:10:37 -05:00
143 changed files with 1568 additions and 1602 deletions

View File

@@ -2,7 +2,7 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { routeTree } from './routeTree.gen'
import { useAuth } from './lib/auth'
import { Skeleton } from './components/ui/skeleton'
import { LoadingText } from './components/ui/skeleton'
const router = createRouter({
routeTree,
@@ -24,17 +24,13 @@ function AppInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Only show skeleton during initial auth check -- NOT on subsequent isLoading changes.
// Only show loading text during initial auth check -- NOT on subsequent isLoading changes.
// Reacting to isLoading here would unmount the entire router tree (including LoginPage)
// every time an auth action sets isLoading, destroying all component local state.
if (!hasChecked) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="space-y-4 w-64">
<Skeleton className="h-8 w-48 mx-auto" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<LoadingText />
</div>
)
}

View File

@@ -146,7 +146,7 @@ export function AnsiNfoModal({ open, onOpenChange }: AnsiNfoModalProps) {
}}
>
{/* Retro title bar */}
<div className="flex items-center justify-between px-3 pr-10 py-1.5 bg-surface border-b border-border font-mono text-xs">
<div className="flex items-center justify-between px-3 pr-10 py-1.5 bg-panel border-b border-border font-mono text-xs">
<DialogTitle id="ansi-nfo-title" className="text-text-muted text-xs font-normal font-mono">
TOD.NFO ACiD View v1.0
</DialogTitle>

View File

@@ -698,7 +698,7 @@ export function AlertRulesPage() {
No alert rules configured.
</p>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium">
<span className="flex-1">Name</span>
@@ -710,7 +710,7 @@ export function AlertRulesPage() {
{rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-panel text-sm"
>
<div className="flex-1 min-w-0">
<span className="text-text-primary truncate block">
@@ -809,7 +809,7 @@ export function AlertRulesPage() {
{channels.map((ch) => (
<div
key={ch.id}
className="rounded-lg border border-border bg-surface p-4 space-y-2"
className="rounded-lg border border-border bg-panel p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">

View File

@@ -89,7 +89,7 @@ function AlertRow({
alert.silenced_until && new Date(alert.silenced_until) > new Date()
return (
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-surface transition-colors">
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-panel transition-colors">
<StatusIcon status={alert.status} />
<SeverityBadge severity={alert.severity} />
@@ -271,7 +271,7 @@ export function AlertsPage() {
description="All clear! No alerts have been triggered."
/>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{alerts.map((alert) => (
<AlertRow
key={alert.id}
@@ -302,7 +302,7 @@ export function AlertsPage() {
description="Alert events will appear here as they are triggered and resolved."
/>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Table header */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] uppercase tracking-wider text-text-muted font-semibold">
<span className="w-5" />
@@ -315,7 +315,7 @@ export function AlertsPage() {
{alerts.map((alert) => (
<div
key={alert.id}
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-panel text-sm"
>
<StatusIcon status={alert.status} />
<span className="w-16">

View File

@@ -80,7 +80,7 @@ function actionBadgeClasses(action: string): string {
if (action.startsWith('device_')) return 'bg-info/10 text-info border-info/20'
if (action.startsWith('alert_')) return 'bg-warning/10 text-warning border-warning/20'
if (action === 'login' || action === 'logout') return 'bg-success/10 text-success border-success/20'
if (action.startsWith('firmware')) return 'bg-purple-500/10 text-purple-400 border-purple-500/20'
if (action.startsWith('firmware')) return 'bg-warning/10 text-warning border-warning/20'
if (action.startsWith('bulk_')) return 'bg-error/10 text-error border-error/20'
return 'bg-elevated text-text-secondary border-border'
}
@@ -143,7 +143,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
aria-label="Filter by action"
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
>
{ACTION_TYPES.map((a) => (
<option key={a.value} value={a.value}>
@@ -160,7 +160,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
aria-label="Filter from date"
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
@@ -172,7 +172,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
aria-label="Filter to date"
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
@@ -185,7 +185,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
aria-label="Filter by user"
className="h-8 rounded-md border border-border bg-surface pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40"
className="h-8 rounded-md border border-border bg-panel pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40"
/>
</div>
@@ -196,7 +196,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
<button
onClick={handleExport}
disabled={exporting || !data?.total}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-panel px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="h-3.5 w-3.5" />
{exporting ? 'Exporting...' : 'Export CSV'}
@@ -204,7 +204,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
</div>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{isLoading ? (
<div className="p-8 text-center">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
@@ -278,7 +278,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
setPage(1)
}}
aria-label="Rows per page"
className="h-7 rounded border border-border bg-surface px-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="h-7 rounded border border-border bg-panel px-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
>
{PER_PAGE_OPTIONS.map((n) => (
<option key={n} value={n}>

View File

@@ -220,7 +220,7 @@ export function EmergencyKitDialog({
<div className="flex gap-2">
<button
onClick={handleCopy}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-surface px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover"
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-panel px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-panel-hover"
>
{copied ? (
<>
@@ -245,7 +245,7 @@ export function EmergencyKitDialog({
</div>
{/* Instructions */}
<div className="mt-3 rounded-md bg-surface-secondary p-3 text-xs text-text-secondary leading-relaxed">
<div className="mt-3 rounded-md bg-panel-secondary p-3 text-xs text-text-secondary leading-relaxed">
The PDF includes your Secret Key. Print it or save it securely.
You can also store the key in your password manager. Do NOT store it alongside your password.
</div>

View File

@@ -134,7 +134,7 @@ export function SrpUpgradeDialog({
</div>
) : (
<>
<div className="rounded-md bg-surface-secondary p-4 text-sm text-text-secondary leading-relaxed space-y-3">
<div className="rounded-md bg-panel-secondary p-4 text-sm text-text-secondary leading-relaxed space-y-3">
<p>
<strong>What happens:</strong>
</p>

View File

@@ -275,7 +275,7 @@ export function BulkDeployDialog({
'rounded-lg border p-4 text-center',
result.failed > 0
? 'border-error/30 bg-error/5'
: 'border-border bg-surface',
: 'border-border bg-panel',
)}
>
<XCircle

View File

@@ -76,7 +76,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
if (!ca) {
return (
<div className="max-w-lg mx-auto">
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-6">
<div className="rounded-lg border border-border bg-panel p-8 text-center space-y-6">
<div className="mx-auto w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center">
<Shield className="h-8 w-8 text-accent" />
</div>
@@ -118,7 +118,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
return (
<div
className={cn(
'rounded-lg border bg-surface p-6 space-y-4',
'rounded-lg border bg-panel p-6 space-y-4',
isExpired ? 'border-error/40' : 'border-success/30',
)}
>

View File

@@ -74,7 +74,7 @@ export function CertificatesPage() {
Certificate Authority
</h1>
</div>
<TableSkeleton rows={3} />
<TableSkeleton />
</div>
)
}

View File

@@ -189,7 +189,7 @@ export function DeviceCertTable({
}
if (loading) {
return <TableSkeleton rows={4} />
return <TableSkeleton />
}
return (

View File

@@ -204,7 +204,7 @@ export function CommandPalette() {
const visiblePages = pageCommands.filter((p) => p.visible)
const itemClass =
'flex items-center gap-3 px-2 py-2 rounded-lg text-sm text-text-secondary cursor-pointer data-[selected=true]:bg-accent-muted data-[selected=true]:text-accent'
'flex items-center gap-3 px-2 py-2 rounded-lg text-sm text-text-secondary cursor-pointer data-[selected=true]:bg-accent-soft data-[selected=true]:text-accent'
const groupHeadingClass =
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-text-muted [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider'
@@ -216,7 +216,7 @@ export function CommandPalette() {
overlayClassName="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
contentClassName="fixed left-1/2 top-[20%] -translate-x-1/2 z-50 w-full max-w-lg"
>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center gap-2 px-4 border-b border-border">
<Search className="h-4 w-4 text-text-muted flex-shrink-0" />
<Command.Input

View File

@@ -136,7 +136,7 @@ export function CommandExecutor({ tenantId, deviceId, currentPath }: CommandExec
{results.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-2">
{results.map((r, i) => (
<div key={i} className="bg-surface border-border rounded-md font-mono text-xs p-2 border">
<div key={i} className="bg-panel border-border rounded-md font-mono text-xs p-2 border">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] text-text-muted">{r.timestamp}</span>
<span className="text-xs font-mono text-text-secondary">{r.command}</span>

View File

@@ -325,7 +325,7 @@ export function ConfigEditorPage() {
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={(o) => !o && setDeleteConfirmOpen(false)}>
<DialogContent className="max-w-sm bg-surface border-border text-text-primary">
<DialogContent className="max-w-sm bg-panel border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm">Confirm Delete</DialogTitle>
</DialogHeader>

View File

@@ -143,7 +143,7 @@ export function EntryForm({ open, onClose, mode, entry, columns, onSubmit }: Ent
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto bg-surface border-border text-text-primary">
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto bg-panel border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm">
{mode === 'add' ? 'Add New Entry' : 'Edit Entry'}

View File

@@ -42,7 +42,7 @@ export function EntryTable({
className={cn(
'rounded-lg border p-4 text-sm',
isContainerPath
? 'border-border bg-surface text-text-secondary'
? 'border-border bg-panel text-text-secondary'
: 'border-error/30 bg-error/10 text-error',
)}
>
@@ -64,14 +64,8 @@ export function EntryTable({
if (isLoading) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between mb-3">
<div className="h-5 w-48 bg-elevated/50 rounded animate-pulse" />
<div className="h-8 w-24 bg-elevated/50 rounded animate-pulse" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 bg-surface rounded animate-pulse" />
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
)
}
@@ -128,7 +122,7 @@ export function EntryTable({
<tr
key={entry['.id'] || i}
className={cn(
'border-b border-border/50 hover:bg-surface transition-colors',
'border-b border-border/50 hover:bg-panel transition-colors',
entry['dynamic'] === 'true' && 'text-text-muted',
)}
>

View File

@@ -265,7 +265,7 @@ function TreeItem({
className={cn(
'flex items-center gap-1.5 w-full px-2 py-1 text-xs rounded transition-colors',
isActive
? 'bg-[hsl(var(--accent-muted))] text-accent'
? 'bg-[hsl(var(--accent-soft))] text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50',
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}

View File

@@ -145,7 +145,7 @@ export function AddressListPanel({ tenantId, deviceId, active }: ConfigPanelProp
</Button>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<List className="h-4 w-4" />

View File

@@ -282,7 +282,7 @@ function AddressTable({
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Network className="h-4 w-4" />

View File

@@ -193,8 +193,8 @@ export function ArpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{tab.label}
@@ -290,7 +290,7 @@ function ArpTable({
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Network className="h-4 w-4" />

View File

@@ -82,7 +82,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="space-y-1 col-span-2 sm:col-span-1">
<Label className="text-xs text-text-secondary">Target Address</Label>
@@ -99,7 +99,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
<select
value={direction}
onChange={(e) => setDirection(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="both">Both</option>
<option value="send">Send</option>
@@ -111,7 +111,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
@@ -170,7 +170,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
)}
{results.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Gauge className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Bandwidth Test Results</span>

View File

@@ -180,11 +180,11 @@ function DeviceSelector({
onSelectionChange(new Set())
}
if (isLoading) return <TableSkeleton rows={5} />
if (isLoading) return <TableSkeleton />
if (devices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<div className="rounded-lg border border-border bg-panel p-8 text-center">
<p className="text-text-muted text-sm">No devices found for this tenant.</p>
</div>
)
@@ -208,7 +208,7 @@ function DeviceSelector({
</div>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/50">
@@ -327,7 +327,7 @@ function ChangeDefiner({
</div>
{operationType && (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
{operationType === 'add-firewall-rule' && (
<>
<h4 className="text-sm font-medium text-text-secondary">Firewall Rule</h4>
@@ -605,7 +605,7 @@ function ExecutionPanel({
return (
<div className="space-y-4">
{/* Change description */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<h4 className="text-sm font-medium text-text-secondary mb-1">Change to Apply</h4>
<p className="text-sm text-text-primary">{change.description}</p>
<p className="text-xs text-text-muted mt-1 font-mono">
@@ -626,7 +626,7 @@ function ExecutionPanel({
{/* Summary */}
{isComplete && (
<div className="rounded-lg border border-border bg-surface p-4 flex items-center gap-4">
<div className="rounded-lg border border-border bg-panel p-4 flex items-center gap-4">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">{successCount} succeeded</span>
@@ -641,7 +641,7 @@ function ExecutionPanel({
)}
{/* Device status table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/50">

View File

@@ -138,11 +138,11 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
</div>
{ports.entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
No bridge ports configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -212,7 +212,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['interface'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, interface: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
>
<option value="">Select...</option>
{ifaceNames.map((name) => <option key={name} value={name}>{name}</option>)}
@@ -223,7 +223,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['bridge'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
>
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
@@ -243,7 +243,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['frame-types'] || 'admit-all'}
onChange={(e) => setFormData((f) => ({ ...f, 'frame-types': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
{FRAME_TYPES.map((ft) => <option key={ft} value={ft}>{ft}</option>)}
</select>
@@ -253,7 +253,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['ingress-filtering'] || 'no'}
onChange={(e) => setFormData((f) => ({ ...f, 'ingress-filtering': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
@@ -264,7 +264,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['hw'] || 'yes'}
onChange={(e) => setFormData((f) => ({ ...f, hw: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
@@ -297,7 +297,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['edge'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, edge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="auto">Auto</option>
<option value="yes">Yes</option>

View File

@@ -140,7 +140,7 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
{/* VLAN Filtering status per bridge */}
{bridges.entries.length > 0 && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="rounded-lg border border-border bg-panel p-3">
<div className="flex items-center gap-2 mb-2">
<Network className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Bridge VLAN Filtering</span>
@@ -170,11 +170,11 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
{/* VLAN Table */}
{vlans.entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
No bridge VLAN entries configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -247,7 +247,7 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select
value={formData['bridge'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
>
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select>

View File

@@ -81,9 +81,9 @@ export function ConfigDiffViewer({
const isLoading = loadingOld || loadingNew
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-surface">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-panel">
<div className="flex items-center gap-2 text-xs text-text-muted">
{isEncrypted && (
<span className="inline-flex items-center gap-1 text-info" title="Decrypted from encrypted backup">
@@ -126,8 +126,8 @@ export function ConfigDiffViewer({
{/* Diff content */}
{isLoading ? (
<div className="p-8 text-center text-sm text-text-muted animate-pulse">
Loading diff...
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : !diffFile ? (
<div className="p-8 text-center text-sm text-text-muted">

View File

@@ -65,7 +65,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
})
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="flex items-center gap-2 mb-3">
<History className="h-4 w-4 text-text-muted" />
<h3 className="text-sm font-medium text-text-muted">Configuration History</h3>
@@ -83,7 +83,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
)}
{isLoading ? (
<TableSkeleton rows={3} />
<TableSkeleton />
) : !changes || changes.length === 0 ? (
<div className="flex items-center justify-center py-6">
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span>

View File

@@ -158,16 +158,11 @@ export function ConfigTab({
{/* Timeline panel */}
<div className="w-72 flex-shrink-0">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-14 rounded-lg border border-border bg-surface animate-pulse"
/>
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : !backups || backups.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
No backups yet. Click &lsquo;Backup Now&rsquo; to create the first backup.
</div>
) : (
@@ -201,7 +196,7 @@ export function ConfigTab({
}
/>
) : (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted h-full flex items-center justify-center min-h-32">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted h-full flex items-center justify-center min-h-32">
{selectedShas.length < 2
? 'Select two backups from the timeline to compare'
: 'Click "Compare selected" to view the diff'}

View File

@@ -104,7 +104,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
</div>
{/* Active connections count */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-accent" />
<div>
@@ -120,7 +120,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
</div>
{/* Tracking settings */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Connection Tracking Settings</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
@@ -154,7 +154,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
<select
value={formData['enabled'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, enabled: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="auto">Auto</option>
<option value="yes">Yes</option>

View File

@@ -206,7 +206,7 @@ export function DhcpClientPanel({ tenantId, deviceId, active }: ConfigPanelProps
</div>
{/* DHCP Client table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Globe className="h-4 w-4" />

View File

@@ -240,8 +240,8 @@ export function DhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{tab.icon}
@@ -400,7 +400,7 @@ function ServersTab({
}, [form, editing, panel])
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">DHCP Servers</h3>
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
@@ -651,7 +651,7 @@ function PoolsTab({
}, [form, editing, panel])
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">Address Pools</h3>
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
@@ -860,7 +860,7 @@ function LeasesTab({
}
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">DHCP Leases</h3>
<Button variant="outline" size="sm" onClick={handleAddStatic} className="gap-1.5">
@@ -1112,7 +1112,7 @@ function NetworksTab({
}, [form, editing, panel])
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">DHCP Networks</h3>
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">

View File

@@ -10,10 +10,10 @@ interface DiffViewerProps {
}
function classifyLine(line: string): string {
if (line.startsWith('@@')) return 'bg-blue-900/20 text-blue-300'
if (line.startsWith('@@')) return 'bg-info/10 text-info'
if (line.startsWith('+++') || line.startsWith('---')) return 'text-text-muted'
if (line.startsWith('+')) return 'bg-green-900/30 text-green-300'
if (line.startsWith('-')) return 'bg-red-900/30 text-red-300'
if (line.startsWith('+')) return 'bg-success/10 text-success'
if (line.startsWith('-')) return 'bg-error/10 text-error'
return 'text-text-primary'
}
@@ -24,7 +24,7 @@ export function DiffViewer({ tenantId, deviceId, snapshotId, onClose }: DiffView
})
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
@@ -47,10 +47,8 @@ export function DiffViewer({ tenantId, deviceId, snapshotId, onClose }: DiffView
{/* Content */}
{isLoading ? (
<div className="space-y-2">
{[75, 90, 65, 85, 70, 80].map((w, i) => (
<div key={i} className="h-4 bg-elevated rounded animate-pulse" style={{ width: `${w}%` }} />
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : isError || !diff ? (
<div className="flex items-center justify-center py-6">

View File

@@ -302,7 +302,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div>
{/* Section 1: Resolver Settings */}
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
<div className="rounded-lg border border-border bg-panel p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-accent" />
@@ -393,7 +393,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div>
{/* Section 2: Static DNS Entries */}
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-accent" />

View File

@@ -23,7 +23,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
@@ -643,7 +643,7 @@ function FilterRulesTable({
</Button>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -836,7 +836,7 @@ function NatRulesTable({
</Button>
</div>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -1340,16 +1340,10 @@ function CellEmpty() {
function LoadingRows({ cols }: { cols: number }) {
return (
<>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/50">
{Array.from({ length: cols }).map((_, j) => (
<td key={j} className="px-3 py-2">
<Skeleton className="h-4 w-full" />
</td>
))}
</tr>
))}
</>
<tr>
<td colSpan={cols} className="py-8 text-center">
<LoadingText />
</td>
</tr>
)
}

View File

@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
@@ -71,14 +71,14 @@ const SUB_TABS: { key: SubTab; label: string; icon: React.ElementType }[] = [
// ---------------------------------------------------------------------------
const TYPE_COLORS: Record<string, string> = {
ether: '#3B82F6',
bridge: '#8B5CF6',
vlan: '#F59E0B',
bonding: '#10B981',
pppoe: '#EF4444',
l2tp: '#EC4899',
ovpn: '#06B6D4',
wlan: '#84CC16',
ether: 'hsl(var(--accent))',
bridge: 'hsl(var(--info))',
vlan: 'hsl(var(--warning))',
bonding: 'hsl(var(--success))',
pppoe: 'hsl(var(--error))',
l2tp: 'hsl(var(--error))',
ovpn: 'hsl(var(--info))',
wlan: 'hsl(var(--success))',
}
// ---------------------------------------------------------------------------
@@ -228,23 +228,13 @@ export function InterfacesPanel({ tenantId, deviceId, active }: ConfigPanelProps
}
// ---------------------------------------------------------------------------
// Loading skeleton
// Loading state
// ---------------------------------------------------------------------------
function TableSkeleton({ rows = 5 }: { rows?: number }) {
function TableLoading() {
return (
<div className="rounded-lg border border-border bg-surface">
<div className="p-3 border-b border-border">
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3 border-b border-border last:border-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-12" />
</div>
))}
<div className="py-8 text-center">
<LoadingText />
</div>
)
}
@@ -260,18 +250,18 @@ function InterfacesTable({
entries: Record<string, string>[]
isLoading: boolean
}) {
if (isLoading) return <TableSkeleton />
if (isLoading) return <TableLoading />
if (entries.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
No interfaces found on this device.
</div>
)
}
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
@@ -362,7 +352,7 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
})
}
if (isLoading) return <TableSkeleton />
if (isLoading) return <TableLoading />
return (
<div className="space-y-3">
@@ -374,11 +364,11 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
No IP addresses configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
@@ -621,7 +611,7 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
})
}
if (isLoading) return <TableSkeleton />
if (isLoading) return <TableLoading />
return (
<div className="space-y-3">
@@ -633,11 +623,11 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
No VLANs configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
@@ -926,7 +916,7 @@ function BridgesTab({
})
}
if (isLoading) return <TableSkeleton />
if (isLoading) return <TableLoading />
return (
<div className="space-y-6">
@@ -941,11 +931,11 @@ function BridgesTab({
</div>
{bridges.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-text-secondary text-sm">
No bridges configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
@@ -1015,11 +1005,11 @@ function BridgesTab({
</div>
{bridgePorts.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-text-secondary text-sm">
No bridge ports configured.
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">

View File

@@ -87,7 +87,7 @@ export function IpsecPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
activeTab === tab.key ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50')}>
{tab.icon}{tab.label}
</button>
))}
@@ -147,11 +147,11 @@ function PeersTab({ entries, panel }: { entries: PeerEntry[]; panel: PanelHook }
<div className="space-y-1"><Label className="text-xs text-text-secondary">Address</Label><Input value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} placeholder="0.0.0.0/0" className="h-8 text-sm font-mono" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Auth Method</Label>
<select value={form['auth-method']} onChange={(e) => setForm((f) => ({ ...f, 'auth-method': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<select value={form['auth-method']} onChange={(e) => setForm((f) => ({ ...f, 'auth-method': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="pre-shared-key">Pre-Shared Key</option><option value="rsa-key">RSA Key</option><option value="rsa-signature">RSA Signature</option>
</select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Exchange Mode</Label>
<select value={form['exchange-mode']} onChange={(e) => setForm((f) => ({ ...f, 'exchange-mode': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<select value={form['exchange-mode']} onChange={(e) => setForm((f) => ({ ...f, 'exchange-mode': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="main">Main</option><option value="aggressive">Aggressive</option><option value="ike2">IKEv2</option>
</select></div>
</div>
@@ -208,8 +208,8 @@ function PoliciesTab({ entries, panel }: { entries: PolicyEntry[]; panel: PanelH
<div className="space-y-1"><Label className="text-xs text-text-secondary">Dst Address</Label><Input value={form['dst-address']} onChange={(e) => setForm((f) => ({ ...f, 'dst-address': e.target.value }))} placeholder="10.0.0.0/24" className="h-8 text-sm font-mono" /></div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Tunnel</Label><select value={form.tunnel} onChange={(e) => setForm((f) => ({ ...f, tunnel: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="yes">Yes</option><option value="no">No</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Action</Label><select value={form.action} onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="encrypt">Encrypt</option><option value="none">None</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Tunnel</Label><select value={form.tunnel} onChange={(e) => setForm((f) => ({ ...f, tunnel: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"><option value="yes">Yes</option><option value="no">No</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Action</Label><select value={form.action} onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"><option value="encrypt">Encrypt</option><option value="none">None</option></select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Proposal</Label><Input value={form.proposal} onChange={(e) => setForm((f) => ({ ...f, proposal: e.target.value }))} className="h-8 text-sm" /></div>
</div>
</div>
@@ -275,7 +275,7 @@ function ProposalsTab({ entries, panel }: { entries: ProposalEntry[]; panel: Pan
function SasTab({ entries }: { entries: SaEntry[] }) {
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50"><span className="text-sm font-medium text-text-secondary">Installed SAs ({entries.length})</span></div>
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No active security associations.</div> : (
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
@@ -301,7 +301,7 @@ function SasTab({ entries }: { entries: SaEntry[] }) {
function TableWrapper({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">{title} ({count})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={onAdd}><Plus className="h-3.5 w-3.5" />Add</Button>

View File

@@ -127,7 +127,7 @@ export function ManglePanel({ tenantId, deviceId, active }: ConfigPanelProps) {
onClick={() => setChainFilter(chain)}
className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize',
chainFilter === chain ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
chainFilter === chain ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{chain}
@@ -193,7 +193,7 @@ function MangleTable({ entries, panel }: { entries: MangleEntry[]; panel: PanelH
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Filter className="h-4 w-4" />

View File

@@ -90,7 +90,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
return (
<div className="space-y-4">
{/* Input form */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="space-y-1 col-span-2">
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
@@ -166,7 +166,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
)}
{results.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">Ping Results</span>
@@ -189,7 +189,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
{/* Stats summary */}
{stats && (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4 text-center">
<StatBox label="Sent" value={String(stats.sent)} />
<StatBox label="Received" value={String(stats.received)} />

View File

@@ -275,7 +275,7 @@ function PoolTable({
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Layers className="h-4 w-4" />

View File

@@ -98,7 +98,7 @@ export function PppPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
activeTab === tab.key ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50')}>
{tab.icon}{tab.label}
{tab.key === 'active' && activeConns.entries.length > 0 && (
<span className="text-xs bg-accent/20 text-accent px-1 rounded">{activeConns.entries.length}</span>
@@ -146,7 +146,7 @@ function ProfilesTab({ entries, panel }: { entries: ProfileEntry[]; panel: Panel
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">PPP Profiles ({entries.length})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Profile</Button>
@@ -219,7 +219,7 @@ function SecretsTab({ entries, panel, profileNames }: { entries: SecretEntry[];
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">PPP Secrets ({entries.length})</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Secret</Button>
@@ -252,7 +252,7 @@ function SecretsTab({ entries, panel, profileNames }: { entries: SecretEntry[];
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Service</Label>
<select value={form.service} onChange={(e) => setForm((f) => ({ ...f, service: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
<select value={form.service} onChange={(e) => setForm((f) => ({ ...f, service: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
{PPP_SERVICES.map((s) => <option key={s} value={s}>{s}</option>)}
</select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Profile</Label><Input value={form.profile} onChange={(e) => setForm((f) => ({ ...f, profile: e.target.value }))} placeholder="default" className="h-8 text-sm" list="profile-names" />
@@ -281,7 +281,7 @@ function ActiveTab({ entries, tenantId, deviceId, refetch }: { entries: ActiveEn
})
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Active Connections ({entries.length})</span>
</div>

View File

@@ -52,10 +52,8 @@ export function RestorePreview({
if (isLoading) {
return (
<div className="space-y-4 p-4">
<div className="h-12 rounded-lg bg-elevated animate-pulse" />
<div className="h-32 rounded-lg bg-elevated animate-pulse" />
<div className="h-16 rounded-lg bg-elevated animate-pulse" />
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
)
}
@@ -100,7 +98,7 @@ export function RestorePreview({
)}
{/* Summary bar */}
<div className="rounded-lg border border-border bg-surface-raised p-3 flex items-center justify-between">
<div className="rounded-lg border border-border bg-panel-raised p-3 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="text-success font-mono">+{diff.added}</span>
<span className="text-error font-mono">-{diff.removed}</span>
@@ -125,7 +123,7 @@ export function RestorePreview({
{changedCategories.map((cat) => (
<button
key={cat.path}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-surface-raised/50 transition-colors"
className="w-full px-3 py-2 flex items-center justify-between hover:bg-panel-raised/50 transition-colors"
onClick={() => togglePath(cat.path)}
>
<div className="flex items-center gap-2 text-sm">

View File

@@ -170,8 +170,8 @@ export function RoutesPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{tab.label}
@@ -313,7 +313,7 @@ function RoutesTable({
return (
<>
{/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Route className="h-4 w-4" />

View File

@@ -148,8 +148,8 @@ export function ScriptsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{tab.icon}
@@ -275,7 +275,7 @@ function ScriptsTab({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Code className="h-4 w-4" />
@@ -477,7 +477,7 @@ function SchedulerTab({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Clock className="h-4 w-4" />

View File

@@ -230,7 +230,7 @@ function ServiceTable({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Shield className="h-4 w-4" />
@@ -337,7 +337,7 @@ function ServiceTable({
<select
value={form.disabled}
onChange={(e) => setForm((f) => ({ ...f, disabled: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="false">Enabled</option>
<option value="true">Disabled</option>

View File

@@ -158,7 +158,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div>
{/* SNMP Status + Settings */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-accent" />
@@ -192,7 +192,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div>
{/* Communities */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">SNMP Communities</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleAddCommunity}>
@@ -279,7 +279,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Trap Version</Label>
<select value={settingsForm['trap-version'] || '1'} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-version': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="1">v1</option>
<option value="2">v2c</option>
<option value="3">v3</option>
@@ -313,7 +313,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Read Access</Label>
<select value={communityForm['read-access'] || 'yes'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'read-access': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
@@ -321,7 +321,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Write Access</Label>
<select value={communityForm['write-access'] || 'no'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'write-access': e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
@@ -329,7 +329,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Security</Label>
<select value={communityForm['security'] || 'none'} onChange={(e) => setCommunityForm((f) => ({ ...f, security: e.target.value }))}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
<option value="none">None</option>
<option value="authorized">Authorized</option>
<option value="private">Private</option>

View File

@@ -10,7 +10,7 @@
import { useMemo } from 'react'
import { Zap, Network } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useConfigBrowse } from '@/hooks/useConfigPanel'
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
@@ -134,19 +134,15 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
if (isLoading) {
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex flex-wrap gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-16 rounded-md" />
))}
</div>
<div className="py-8 text-center">
<LoadingText />
</div>
)
}
if (etherPorts.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
<Network className="h-8 w-8 mx-auto mb-2 opacity-40" />
No ethernet ports detected on this device.
</div>
@@ -156,7 +152,7 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
return (
<div className="space-y-4">
{/* Port grid */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<h3 className="text-sm font-medium text-text-primary mb-3">Switch Ports</h3>
<div className="flex flex-wrap gap-2">
{etherPorts.map((port) => {
@@ -177,7 +173,7 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
</div>
{/* VLAN Legend */}
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<h3 className="text-sm font-medium text-text-primary mb-2">VLAN Legend</h3>
<div className="flex flex-wrap gap-3">
<LegendItem color={UNASSIGNED_COLOR} label="Unassigned" />

View File

@@ -104,8 +104,8 @@ export function SystemPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
)}
>
{tab.icon}
@@ -179,7 +179,7 @@ function IdentityTab({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">System Identity</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
@@ -283,7 +283,7 @@ function ClockTab({
return (
<div className="space-y-4">
{/* Clock info */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">Clock Settings</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditClock}>
@@ -300,7 +300,7 @@ function ClockTab({
</div>
{/* NTP info */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">NTP Client</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditNtp}>
@@ -351,7 +351,7 @@ function ClockTab({
<select
value={ntpEnabled}
onChange={(e) => setNtpEnabled(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="yes">Yes</option>
<option value="no">No</option>
@@ -394,7 +394,7 @@ function ResourcesTab({ data }: { data: Record<string, string> }) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">System Resources</span>
</div>

View File

@@ -106,14 +106,14 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Interface</Label>
<select
value={iface}
onChange={(e) => setIface(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
>
{ifaceNames.length > 0
? ifaceNames.map((name) => (
@@ -190,7 +190,7 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
)}
{entries.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Flame className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">
@@ -229,7 +229,7 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
)}
{entries.length === 0 && !torchMutation.isPending && !torchMutation.isIdle && (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
No traffic captured. Try a different interface or remove filters.
</div>
)}

View File

@@ -69,7 +69,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1 col-span-2">
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
@@ -108,7 +108,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
>
<option value="icmp">ICMP</option>
<option value="udp">UDP</option>
@@ -137,7 +137,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
)}
{hops.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Route className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary">

View File

@@ -148,7 +148,7 @@ export function UsersPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{/* Groups overview */}
{groups.length > 0 && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Shield className="h-4 w-4 text-text-muted" />
<span className="text-sm font-medium text-text-secondary">User Groups</span>
@@ -266,7 +266,7 @@ function UsersTable({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Users className="h-4 w-4" />

View File

@@ -69,7 +69,7 @@ export function AlertSummary({
}))
return (
<Card className="bg-surface border-border">
<Card className="bg-panel border-border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
Alert Summary

View File

@@ -45,7 +45,7 @@ function BwTooltip({ active, payload }: CustomTooltipProps) {
if (!active || !payload || payload.length === 0) return null
const item = payload[0]
return (
<div className="rounded-md border border-border bg-surface px-3 py-2 text-xs">
<div className="rounded-md border border-border bg-panel px-3 py-2 text-xs">
<p className="font-medium text-text-primary">{item.payload.hostname}</p>
<p className="text-text-secondary">{formatBw(item.value)}</p>
</div>
@@ -56,7 +56,7 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
const chartHeight = Math.max(200, devices.length * 36)
return (
<Card className="bg-surface border-border">
<Card className="bg-panel border-border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
Top Bandwidth Consumers
@@ -78,7 +78,7 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
<XAxis
type="number"
tickFormatter={formatAxisTick}
tick={{ fontSize: 11, fill: '#94a3b8' }}
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
axisLine={false}
tickLine={false}
/>
@@ -86,19 +86,19 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
type="category"
dataKey="hostname"
width={120}
tick={{ fontSize: 11, fill: '#cbd5e1' }}
tick={{ fontSize: 10, fill: 'hsl(var(--text-secondary))' }}
axisLine={false}
tickLine={false}
/>
<Tooltip
content={<BwTooltip />}
cursor={{ fill: '#334155', opacity: 0.5 }}
cursor={{ fill: 'hsl(var(--elevated))', opacity: 0.5 }}
/>
<Bar
dataKey="bandwidthBps"
fill="#38BDF8"
radius={[0, 4, 4, 0]}
maxBarSize={24}
fill="hsl(var(--accent))"
radius={[0, 2, 2, 0]}
maxBarSize={20}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Bell, Server, HardDrive } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { eventsApi, type DashboardEvent, type EventsParams } from '@/lib/eventsApi'
import { DeviceLink } from '@/components/ui/device-link'
@@ -83,22 +82,6 @@ function EventIcon({ event }: { event: DashboardEvent }) {
}
}
function TimelineSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 pl-3">
<Skeleton className="h-4 w-4 rounded-full shrink-0 mt-0.5" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-3 w-12 shrink-0" />
</div>
))}
</div>
)
}
export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps) {
const [filterType, setFilterType] = useState<EventFilter>(undefined)
@@ -115,86 +98,79 @@ export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps)
})
return (
<Card className="bg-surface border-border">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-medium text-text-secondary">
Recent Events
</CardTitle>
<div className="flex gap-1">
{FILTERS.map((f) => (
<button
key={f.label}
onClick={() => setFilterType(f.value)}
className={cn(
'px-2 py-0.5 rounded text-xs font-medium transition-colors',
filterType === f.value
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)}
>
{f.label}
</button>
))}
</div>
<div className="bg-panel border border-border rounded-sm">
<div className="px-3 py-2 border-b border-border-subtle bg-elevated flex items-center justify-between">
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
Recent Events
</span>
<div className="flex gap-0.5">
{FILTERS.map((f) => (
<button
key={f.label}
onClick={() => setFilterType(f.value)}
className={cn(
'px-1.5 py-0.5 rounded-sm text-[10px] font-medium transition-[background-color,color] duration-[50ms]',
filterType === f.value
? 'bg-accent-soft text-accent'
: 'text-text-muted hover:text-text-secondary',
)}
>
{f.label}
</button>
))}
</div>
</CardHeader>
<CardContent>
</div>
<div>
{isSuperAdmin && !tenantId ? (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<div className="py-5 text-center text-[9px] text-text-muted">
Select a tenant to view events
</div>
) : isLoading ? (
<TimelineSkeleton />
<div className="py-5 text-center text-[9px] text-text-muted">
Loading
</div>
) : !events || events.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<div className="py-5 text-center text-[9px] text-text-muted">
No recent events
</div>
) : (
<div className="max-h-[400px] overflow-y-auto pr-1">
<div className="relative border-l-2 border-border ml-2">
{events.map((event) => (
<div
key={event.id}
className="relative flex items-start gap-3 pb-3 pl-4 last:pb-0"
>
{/* Icon positioned over the timeline line */}
<div className="absolute -left-[9px] top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-surface">
<EventIcon event={event} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 ml-1">
<p className="text-sm font-medium text-text-primary truncate">
{event.title}
</p>
<p className="text-xs text-text-muted truncate">
{event.description}
{event.device_hostname && (
<span className="ml-1 text-text-secondary">
&mdash;{' '}
{event.device_id ? (
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
{event.device_hostname}
</DeviceLink>
) : (
event.device_hostname
)}
</span>
)}
</p>
</div>
{/* Timestamp */}
<span className="text-xs text-text-muted whitespace-nowrap shrink-0 mt-0.5">
{formatRelativeTime(event.timestamp)}
<div className="max-h-[400px] overflow-y-auto divide-y divide-border-subtle">
{events.map((event) => (
<div
key={event.id}
className="flex items-center gap-2.5 px-3 py-1.5"
>
<div className="shrink-0">
<EventIcon event={event} />
</div>
<div className="flex-1 min-w-0">
<span className="text-xs text-text-primary truncate block">
{event.title}
</span>
<span className="text-[10px] text-text-muted truncate block">
{event.description}
{event.device_hostname && (
<span className="ml-1 text-text-secondary">
&mdash;{' '}
{event.device_id ? (
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
{event.device_hostname}
</DeviceLink>
) : (
event.device_hostname
)}
</span>
)}
</span>
</div>
))}
</div>
<span className="text-[10px] font-mono text-text-muted whitespace-nowrap shrink-0">
{formatRelativeTime(event.timestamp)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -132,7 +132,7 @@ export function HealthScore({
const dashOffset = CIRCUMFERENCE * (1 - progress)
return (
<Card className="bg-surface border-border">
<Card className="bg-panel border-border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
Network Health

View File

@@ -44,45 +44,31 @@ function KpiCard({
const animatedValue = useAnimatedCounter(value, 800, decimals)
return (
<Card
<div
className={cn(
'bg-gradient-to-br from-[#f8f8ff] to-elevated dark:from-elevated dark:to-[#16162a] border-border transition-colors',
highlight && 'border-warning/30',
'bg-panel border border-border px-3 py-2.5 rounded-sm',
highlight && 'border-l-2 border-l-warning',
)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
{label}
</span>
<div className="flex items-baseline gap-1">
<span
className={cn(
'text-2xl font-medium tabular-nums font-mono',
colorClass,
)}
>
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
</span>
{suffix && (
<span className="text-sm font-medium text-text-muted">
{suffix}
</span>
)}
</div>
</div>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg bg-elevated/50',
colorClass,
)}
>
{icon}
</div>
</div>
</CardContent>
</Card>
<div className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px] mb-1">
{label}
</div>
<div className="flex items-baseline gap-1">
<span
className={cn(
'text-lg font-medium tabular-nums font-mono',
colorClass,
)}
>
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
</span>
{suffix && (
<span className="text-xs text-text-muted">
{suffix}
</span>
)}
</div>
</div>
)
}

View File

@@ -72,34 +72,38 @@ export function QuickActions({ tenantId, isSuperAdmin }: QuickActionsProps) {
const actions = getActions(tenantId, isSuperAdmin)
return (
<Card className="bg-surface border-border">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-text-secondary">
<div className="bg-panel border border-border rounded-sm">
<div className="px-3 py-2 border-b border-border-subtle bg-elevated">
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => (
<Link
key={action.label}
to={action.to}
className={cn(
'flex flex-col items-center gap-1.5 rounded-lg px-3 py-3',
'text-text-secondary hover:bg-elevated/50 hover:text-text-primary',
'transition-colors text-center',
)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-elevated/50">
{action.icon}
</div>
<span className="text-xs font-medium leading-tight">
</span>
</div>
<div className="divide-y divide-border-subtle">
{actions.map((action) => (
<Link
key={action.label}
to={action.to}
className={cn(
'flex items-center gap-2.5 px-3 py-2',
'text-text-secondary hover:text-text-primary',
'border-l-2 border-transparent hover:border-accent',
'transition-[border-color,color] duration-[50ms]',
)}
>
<div className="text-text-muted">
{action.icon}
</div>
<div className="min-w-0">
<span className="text-xs font-medium block">
{action.label}
</span>
</Link>
))}
</div>
</CardContent>
</Card>
<span className="text-[10px] text-text-muted block">
{action.description}
</span>
</div>
</Link>
))}
</div>
</div>
)
}

View File

@@ -25,7 +25,7 @@ export function WirelessIssues({ tenantId }: WirelessIssuesProps) {
})
return (
<div className="rounded-lg border border-border bg-surface p-5">
<div className="rounded-lg border border-border bg-panel p-5">
<h3 className="text-sm font-semibold text-text-primary mb-4 flex items-center gap-2">
<Wifi className="h-4 w-4 text-text-muted" />
APs Needing Attention

View File

@@ -50,7 +50,7 @@ function StatCard({
color: string
}) {
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<div className={cn('text-2xl font-bold', color)}>{value}</div>
<div className="text-xs text-text-muted mt-1">{label}</div>
</div>
@@ -184,7 +184,7 @@ function UpgradeDialog({
</div>
)}
<div className="rounded border border-border bg-surface p-3 text-xs text-text-secondary">
<div className="rounded border border-border bg-panel p-3 text-xs text-text-secondary">
A mandatory config backup will be taken before upgrading each device.
</div>
@@ -254,10 +254,10 @@ function VersionGroupCard({
return (
<>
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-surface transition-colors"
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-panel transition-colors"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted" />

View File

@@ -256,7 +256,7 @@ function MassUpgradeProgress({
)}
{/* Device list */}
<div className="rounded-lg border border-border bg-surface overflow-hidden max-h-48 overflow-y-auto">
<div className="rounded-lg border border-border bg-panel overflow-hidden max-h-48 overflow-y-auto">
{rollout.jobs.map((job) => {
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
const Icon = config.icon

View File

@@ -231,7 +231,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
{/* Step 3: Configure Credentials */}
{step === 3 && (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Configure Credentials</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -253,7 +253,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
className={cn(
'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors',
credMode === opt.value
? 'bg-surface text-text-primary shadow-sm'
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary',
)}
>
@@ -389,7 +389,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
{/* Step 4: Assign Groups & Tags */}
{step === 4 && (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Assign Groups & Tags</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -584,7 +584,7 @@ function SubnetStep({
}
return (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Enter Subnet</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -682,7 +682,7 @@ function ScanResultsStep({
).length
return (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Scan Results</h3>
@@ -703,7 +703,7 @@ function ScanResultsStep({
<div className="rounded-md border border-border/50 overflow-hidden max-h-72 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0">
<tr className="border-b border-border bg-surface">
<tr className="border-b border-border bg-panel">
<th className="px-3 py-2 w-8">
<Checkbox
checked={allNewSelected}
@@ -959,7 +959,7 @@ function ImportVerifyStep({
])
return (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Import & Verify</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -1013,7 +1013,7 @@ function ImportVerifyStep({
<div className="rounded-md border border-border/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<tr className="border-b border-border bg-panel">
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">
Device
</th>

View File

@@ -1,17 +1,17 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useAuth } from '@/lib/auth'
import { metricsApi, tenantsApi } from '@/lib/api'
import { metricsApi, tenantsApi, type FleetDevice } from '@/lib/api'
import { useUIStore } from '@/lib/store'
import { alertsApi } from '@/lib/alertsApi'
import { useEventStreamContext } from '@/contexts/EventStreamContext'
import { LayoutDashboard } from 'lucide-react'
import { LayoutDashboard, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
import { KpiCards } from '@/components/dashboard/KpiCards'
import { HealthScore } from '@/components/dashboard/HealthScore'
import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart'
@@ -30,39 +30,122 @@ const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
{ label: 'Off', value: false },
]
// ─── Dashboard Skeleton ──────────────────────────────────────────────────────
// ─── Dashboard Loading ───────────────────────────────────────────────────────
function DashboardSkeleton() {
function DashboardLoading() {
return (
<div className="space-y-4">
{/* KPI cards skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border p-4">
<Skeleton className="h-3 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
<div className="py-8 text-center">
<LoadingText />
</div>
)
}
// ─── Needs Attention (inline component) ─────────────────────────────────────
interface AttentionItem {
id: string
deviceId: string
tenantId: string
hostname: string
model: string | null
severity: 'error' | 'warning'
reason: string
hasCoords: boolean
}
function NeedsAttention({ devices }: { devices: FleetDevice[] }) {
const items = useMemo<AttentionItem[]>(() => {
const result: AttentionItem[] = []
for (const d of devices) {
const base = {
deviceId: d.id,
tenantId: d.tenant_id,
hostname: d.hostname,
model: d.model,
hasCoords: d.latitude != null && d.longitude != null,
}
if (d.status === 'offline') {
result.push({ ...base, id: `${d.id}-offline`, severity: 'error', reason: 'Offline' })
} else if (d.status === 'degraded') {
result.push({ ...base, id: `${d.id}-degraded`, severity: 'warning', reason: 'Degraded' })
}
if (d.last_cpu_load != null && d.last_cpu_load > 80) {
result.push({ ...base, id: `${d.id}-cpu`, severity: 'warning', reason: `CPU ${d.last_cpu_load}%` })
}
}
result.sort((a, b) => {
if (a.severity === b.severity) return 0
return a.severity === 'error' ? -1 : 1
})
return result.slice(0, 10)
}, [devices])
const count = items.length
return (
<div className="bg-panel border border-border-default rounded-sm mb-3.5">
<div className="px-3 py-2 border-b border-border-default bg-elevated">
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
Needs Attention
</span>
<span className="text-[7px] text-[hsl(var(--text-label))]"> · </span>
<span className="text-[7px] text-text-secondary font-mono">{count}</span>
</div>
{/* Widget grid skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-48 w-full" />
{count > 0 ? (
<div className="divide-y divide-border-subtle">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between px-3 py-1.5 border-l-2"
style={{
borderLeftColor:
item.severity === 'error'
? 'hsl(var(--error))'
: 'hsl(var(--warning))',
}}
>
<div className="flex items-center gap-2 min-w-0">
<Link
to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId: item.tenantId, deviceId: item.deviceId }}
className="text-xs text-text-primary font-medium truncate hover:text-accent transition-[color] duration-[50ms]"
>
{item.hostname}
</Link>
<span className="text-[10px] text-text-secondary flex-shrink-0">
{item.model}
</span>
{item.hasCoords && (
<Link
to="/map"
className="text-text-muted hover:text-accent transition-[color] duration-[50ms] flex-shrink-0"
title="View on map"
>
<MapPin className="h-3 w-3" />
</Link>
)}
</div>
<span
className={cn(
'text-[10px] font-mono font-medium flex-shrink-0 ml-2',
item.severity === 'error' ? 'text-error' : 'text-warning',
)}
>
{item.reason}
</span>
</div>
))}
</div>
<div className="rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-32 w-full" />
) : (
<div className="py-5 text-center">
<span className="text-[9px] text-text-muted">No issues detected</span>
</div>
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-48 w-full" />
</div>
<div className="rounded-lg border border-border p-4 space-y-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-32 w-full" />
</div>
</div>
)}
</div>
)
}
@@ -96,9 +179,9 @@ export function FleetDashboard() {
isFetching: fleetFetching,
dataUpdatedAt,
} = useQuery({
queryKey: ['fleet-summary', isSuperAdmin ? 'all' : tenantId],
queryKey: ['fleet-summary', isSuperAdmin && !selectedTenantId ? 'all' : tenantId],
queryFn: () =>
isSuperAdmin
isSuperAdmin && !selectedTenantId
? metricsApi.fleetSummaryAll()
: metricsApi.fleetSummary(tenantId),
// Disable polling when SSE is connected (events update cache directly)
@@ -128,6 +211,15 @@ export function FleetDashboard() {
const onlinePercent =
totalDevices > 0 ? (onlineDevices.length / totalDevices) * 100 : 0
const degradedCount = useMemo(
() => fleetDevices?.filter((d) => d.status === 'degraded').length ?? 0,
[fleetDevices],
)
const offlineCount = useMemo(
() => fleetDevices?.filter((d) => d.status === 'offline').length ?? 0,
[fleetDevices],
)
// Alert counts
const alerts = alertsData?.items ?? []
const criticalCount = alerts.filter((a) => a.severity === 'critical').length
@@ -183,10 +275,10 @@ export function FleetDashboard() {
return (
<div className="space-y-6" data-testid="dashboard">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-between gap-4 pb-2.5 mb-3.5 border-b border-border-default">
<div>
<h1 className="text-xl font-semibold">Dashboard</h1>
<p className="text-sm text-text-muted mt-0.5">
<h1 className="text-sm font-semibold text-text-primary">Overview</h1>
<p className="text-[9px] text-text-muted mt-0.5">
Fleet overview across{' '}
{isSuperAdmin
? selectedTenantId && selectedTenantName
@@ -213,7 +305,7 @@ export function FleetDashboard() {
</span>
)}
{/* Refresh interval selector */}
<div className="flex items-center rounded-md border border-border bg-surface">
<div className="flex items-center rounded-md border border-border bg-panel">
{REFRESH_OPTIONS.map((opt) => (
<button
key={opt.label}
@@ -223,7 +315,7 @@ export function FleetDashboard() {
'px-2.5 py-1 text-xs font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
refreshInterval === opt.value
? 'bg-accent/15 text-accent'
? 'bg-accent-soft text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)}
>
@@ -236,7 +328,7 @@ export function FleetDashboard() {
{/* ── Dashboard Content ───────────────────────────────────────────── */}
{fleetLoading ? (
<DashboardSkeleton />
<DashboardLoading />
) : totalDevices === 0 ? (
<EmptyState
icon={LayoutDashboard}
@@ -245,13 +337,54 @@ export function FleetDashboard() {
/>
) : (
<>
{/* KPI Cards — full width, 4 columns */}
<KpiCards
totalDevices={totalDevices}
onlinePercent={onlinePercent}
activeAlerts={totalAlerts}
totalBandwidthBps={totalBandwidthBps}
/>
{/* Metrics Strip — joined 4-column bar */}
<div className="flex gap-px mb-3.5 bg-border-default rounded-sm overflow-hidden">
<div className="flex-1 bg-panel px-3 py-2">
<div className="text-lg font-medium font-mono text-text-primary">
{totalDevices}
</div>
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
Devices
</div>
</div>
<div className="flex-1 bg-panel px-3 py-2">
<div className="text-lg font-medium font-mono text-success">
{onlineDevices.length}
</div>
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
Online
</div>
</div>
<div className="flex-1 bg-panel px-3 py-2">
<div
className={cn(
'text-lg font-medium font-mono',
degradedCount > 0 ? 'text-warning' : 'text-text-primary',
)}
>
{degradedCount}
</div>
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
Degraded
</div>
</div>
<div className="flex-1 bg-panel px-3 py-2">
<div
className={cn(
'text-lg font-medium font-mono',
offlineCount > 0 ? 'text-error' : 'text-text-primary',
)}
>
{offlineCount}
</div>
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
Offline
</div>
</div>
</div>
{/* Needs Attention — full width */}
<NeedsAttention devices={fleetDevices ?? []} />
{/* Widget Grid — responsive 3 columns */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -92,7 +92,7 @@ function SortHeader({ column, label, currentSort, currentDir, onSort, className
function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: string }) {
return (
<div
className="w-full text-left rounded-lg border border-border bg-surface p-3 hover:bg-elevated/50 transition-colors min-h-[44px]"
className="w-full text-left rounded-lg border border-border bg-panel p-3 hover:bg-elevated/30 transition-[background-color] duration-[50ms] min-h-[44px]"
data-testid={`device-card-${device.hostname}`}
>
<div className="flex items-start justify-between gap-2">
@@ -345,7 +345,7 @@ export function FleetTable({
isFetching && !isLoading && 'opacity-70',
)}>
{isLoading ? (
<TableSkeleton rows={3} />
<TableSkeleton />
) : items.length === 0 ? (
<EmptyState
icon={Monitor}
@@ -402,7 +402,7 @@ export function FleetTable({
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={cn(
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
'border-b border-border/50 hover:bg-elevated/30 transition-[background-color] duration-[50ms]',
selectedIndex === virtualRow.index && 'bg-elevated/50',
)}
style={{
@@ -460,7 +460,7 @@ export function FleetTable({
key={device.id}
data-testid={`device-row-${device.hostname}`}
className={cn(
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
'border-b border-border/50 hover:bg-elevated/30 transition-[background-color] duration-[50ms]',
selectedIndex === idx && 'bg-elevated/50',
)}
>

View File

@@ -162,26 +162,26 @@ export function RemoteWinBoxButton({ tenantId, deviceId }: RemoteWinBoxButtonPro
if (state === 'idle' || state === 'failed' || state === 'terminated') {
return (
<div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
onClick={handleOpen}
disabled={createMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms] disabled:opacity-50"
title="Open Remote WinBox (browser)"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Globe className="h-4 w-4" />
<Globe className="h-3 w-3" />
)}
{createMutation.isPending ? 'Starting...' : 'Remote WinBox'}
{createMutation.isPending ? 'Starting' : 'Remote'}
</button>
<button
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
title="Reset all remote WinBox sessions for this device"
className="inline-flex items-center px-1.5 py-1 rounded-[var(--radius-control)] text-text-muted border border-border-default hover:border-accent transition-[border-color] duration-[50ms]"
title="Reset remote sessions"
>
<RefreshCw className="h-4 w-4" />
Reset
<RefreshCw className="h-3 w-3" />
</button>
</div>
{state === 'failed' && error && (

View File

@@ -162,10 +162,11 @@ export function SSHTerminal({ tenantId, deviceId, deviceName }: SSHTerminalProps
return (
<button
onClick={handleOpen}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90"
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms]"
title="Open SSH Terminal"
>
<TerminalIcon className="h-4 w-4" />
SSH Terminal
<TerminalIcon className="h-3 w-3" />
SSH
</button>
)
}

View File

@@ -104,7 +104,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<tr className="border-b border-border bg-panel">
<th className="px-3 py-2 w-8">
<Checkbox
checked={allSelected}
@@ -121,7 +121,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
{results.discovered.map((device) => (
<tr
key={device.ip_address}
className="border-b border-border/50 hover:bg-surface cursor-pointer"
className="border-b border-border/50 hover:bg-panel cursor-pointer"
onClick={() => toggleSelect(device.ip_address)}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
@@ -154,7 +154,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
{/* Credentials */}
{selected.size > 0 && (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">
Credentials for {selected.size} selected device{selected.size !== 1 ? 's' : ''}

View File

@@ -78,14 +78,15 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
openMutation.mutate()
}}
disabled={openMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms] disabled:opacity-50"
title="Open WinBox tunnel"
>
{openMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Monitor className="h-4 w-4" />
<Monitor className="h-3 w-3" />
)}
{openMutation.isPending ? 'Connecting...' : 'Open WinBox'}
{openMutation.isPending ? 'Connecting' : 'WinBox'}
</button>
{error && <p className="mt-2 text-sm text-error">{error}</p>}
</div>
@@ -94,18 +95,17 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
if (state === 'ready' && tunnelInfo) {
return (
<div className="rounded-md border p-4 space-y-3">
<p className="font-medium text-sm">WinBox tunnel ready</p>
<p className="text-sm text-text-muted">
Connect to: <code className="font-mono">{tunnelInfo.host}:{tunnelInfo.port}</code>
<div className="rounded-sm border border-border-default bg-panel p-2.5 space-y-2">
<p className="text-xs text-text-primary">
Tunnel ready: <code className="font-mono text-[10px] text-text-secondary">{tunnelInfo.host}:{tunnelInfo.port}</code>
</p>
<div className="flex gap-2">
<div className="flex gap-1">
<button
onClick={copyAddress}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border hover:bg-accent"
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-[var(--radius-control)] border border-border-default text-text-secondary hover:border-accent transition-[border-color] duration-[50ms]"
>
<Copy className="h-3 w-3" />
{copied ? 'Copied!' : 'Copy Address'}
<Copy className="h-2.5 w-2.5" />
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={() => {
@@ -113,15 +113,13 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
closeMutation.mutate()
}}
disabled={closeMutation.isPending}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border hover:bg-accent disabled:opacity-50"
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-[var(--radius-control)] border border-border-default text-text-muted hover:border-accent disabled:opacity-50 transition-[border-color] duration-[50ms]"
>
<X className="h-3 w-3" />
Close Tunnel
<X className="h-2.5 w-2.5" />
Close
</button>
</div>
<p className="text-xs text-text-muted">
Tunnel closes after 5 min of inactivity
</p>
<p className="text-[9px] text-text-muted">Closes after 5 min idle</p>
</div>
)
}

View File

@@ -1,15 +1,20 @@
import { type ReactNode } from 'react'
import { type ReactNode, useEffect } from 'react'
import { Sidebar } from './Sidebar'
import { ContextStrip } from './ContextStrip'
import { ShortcutsDialog } from './ShortcutsDialog'
import { CommandPalette } from '@/components/command-palette/CommandPalette'
import { Toaster } from '@/components/ui/toast'
import { useUIStore } from '@/lib/store'
interface AppLayoutProps {
children: ReactNode
}
export function AppLayout({ children }: AppLayoutProps) {
// Apply persisted UI scale on mount
const uiScale = useUIStore((s) => s.uiScale)
useEffect(() => {
document.documentElement.style.zoom = `${uiScale}%`
}, [uiScale])
return (
<div className="flex h-screen overflow-hidden bg-background">
<a
@@ -20,7 +25,6 @@ export function AppLayout({ children }: AppLayoutProps) {
</a>
<Sidebar />
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<ContextStrip />
<main id="main-content" tabIndex={-1} className="flex-1 overflow-auto p-5">
{children}
</main>

View File

@@ -1,266 +0,0 @@
import { useEffect } from 'react'
import { useNavigate, Link } from '@tanstack/react-router'
import { ChevronDown, Sun, Moon, LogOut, Settings, Menu } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useCommandPalette } from '@/components/command-palette/useCommandPalette'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAuth, isSuperAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { tenantsApi, metricsApi } from '@/lib/api'
import { getLicenseStatus } from '@/lib/settingsApi'
import { useEventStreamContext } from '@/contexts/EventStreamContext'
import type { ConnectionState } from '@/hooks/useEventStream'
import { NotificationBell } from '@/components/alerts/NotificationBell'
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
const CONNECTION_COLORS: Record<ConnectionState, string> = {
connected: 'bg-success',
connecting: 'bg-warning animate-pulse',
reconnecting: 'bg-warning animate-pulse',
disconnected: 'bg-error',
}
const CONNECTION_LABELS: Record<ConnectionState, string> = {
connected: 'Connected',
connecting: 'Connecting',
reconnecting: 'Reconnecting',
disconnected: 'Disconnected',
}
// Generate a deterministic color from a string
function tenantColor(name: string): string {
const colors = [
'bg-info', 'bg-success', 'bg-accent', 'bg-warning',
'bg-error', 'bg-info', 'bg-accent', 'bg-success',
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
export function ContextStrip() {
const { user, logout } = useAuth()
const { selectedTenantId, setSelectedTenantId, theme, setTheme, setMobileSidebarOpen } = useUIStore()
const { connectionState } = useEventStreamContext()
const navigate = useNavigate()
const superAdmin = isSuperAdmin(user)
// Tenant list (super_admin only)
const { data: tenants } = useQuery({
queryKey: ['tenants'],
queryFn: tenantsApi.list,
enabled: superAdmin,
select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID),
})
const selectedTenant = tenants?.find((t) => t.id === selectedTenantId)
// Auto-select when there's exactly one tenant and nothing selected
useEffect(() => {
if (superAdmin && tenants && tenants.length === 1 && !selectedTenantId) {
setSelectedTenantId(tenants[0].id)
}
}, [tenants, selectedTenantId, superAdmin, setSelectedTenantId])
// Fleet summary for status indicators
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
const { data: fleet } = useQuery({
queryKey: ['fleet-summary', superAdmin ? 'all' : tenantId],
queryFn: () =>
superAdmin && !selectedTenantId
? metricsApi.fleetSummaryAll()
: tenantId
? metricsApi.fleetSummary(tenantId)
: Promise.resolve([]),
enabled: !!tenantId || superAdmin,
refetchInterval: 30_000,
})
const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0
const degradedCount = fleet?.filter((d) => d.status === 'degraded').length ?? 0
// License status (super_admin only)
const { data: license } = useQuery({
queryKey: ['license-status'],
queryFn: getLicenseStatus,
enabled: superAdmin,
refetchInterval: 60_000,
})
const handleLogout = async () => {
await logout()
void navigate({ to: '/login' })
}
// User initials for avatar
const initials = user?.name
? user.name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase()
: user?.email?.slice(0, 2).toUpperCase() ?? '?'
// Tenant display name for non-super_admin
const tenantName = superAdmin
? (selectedTenant?.name ?? 'All Orgs')
: user?.name ?? 'Tenant'
return (
<div className="flex items-center h-9 bg-background/80 border-b border-border px-4 gap-4 flex-shrink-0">
{/* Mobile hamburger */}
<button
onClick={() => setMobileSidebarOpen(true)}
className="lg:hidden p-1 rounded-md text-text-muted hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
aria-label="Open menu"
>
<Menu className="h-4 w-4" />
</button>
{/* Left: Org switcher */}
<div className="flex items-center border-r border-border pr-4">
{superAdmin && tenants && tenants.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors">
<div
className={`w-4 h-4 rounded flex items-center justify-center text-[8px] font-bold text-white ${tenantColor(tenantName)}`}
>
{tenantName[0]?.toUpperCase()}
</div>
<span className="truncate max-w-[120px]">{tenantName}</span>
<ChevronDown className="h-3 w-3 flex-shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="text-xs">Organization</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSelectedTenantId(null)} className="text-xs">
All Orgs
</DropdownMenuItem>
{tenants.map((tenant) => (
<DropdownMenuItem
key={tenant.id}
onClick={() => setSelectedTenantId(tenant.id)}
className="text-xs"
>
<div
className={`w-3 h-3 rounded flex items-center justify-center text-[7px] font-bold text-white mr-1.5 ${tenantColor(tenant.name)}`}
>
{tenant.name[0]?.toUpperCase()}
</div>
{tenant.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-xs text-text-secondary truncate max-w-[120px]">
{superAdmin ? 'No orgs' : (user?.name ?? 'Tenant')}
</span>
)}
</div>
{/* Center: Status indicators */}
<div className="flex-1 hidden sm:flex items-center gap-3">
{fleet ? (
<>
{offlineCount > 0 && (
<button
onClick={() => void navigate({ to: '/' })}
className="flex items-center gap-1 text-xs text-error hover:text-error/80 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full bg-error" />
{offlineCount} down
</button>
)}
{degradedCount > 0 && (
<button
onClick={() => void navigate({ to: '/' })}
className="flex items-center gap-1 text-xs text-warning hover:text-warning/80 transition-colors"
>
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
{degradedCount} degraded
</button>
)}
{offlineCount === 0 && degradedCount === 0 && fleet.length > 0 && (
<span className="text-xs text-text-muted">All systems nominal</span>
)}
{fleet.length === 0 && (
<span className="text-xs text-text-muted">No devices</span>
)}
</>
) : (
<span className="text-xs text-text-muted">Status loading...</span>
)}
{license?.over_limit && (
<span className="text-xs font-mono text-error animate-pulse">
{license.actual_devices}/{license.licensed_devices} licensed
</span>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2 ml-auto">
{/* Notification bell */}
{tenantId && <NotificationBell tenantId={tenantId} />}
{/* Command palette shortcut */}
<button
onClick={() => useCommandPalette.getState().setOpen(true)}
className="text-[10px] font-mono text-text-muted hover:text-text-secondary border border-border rounded px-1.5 py-0.5 transition-colors"
aria-label="Open command palette"
>
&#8984;K
</button>
{/* Connection status dot */}
<div
className={`w-1.5 h-1.5 rounded-full ${CONNECTION_COLORS[connectionState]}`}
role="status"
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
title={CONNECTION_LABELS[connectionState]}
/>
{/* Theme toggle */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
{/* User avatar dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center" aria-label="User menu">
<div className="w-[22px] h-[22px] rounded-full bg-elevated flex items-center justify-center text-[9px] font-semibold text-text-secondary">
{initials}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="text-xs font-normal text-text-secondary">{user?.email}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void handleLogout()} className="text-error text-xs">
<LogOut className="h-3.5 w-3.5" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@@ -1,59 +1,197 @@
import { useEffect, useRef } from 'react'
import { APP_VERSION } from '@/lib/version'
import { Link, useRouterState } from '@tanstack/react-router'
import { Link, useRouterState, useNavigate } from '@tanstack/react-router'
import {
Monitor,
Building2,
Users,
Settings,
ChevronLeft,
ChevronRight,
Download,
LayoutDashboard,
Wifi,
MapPin,
Bell,
Map,
Terminal,
FileCode,
LayoutDashboard,
ClipboardList,
Wifi,
BarChart3,
MapPin,
ShieldCheck,
KeyRound,
Info,
Bell,
Network,
Map,
Layers,
Download,
Wrench,
ClipboardList,
BellRing,
Calendar,
FileBarChart,
Eye,
BellRing,
ShieldCheck,
KeyRound,
Sun,
Moon,
LogOut,
ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { useEventStreamContext } from '@/contexts/EventStreamContext'
import { alertEventsApi, tenantsApi } from '@/lib/api'
import { useQuery } from '@tanstack/react-query'
import { RugLogo } from '@/components/brand/RugLogo'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { ConnectionState } from '@/hooks/useEventStream'
// ─── Types ──────────────────────────────────────────────────────────────────
interface NavItem {
label: string
href: string
icon: React.FC<{ className?: string }>
exact?: boolean
badge?: number
}
interface NavSection {
label: string
items: NavItem[]
visible: boolean
// ─── Constants ──────────────────────────────────────────────────────────────
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
const CONNECTION_LABELS: Record<ConnectionState, string> = {
connected: 'Connected',
connecting: 'Connecting',
reconnecting: 'Reconnecting',
disconnected: 'Disconnected',
}
// ─── Styles ─────────────────────────────────────────────────────────────────
const navItemBase =
'flex items-center gap-2 text-[13px] py-[5px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear'
const navItemInactive =
'text-text-secondary hover:border-accent'
const navItemActive =
'text-text-primary font-medium border-accent bg-accent-soft rounded-r-sm'
const lowFreqBase =
'flex items-center gap-2 text-[13px] text-text-muted py-[3px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
const iconClass = 'h-4 w-4 text-text-muted flex-shrink-0'
// ─── Component ──────────────────────────────────────────────────────────────
export function Sidebar() {
const { user } = useAuth()
const { sidebarCollapsed, toggleSidebar, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore()
const { user, logout } = useAuth()
const {
sidebarCollapsed,
toggleSidebar,
mobileSidebarOpen,
setMobileSidebarOpen,
selectedTenantId,
setSelectedTenantId,
theme,
setTheme,
uiScale,
setUIScale,
} = useUIStore()
const { connectionState } = useEventStreamContext()
const routerState = useRouterState()
const currentPath = routerState.location.pathname
const navigate = useNavigate()
const navRef = useRef<HTMLElement>(null)
const superAdmin = isSuperAdmin(user)
const tenantAdmin = isTenantAdmin(user)
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
// ─── Queries ────────────────────────────────────────────────────────────
const { data: tenants } = useQuery({
queryKey: ['tenants'],
queryFn: tenantsApi.list,
enabled: superAdmin,
select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID),
})
const selectedTenant = tenants?.find((t) => t.id === selectedTenantId)
// Auto-select when there's exactly one tenant and nothing selected
useEffect(() => {
if (superAdmin && tenants && tenants.length === 1 && !selectedTenantId) {
setSelectedTenantId(tenants[0].id)
}
}, [tenants, selectedTenantId, superAdmin, setSelectedTenantId])
const { data: alertCount } = useQuery({
queryKey: ['alert-active-count', tenantId],
queryFn: () => alertEventsApi.activeCount(tenantId!),
enabled: !!tenantId,
refetchInterval: 30_000,
})
// ─── Tenant display name ───────────────────────────────────────────────
const tenantName = superAdmin
? (selectedTenant?.name ?? 'All Orgs')
: (user?.name ?? user?.email ?? 'Tenant')
// ─── Nav items ────────────────────────────────────────────────────────
const operateItems: NavItem[] = [
{ label: 'Overview', href: '/', icon: LayoutDashboard, exact: true },
...(!superAdmin && user?.tenant_id
? [
{ label: 'Devices', href: `/tenants/${user.tenant_id}/devices`, icon: Monitor },
{ label: 'Sites', href: `/tenants/${user.tenant_id}/sites`, icon: MapPin },
]
: []),
{
label: 'Alerts',
href: '/alerts',
icon: Bell,
badge: alertCount && alertCount > 0 ? alertCount : undefined,
},
...(!superAdmin && user?.tenant_id
? [{ label: 'Wireless', href: `/tenants/${user.tenant_id}/wireless-links`, icon: Wifi }]
: [{ label: 'Wireless', href: '/wireless', icon: Wifi }]
),
{ label: 'Map', href: '/map', icon: Map },
]
const actItems: NavItem[] = [
{ label: 'Config', href: '/config-editor', icon: Terminal },
{ label: 'Templates', href: '/templates', icon: FileCode },
{ label: 'Firmware', href: '/firmware', icon: Download },
{ label: 'Commands', href: '/bulk-commands', icon: Wrench },
]
const lowFreqItems: NavItem[] = [
...(superAdmin || tenantAdmin
? [{ label: 'Organizations', href: '/tenants', icon: Building2 }]
: []),
...(tenantAdmin && user?.tenant_id
? [{ label: 'Users', href: `/tenants/${user.tenant_id}/users`, icon: Users }]
: []),
{ label: 'Certificates', href: '/certificates', icon: ShieldCheck },
{ label: 'VPN', href: '/vpn', icon: KeyRound },
{ label: 'Alert Rules', href: '/alert-rules', icon: BellRing },
{ label: 'Maintenance', href: '/maintenance', icon: Calendar },
{ label: 'Settings', href: '/settings', icon: Settings },
{ label: 'Audit Log', href: '/audit', icon: ClipboardList },
{ label: 'Reports', href: '/reports', icon: FileBarChart },
]
// ─── Active state ─────────────────────────────────────────────────────
const isActive = (item: NavItem) => {
if (item.exact) return currentPath === item.href
if (item.href === '/settings')
return currentPath === '/settings' || currentPath.startsWith('/settings/')
return currentPath.startsWith(item.href) && item.href.length > 1
}
// ─── Focus trap for mobile ───────────────────────────────────────────
// Mobile sidebar focus trap
useEffect(() => {
if (!mobileSidebarOpen) return
@@ -86,9 +224,8 @@ export function Sidebar() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [mobileSidebarOpen, setMobileSidebarOpen])
const navRef = useRef<HTMLElement>(null)
// ─── Keyboard shortcut: [ to toggle ───────────────────────────────────
// Keyboard toggle: [ key collapses/expands sidebar
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
@@ -105,262 +242,253 @@ export function Sidebar() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
const sections: NavSection[] = [
{
label: 'Fleet',
visible: true,
items: [
{
label: 'Overview',
href: '/',
icon: LayoutDashboard,
exact: true,
},
// Only show Devices for non-super_admin with a tenant_id
...(!isSuperAdmin(user) && user?.tenant_id
? [
{
label: 'Devices',
href: `/tenants/${user.tenant_id}/devices`,
icon: Monitor,
},
]
: []),
...(!isSuperAdmin(user) && user?.tenant_id
? [
{
label: 'Sites',
href: `/tenants/${user.tenant_id}/sites`,
icon: MapPin,
},
]
: []),
...(!isSuperAdmin(user) && user?.tenant_id
? [{
label: 'Wireless Links',
href: `/tenants/${user.tenant_id}/wireless-links`,
icon: Wifi,
}]
: [{
label: 'Wireless Links',
href: '/wireless',
icon: Wifi,
}]
),
{
label: 'Traffic',
href: '/traffic',
icon: BarChart3,
},
{
label: 'Alerts',
href: '/alerts',
icon: Bell,
},
{
label: 'Topology',
href: '/topology',
icon: Network,
},
{
label: 'Map',
href: '/map',
icon: Map,
},
],
},
{
label: 'Config',
visible: true,
items: [
{
label: 'Editor',
href: '/config-editor',
icon: Terminal,
},
{
label: 'Templates',
href: '/templates',
icon: FileCode,
},
{
label: 'Firmware',
href: '/firmware',
icon: Download,
},
{
label: 'Certificates',
href: '/certificates',
icon: ShieldCheck,
},
{
label: 'VPN',
href: '/vpn',
icon: KeyRound,
},
{
label: 'Batch Config',
href: '/batch-config',
icon: Layers,
},
{
label: 'Bulk Commands',
href: '/bulk-commands',
icon: Wrench,
},
],
},
{
label: 'Admin',
visible: isSuperAdmin(user) || isTenantAdmin(user),
items: [
...(isTenantAdmin(user) && user?.tenant_id
? [
{
label: 'Users',
href: `/tenants/${user.tenant_id}/users`,
icon: Users,
},
]
: []),
...(isSuperAdmin(user) || isTenantAdmin(user)
? [
{
label: 'Organizations',
href: '/tenants',
icon: Building2,
},
]
: []),
{
label: 'Audit Log',
href: '/audit',
icon: ClipboardList,
},
{
label: 'Settings',
href: '/settings',
icon: Settings,
},
{
label: 'Alert Rules',
href: '/alert-rules',
icon: BellRing,
},
{
label: 'Maintenance',
href: '/maintenance',
icon: Calendar,
},
{
label: 'Reports',
href: '/reports',
icon: FileBarChart,
},
{
label: 'Transparency',
href: '/transparency',
icon: Eye,
},
{
label: 'About',
href: '/about',
icon: Info,
},
],
},
]
// ─── Logout handler ───────────────────────────────────────────────────
const visibleSections = sections.filter((s) => s.visible)
const isActive = (item: NavItem) => {
if (item.exact) return currentPath === item.href
// Settings should only match exact to avoid catching everything
if (item.href === '/settings') return currentPath === '/settings' || currentPath.startsWith('/settings/')
return currentPath.startsWith(item.href) && item.href.length > 1
const handleLogout = async () => {
await logout()
void navigate({ to: '/login' })
}
const sidebarContent = (showCollapsed: boolean) => (
// ─── Render helpers ───────────────────────────────────────────────────
const renderNavItem = (item: NavItem, collapsed: boolean) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileSidebarOpen(false)}
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
className={cn(
collapsed
? 'flex items-center justify-center py-[5px] px-2 border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
: navItemBase,
active
? navItemActive
: collapsed
? 'text-text-secondary'
: navItemInactive,
)}
title={collapsed ? item.label : undefined}
aria-label={collapsed ? item.label : undefined}
aria-current={active ? 'page' : undefined}
>
<Icon className={iconClass} aria-hidden="true" />
{!collapsed && (
<span className="truncate flex-1">{item.label}</span>
)}
{!collapsed && item.badge !== undefined && item.badge > 0 && (
<span className="text-[8px] font-semibold font-mono bg-alert-badge text-background px-1.5 rounded-sm leading-4">
{item.badge}
</span>
)}
</Link>
)
}
const renderLowFreqItem = (item: NavItem, collapsed: boolean) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileSidebarOpen(false)}
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
className={cn(
collapsed
? 'flex items-center justify-center py-[3px] px-2 border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
: lowFreqBase,
active && navItemActive,
)}
title={collapsed ? item.label : undefined}
aria-label={collapsed ? item.label : undefined}
aria-current={active ? 'page' : undefined}
>
<Icon className={iconClass} aria-hidden="true" />
{!collapsed && <span className="truncate">{item.label}</span>}
</Link>
)
}
// ─── Sidebar content ─────────────────────────────────────────────────
const sidebarContent = (collapsed: boolean) => (
<>
{/* Logo */}
{/* Logo area */}
<div
className={cn(
'flex items-center h-12 px-3 border-b border-border',
showCollapsed ? 'justify-center' : 'gap-2',
'flex items-center border-b border-border-subtle px-3 py-2',
collapsed ? 'justify-center h-12' : 'gap-2 min-h-[48px]',
)}
>
<RugLogo size={showCollapsed ? 24 : 28} className="flex-shrink-0" />
{!showCollapsed && (
<span className="text-sm font-semibold text-text-primary truncate">
TOD
</span>
<RugLogo size={collapsed ? 24 : 28} className="flex-shrink-0" />
{!collapsed && (
<div className="min-w-0">
<span className="text-sm font-semibold text-text-primary">TOD</span>
<div className="text-[8px] text-text-muted truncate">{tenantName}</div>
</div>
)}
</div>
{/* Navigation */}
<nav ref={navRef} data-slot="fleet-nav" className="flex-1 py-2 overflow-y-auto">
{visibleSections.map((section, sectionIdx) => (
<div key={section.label}>
{showCollapsed && sectionIdx > 0 && (
<div className="mx-2 my-1 border-t border-border" />
)}
{!showCollapsed && (
<div className="px-3 pt-4 pb-1 text-[10px] font-semibold uppercase tracking-wider text-text-muted">
{section.label}
</div>
)}
{section.items.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={`${section.label}-${item.label}`}
to={item.href}
onClick={() => setMobileSidebarOpen(false)}
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
className={cn(
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded-md text-sm transition-colors min-h-[44px]',
active
? 'bg-[hsl(var(--accent-muted))] text-accent rounded-md'
: 'text-text-muted hover:text-text-primary hover:bg-elevated/50 rounded-md',
showCollapsed && 'justify-center px-0',
)}
title={showCollapsed ? item.label : undefined}
aria-label={showCollapsed ? item.label : undefined}
aria-current={active ? 'page' : undefined}
{/* Tenant selector (super_admin only) */}
{superAdmin && !collapsed && tenants && tenants.length > 0 && (
<div className="px-3 py-2 border-b border-border-subtle">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 w-full text-[11px] text-text-secondary hover:text-text-primary transition-[color] duration-[50ms] linear">
<span className="truncate flex-1 text-left">{tenantName}</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-text-muted" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem
onClick={() => setSelectedTenantId(null)}
className="text-xs"
>
All Orgs
</DropdownMenuItem>
{tenants.map((tenant) => (
<DropdownMenuItem
key={tenant.id}
onClick={() => setSelectedTenantId(tenant.id)}
className="text-xs"
>
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
{!showCollapsed && (
<span className="truncate">{item.label}</span>
)}
</Link>
)
})}
</div>
))}
</nav>
{/* Version identifier */}
{!showCollapsed && (
<div className="px-3 py-1 text-center">
<span className="font-mono text-[9px] text-text-muted">TOD {APP_VERSION}</span>
{tenant.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{superAdmin && collapsed && (
<div className="border-b border-border-subtle" />
)}
{/* Collapse toggle (hidden on mobile) */}
<button
onClick={toggleSidebar}
className="hidden lg:flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-secondary transition-colors"
title={showCollapsed ? 'Expand sidebar ([)' : 'Collapse sidebar ([)'}
aria-label={showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-testid="sidebar-toggle"
>
{showCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
{/* Main navigation */}
<nav ref={navRef} data-slot="fleet-nav" className="flex-1 overflow-y-auto py-2">
{/* operate section */}
{!collapsed && (
<div className="text-[7px] uppercase tracking-[3px] text-text-label pl-[10px] mb-2">
operate
</div>
)}
</button>
{operateItems.map((item) => renderNavItem(item, collapsed))}
{/* Hairline separator */}
<div className="mx-2 my-2 border-t border-border-subtle" />
{/* act section */}
{!collapsed && (
<div className="text-[7px] uppercase tracking-[3px] text-text-label pl-[10px] mb-2">
act
</div>
)}
{actItems.map((item) => renderNavItem(item, collapsed))}
{/* Low-frequency items separator */}
<div className="mx-2 my-2 border-t border-border-subtle" />
{/* Low-frequency items */}
{lowFreqItems.map((item) => renderLowFreqItem(item, collapsed))}
</nav>
{/* Footer */}
<div className="border-t border-border-subtle px-3 py-2">
{!collapsed ? (
<>
{/* User row: email + theme + logout */}
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[8px] text-text-muted truncate flex-1">
{user?.email}
</span>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<Sun className="h-3 w-3" />
) : (
<Moon className="h-3 w-3" />
)}
</button>
<button
onClick={() => void handleLogout()}
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
aria-label="Sign out"
>
<LogOut className="h-3 w-3" />
</button>
</div>
{/* Scale selector */}
<div className="flex items-center gap-px rounded-[var(--radius-control)] border border-border-subtle overflow-hidden mb-1">
{([100, 110, 125] as const).map((s) => (
<button
key={s}
onClick={() => setUIScale(s)}
className={cn(
'flex-1 text-[8px] py-px text-center transition-[background-color,color] duration-[50ms]',
uiScale === s
? 'bg-accent-soft text-text-primary font-medium'
: 'text-text-muted hover:text-text-secondary',
)}
>
{s}%
</button>
))}
</div>
{/* Connection + version row */}
<div className="flex items-center gap-1.5">
<span
className={cn(
'w-[5px] h-[5px] rounded-full flex-shrink-0',
connectionState === 'connected' ? 'bg-online' : 'bg-offline',
(connectionState === 'connecting' || connectionState === 'reconnecting') && 'animate-pulse',
)}
role="status"
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
/>
<span className="text-[8px] text-text-muted">
{CONNECTION_LABELS[connectionState]}
</span>
<span className="text-[8px] text-text-muted font-mono ml-auto">
{APP_VERSION}
</span>
</div>
</>
) : (
/* Collapsed footer: just connection dot and theme toggle */
<div className="flex flex-col items-center gap-2">
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<Sun className="h-3 w-3" />
) : (
<Moon className="h-3 w-3" />
)}
</button>
<button
onClick={() => void handleLogout()}
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
aria-label="Sign out"
>
<LogOut className="h-3 w-3" />
</button>
<span
className={cn(
'w-[5px] h-[5px] rounded-full',
connectionState === 'connected' ? 'bg-online' : 'bg-offline',
(connectionState === 'connecting' || connectionState === 'reconnecting') && 'animate-pulse',
)}
role="status"
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
/>
</div>
)}
</div>
</>
)
@@ -371,13 +499,15 @@ export function Sidebar() {
data-testid="sidebar"
data-sidebar
className={cn(
'hidden lg:flex flex-col border-r border-border bg-sidebar transition-all duration-200',
sidebarCollapsed ? 'w-14' : 'w-[180px]',
'hidden lg:flex flex-col border-r border-border-default bg-sidebar transition-[width] duration-200',
sidebarCollapsed ? 'w-14' : 'w-[172px]',
)}
>
{sidebarContent(sidebarCollapsed)}
</aside>
{/* Mobile hamburger (rendered outside sidebar for AppLayout) */}
{/* Mobile overlay */}
{mobileSidebarOpen && (
<>
@@ -390,7 +520,7 @@ export function Sidebar() {
role="dialog"
aria-modal="true"
aria-label="Navigation"
className="lg:hidden fixed inset-y-0 left-0 z-50 w-[180px] flex flex-col bg-sidebar border-r border-border"
className="lg:hidden fixed inset-y-0 left-0 z-50 w-[172px] flex flex-col bg-sidebar border-r border-border-default"
>
{sidebarContent(false)}
</aside>

View File

@@ -99,13 +99,8 @@ export function MaintenanceList({ tenantId }: MaintenanceListProps) {
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-20 rounded-lg border border-border bg-surface animate-pulse"
/>
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
)
}
@@ -266,7 +261,7 @@ function WindowCard({
return (
<div
className={`rounded-lg border border-border bg-surface p-3 ${
className={`rounded-lg border border-border bg-panel p-3 ${
isPast ? 'opacity-60' : ''
}`}
>

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { MapPin } from 'lucide-react'
import { metricsApi, tenantsApi } from '@/lib/api'
import { useAuth, isSuperAdmin } from '@/lib/auth'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import { FleetMap } from './FleetMap'
export function MapPage() {
@@ -55,7 +55,11 @@ export function MapPage() {
}, [superAdmin, selectedTenant, user])
if (devicesLoading) {
return <Skeleton className="h-[calc(100vh-8rem)] w-full rounded-lg" />
return (
<div className="flex items-center justify-center h-[calc(100vh-8rem)]">
<LoadingText />
</div>
)
}
if (devicesError) {
@@ -88,7 +92,7 @@ export function MapPage() {
<select
value={selectedTenant}
onChange={(e) => setSelectedTenant(e.target.value)}
className="text-xs bg-elevated/50 border border-border text-text-primary rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-border-bright"
className="text-xs bg-elevated/50 border border-border text-text-primary rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-border-default"
>
<option value="all">All Organizations</option>
{tenants.map((t) => (

View File

@@ -33,7 +33,7 @@ function CustomTooltip({
}: { active?: boolean; payload?: Array<{ value?: number }>; label?: string; unit: string }) {
if (!active || !payload?.length) return null
return (
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary">
<div className="rounded border border-border bg-panel px-2 py-1.5 text-xs text-text-primary">
<div className="mb-1 text-text-muted">{label}</div>
<div>
{(payload[0].value ?? 0).toFixed(1)}
@@ -64,10 +64,10 @@ export function HealthChart({ data, metric, label, color, unit, maxY }: HealthCh
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 9, fill: '#94a3b8' }}
tick={{ fontSize: 9, fill: 'hsl(var(--text-muted))' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
@@ -75,7 +75,7 @@ export function HealthChart({ data, metric, label, color, unit, maxY }: HealthCh
<YAxis
domain={domain}
tickFormatter={(v: number) => `${v}${unit}`}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tick={{ fontSize: 9, fill: 'hsl(var(--text-muted))' }}
axisLine={false}
tickLine={false}
width={40}

View File

@@ -41,28 +41,26 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
/>
{isLoading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-44 animate-pulse" />
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : !data || data.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
No health metrics data available for the selected time range.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<HealthChart
data={data}
metric="avg_cpu"
label="CPU Load"
color="#38BDF8"
color="hsl(var(--accent))"
unit="%"
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<HealthChart
data={data}
metric="avg_mem_pct"
@@ -72,7 +70,7 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<HealthChart
data={data}
metric="avg_disk_pct"
@@ -82,7 +80,7 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
maxY={100}
/>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<div className="rounded-lg border border-border bg-panel p-4">
<HealthChart
data={data}
metric="avg_temp"

View File

@@ -87,13 +87,11 @@ export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesT
{/* Charts */}
{isLoading ? (
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-56 animate-pulse" />
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : !trafficData || trafficData.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
{interfaces && interfaces.length === 0
? 'No interfaces discovered for this device.'
: 'No traffic data available for the selected time range.'}
@@ -103,7 +101,7 @@ export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesT
{interfaceNames.map((ifaceName) => {
const ifaceData = byInterface.get(ifaceName) ?? []
return (
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-4">
<div key={ifaceName} className="rounded-lg border border-border bg-panel p-4">
<TrafficChart data={ifaceData} interfaceName={ifaceName} />
</div>
)

View File

@@ -7,7 +7,7 @@ interface SparklineProps {
height?: number
}
export function Sparkline({ data, color = '#38BDF8', width = 60, height = 24 }: SparklineProps) {
export function Sparkline({ data, color = 'hsl(var(--accent))', width = 60, height = 24 }: SparklineProps) {
const chartData = data.map((v, i) => ({ v, i }))
return (
<LineChart width={width} height={height} data={chartData}>

View File

@@ -101,7 +101,7 @@ export function TimeRangeSelector({
className={cn(
'px-2.5 py-1 text-xs rounded border transition-colors',
value === preset
? 'bg-elevated border-border-bright text-text-primary'
? 'bg-elevated border-border-default text-text-primary'
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
)}
>
@@ -113,7 +113,7 @@ export function TimeRangeSelector({
className={cn(
'px-2.5 py-1 text-xs rounded border transition-colors',
value === 'custom'
? 'bg-elevated border-border-bright text-text-primary'
? 'bg-elevated border-border-default text-text-primary'
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
)}
>

View File

@@ -36,7 +36,7 @@ function formatBucket(bucket: string, useDate: boolean): string {
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value?: number; dataKey?: string; name?: string; color?: string }>; label?: string }) {
if (!active || !payload?.length) return null
return (
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary">
<div className="rounded border border-border bg-panel px-2 py-1.5 text-xs text-text-primary">
<div className="mb-1 text-text-muted">{label}</div>
{payload.map((entry) => (
<div key={entry.dataKey} className="flex items-center gap-2">
@@ -70,25 +70,25 @@ export function TrafficChart({ data, interfaceName }: TrafficChartProps) {
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`rx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#38BDF8" stopOpacity={0.3} />
<stop offset="100%" stopColor="#38BDF8" stopOpacity={0} />
<stop offset="0%" stopColor="hsl(var(--accent))" stopOpacity={0.3} />
<stop offset="100%" stopColor="hsl(var(--accent))" stopOpacity={0} />
</linearGradient>
<linearGradient id={`tx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#4ADE80" stopOpacity={0.3} />
<stop offset="100%" stopColor="#4ADE80" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatBps}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
axisLine={false}
tickLine={false}
width={60}
@@ -98,7 +98,7 @@ export function TrafficChart({ data, interfaceName }: TrafficChartProps) {
type="monotone"
dataKey="avg_rx_bps"
name="avg_rx_bps"
stroke="#38BDF8"
stroke="hsl(var(--accent))"
strokeWidth={1.5}
fill={`url(#rx-grad-${interfaceName})`}
/>

View File

@@ -38,7 +38,7 @@ function ClientCountMiniChart({ data }: { data: WirelessMetricPoint[] }) {
<stop offset="100%" stopColor="#A78BFA" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
<XAxis dataKey="bucket" hide />
<Area
type="monotone"
@@ -57,7 +57,7 @@ function WirelessInterfaceCard({ section }: { section: WirelessInterfaceSection
const { interfaceName, latest, history } = section
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
{/* Interface name header */}
<h3 className="text-sm font-medium text-text-primary">{interfaceName}</h3>
@@ -159,13 +159,11 @@ export function WirelessTab({ tenantId, deviceId, active = true }: WirelessTabPr
/>
{isLoading ? (
<div className="space-y-4">
{[0, 1].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-48 animate-pulse" />
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
) : hasNoWireless ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
No wireless interfaces detected on this device.
</div>
) : (

View File

@@ -171,7 +171,7 @@ export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
// Loading state
if (isLoading) {
return <TableSkeleton rows={8} />
return <TableSkeleton />
}
// Error state
@@ -246,7 +246,7 @@ export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface text-text-secondary text-left">
<tr className="bg-panel text-text-secondary text-left">
{/* Expand chevron column */}
<th className="w-8 px-3 py-2.5" />

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
interface InterfaceGaugesProps {
tenantId: string
@@ -101,21 +101,15 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
if (isLoading) {
return (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-3">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-3 w-full mb-1" />
<Skeleton className="h-3 w-full" />
</div>
))}
<div className="py-8 text-center">
<LoadingText />
</div>
)
}
if (!interfaces || interfaces.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
No interface data available.
</div>
)
@@ -149,7 +143,7 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
const values = latestByIface.get(ifaceName) ?? { rx: 0, tx: 0 }
return (
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-3">
<div key={ifaceName} className="rounded-lg border border-border bg-panel p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-text-primary">{ifaceName}</span>
<span className="text-[10px] text-text-muted">

View File

@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, RefreshCw } from 'lucide-react'
import { networkApi, type LogEntry } from '@/lib/networkApi'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
interface LogsTabProps {
tenantId: string
@@ -62,16 +62,10 @@ function TopicBadge({ topics }: { topics: string }) {
)
}
function TableSkeleton() {
function TableLoading() {
return (
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-3 py-2 px-3">
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="h-4 w-20 shrink-0" />
<Skeleton className="h-4 flex-1" />
</div>
))}
<div className="py-8 text-center">
<LoadingText />
</div>
)
}
@@ -187,9 +181,9 @@ export function LogsTab({ tenantId, deviceId, active }: LogsTabProps) {
</div>
{/* Log table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{isLoading ? (
<TableSkeleton />
<TableLoading />
) : error ? (
<div className="p-6 text-center text-sm text-error">
Failed to fetch device logs. The device may be offline or unreachable.

View File

@@ -99,7 +99,7 @@ function DeviceNode({ data }: NodeProps<DeviceNodeData>) {
return (
<div
className={cn(
'rounded-lg border bg-surface px-3 py-2 min-w-[180px]',
'rounded-lg border bg-panel px-3 py-2 min-w-[180px]',
'transition-colors',
isOnline ? 'border-border' : 'border-error/30',
)}
@@ -339,7 +339,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
>
<Background color="hsl(var(--muted))" gap={20} size={1} />
<Controls
className="!bg-surface !border-border [&>button]:!bg-surface [&>button]:!border-border [&>button]:!text-text-secondary [&>button:hover]:!bg-elevated"
className="!bg-panel !border-border [&>button]:!bg-panel [&>button]:!border-border [&>button]:!text-text-secondary [&>button:hover]:!bg-elevated"
/>
<MiniMap
nodeColor={(node) => {
@@ -349,7 +349,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
: 'hsl(var(--error))'
}}
maskColor="hsl(var(--background) / 0.7)"
className="!bg-surface !border-border"
className="!bg-panel !border-border"
/>
</ReactFlow>
@@ -357,7 +357,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
{tooltip && <NodeTooltip data={tooltip} onClose={() => setTooltip(null)} />}
{/* Legend */}
<div className="absolute bottom-4 left-4 rounded-lg border border-border bg-surface/90 backdrop-blur-sm px-3 py-2 text-xs text-text-muted">
<div className="absolute bottom-4 left-4 rounded-lg border border-border bg-panel/90 backdrop-blur-sm px-3 py-2 text-xs text-text-muted">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-success" /> Online

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { Shield, Lock, Globe } from 'lucide-react'
import { networkApi, type VpnTunnel } from '@/lib/networkApi'
import { Skeleton } from '@/components/ui/skeleton'
import { LoadingText } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
interface VpnTabProps {
@@ -26,17 +26,17 @@ const VPN_TYPE_CONFIG = {
wireguard: {
icon: Shield,
label: 'WireGuard',
color: '#a855f7', // purple
color: 'hsl(var(--accent))',
},
ipsec: {
icon: Lock,
label: 'IPsec',
color: '#3b82f6', // blue
color: 'hsl(var(--info))',
},
l2tp: {
icon: Globe,
label: 'L2TP',
color: '#22c55e', // green
color: 'hsl(var(--success))',
},
} as const
@@ -102,17 +102,15 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
if (isLoading) {
return (
<div className="mt-4 space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<div className="mt-4 py-8 text-center">
<LoadingText />
</div>
)
}
if (error) {
return (
<div className="mt-4 rounded-lg border border-border bg-surface p-6 text-center text-sm text-error">
<div className="mt-4 rounded-lg border border-border bg-panel p-6 text-center text-sm text-error">
Failed to load VPN tunnels. The device may not support this feature.
</div>
)
@@ -120,7 +118,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
if (!data || data.tunnels.length === 0) {
return (
<div className="mt-4 rounded-lg border border-border bg-surface p-8 text-center">
<div className="mt-4 rounded-lg border border-border bg-panel p-8 text-center">
<Shield className="w-10 h-10 mx-auto mb-3 text-text-muted opacity-40" />
<p className="text-sm font-medium text-text-primary mb-1">
No active VPN tunnels
@@ -134,7 +132,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
}
return (
<div className="mt-4 rounded-lg border border-border bg-surface overflow-hidden">
<div className="mt-4 rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-border bg-elevated/50">

View File

@@ -251,7 +251,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
{/* Step 2: Enter Command */}
{step === 2 && (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Enter RouterOS Command</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -294,7 +294,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
{/* Step 3: Review & Execute */}
{step === 3 && (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Review & Execute</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -336,7 +336,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<tr className="border-b border-border bg-panel">
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">
Device
</th>
@@ -611,11 +611,11 @@ function DeviceSelectionStep({
}
if (devicesLoading) {
return <TableSkeleton rows={5} />
return <TableSkeleton />
}
return (
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
<div>
<h3 className="text-sm font-semibold">Select Target Devices</h3>
<p className="text-xs text-text-muted mt-0.5">
@@ -641,7 +641,7 @@ function DeviceSelectionStep({
className={cn(
'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors',
mode === opt.value
? 'bg-surface text-text-primary shadow-sm'
? 'bg-panel text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary',
)}
>
@@ -699,7 +699,7 @@ function DeviceSelectionStep({
<div className="rounded-md border border-border/50 overflow-hidden max-h-72 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0">
<tr className="border-b border-border bg-surface">
<tr className="border-b border-border bg-panel">
<th className="px-3 py-2 w-8">
<Checkbox
checked={

View File

@@ -122,8 +122,8 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
className={cn(
'flex items-start gap-3 p-4 rounded-lg border text-left transition-all',
isSelected
? 'border-accent bg-accent-muted/30 ring-1 ring-accent'
: 'border-border bg-surface hover:border-text-muted',
? 'border-accent bg-accent-soft/30 ring-1 ring-accent'
: 'border-border bg-panel hover:border-text-muted',
)}
>
<div
@@ -172,7 +172,7 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full h-9 rounded-md border border-border bg-surface px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="w-full h-9 rounded-md border border-border bg-panel px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="flex-1">
@@ -187,7 +187,7 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full h-9 rounded-md border border-border bg-surface px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
className="w-full h-9 rounded-md border border-border bg-panel px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>

View File

@@ -166,7 +166,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
action={{ label: 'Create API Key', onClick: () => setShowCreateDialog(true) }}
/>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-elevated/30">
@@ -264,7 +264,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
</label>
<input
type="text"
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
placeholder="e.g. Monitoring Integration"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -300,7 +300,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
</label>
<input
type="date"
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}

View File

@@ -78,7 +78,7 @@ export function SettingsPage() {
</div>
{/* Account section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={User} title="Account" />
<InfoRow label="Email" value={user?.email} />
<InfoRow label="Role" value={
@@ -94,13 +94,13 @@ export function SettingsPage() {
</div>
{/* Password & Security section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={Lock} title="Password & Security" />
<ChangePasswordForm />
</div>
{/* Permissions section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={Shield} title="Permissions" />
<InfoRow label="Read devices" value="Yes" />
<InfoRow
@@ -118,7 +118,7 @@ export function SettingsPage() {
</div>
{/* System info section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={Info} title="System" />
<InfoRow label="API" value={
<a
@@ -135,7 +135,7 @@ export function SettingsPage() {
{/* Quick links */}
{isTenantAdmin(user) && (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={Key} title="Integrations" />
<Link
to="/settings/api-keys"
@@ -152,7 +152,7 @@ export function SettingsPage() {
{/* Maintenance — super_admin only */}
{isSuperAdmin(user) && (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
<SectionHeader icon={Monitor} title="Maintenance" />
<div className="flex items-center justify-between py-2">
<div>
@@ -182,7 +182,7 @@ export function SettingsPage() {
{isSuperAdmin(user) && <SMTPSettingsSection />}
{/* Data & Privacy section */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-3">
<SectionHeader icon={Shield} title="Data & Privacy" />
{/* Export Data */}
@@ -377,7 +377,7 @@ function SMTPSettingsSection() {
if (isLoading) return null
return (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<SectionHeader icon={Mail} title="System Email (SMTP)" />
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${

View File

@@ -484,7 +484,7 @@ export function SetupWizard() {
<StepIndicator currentStep={step} />
{/* Card */}
<div className="bg-surface border border-border rounded-lg p-8">
<div className="bg-panel border border-border rounded-lg p-8">
{step === 1 && (
<CreateTenantStep
onComplete={(tenant) => {

View File

@@ -19,7 +19,7 @@ export function SimpleFormSection({
children,
}: SimpleFormSectionProps) {
return (
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
<div className="rounded-lg border border-border bg-panel p-4 space-y-4">
<div className="flex items-center gap-2.5">
<Icon className="h-4.5 w-4.5 text-accent flex-shrink-0" />
<div>

View File

@@ -14,31 +14,31 @@ interface SimpleModeToggleProps {
export function SimpleModeToggle({ mode, onModeChange }: SimpleModeToggleProps) {
return (
<div className="flex items-center gap-1 rounded-lg border border-border bg-elevated/50 p-1">
<Button
variant="ghost"
size="sm"
<div className="flex items-center gap-px rounded-[var(--radius-control)] border border-border-default overflow-hidden">
<button
onClick={() => onModeChange('simple')}
className={cn(
'gap-1.5 h-7 px-2.5 text-xs',
mode === 'simple' && 'bg-accent/20 text-accent',
'flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition-[background-color,color] duration-[50ms]',
mode === 'simple'
? 'bg-accent-soft text-text-primary font-medium'
: 'text-text-muted hover:text-text-secondary',
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<LayoutGrid className="h-2.5 w-2.5" />
Simple
</Button>
<Button
variant="ghost"
size="sm"
</button>
<button
onClick={() => onModeChange('standard')}
className={cn(
'gap-1.5 h-7 px-2.5 text-xs',
mode === 'standard' && 'bg-accent/20 text-accent',
'flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition-[background-color,color] duration-[50ms]',
mode === 'standard'
? 'bg-accent-soft text-text-primary font-medium'
: 'text-text-muted hover:text-text-secondary',
)}
>
<Sliders className="h-3.5 w-3.5" />
<Sliders className="h-2.5 w-2.5" />
Standard
</Button>
</button>
</div>
)
}

View File

@@ -16,7 +16,7 @@ export function SimpleStatusBanner({ items, isLoading }: SimpleStatusBannerProps
<div key={i} className="flex flex-col">
<span className="text-xs text-text-muted">{item.label}</span>
{isLoading ? (
<div className="h-5 w-24 mt-0.5 rounded bg-elevated animate-shimmer" />
<span className="text-[9px] text-text-muted mt-0.5">Loading&hellip;</span>
) : (
<span className="text-sm font-medium text-text-primary">
{item.value || '\u2014'}

View File

@@ -133,17 +133,17 @@ export function StandardConfigSidebar({
onSwitchToSimple,
}: StandardConfigSidebarProps) {
return (
<div className="w-48 flex-shrink-0 flex flex-col min-h-[400px]">
<nav className="space-y-3 overflow-y-auto flex-1">
<div className="w-44 flex-shrink-0 flex flex-col min-h-[400px]">
<nav className="space-y-2 overflow-y-auto flex-1">
{STANDARD_GROUPS.map((group) => {
const GroupIcon = group.icon
return (
<div key={group.label}>
<p className="flex items-center gap-1.5 text-xs font-medium text-text-muted uppercase tracking-wider mb-1 px-3">
<p className="flex items-center gap-1.5 text-[7px] text-text-label uppercase tracking-[2px] mb-1 pl-2">
<GroupIcon className="h-3 w-3" />
{group.label}
</p>
<div className="space-y-0.5">
<div>
{group.items.map((item) => {
const isActive = activeTab === item.id
return (
@@ -152,10 +152,10 @@ export function StandardConfigSidebar({
onClick={() => onTabChange(item.id)}
data-testid={`tab-${item.id}`}
className={cn(
'flex items-center w-full text-left pl-7 pr-3 py-1.5 rounded-r-lg text-sm transition-colors',
'flex items-center w-full text-left pl-6 pr-2 py-[3px] text-xs border-l-2 transition-[border-color,color] duration-[50ms]',
isActive
? 'bg-accent/10 text-accent border-l-2 border-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50 border-l-2 border-transparent',
? 'bg-accent-soft text-text-primary font-medium border-accent rounded-r-sm'
: 'text-text-secondary hover:border-accent border-transparent',
)}
>
<span className="truncate">{item.label}</span>
@@ -169,13 +169,13 @@ export function StandardConfigSidebar({
</nav>
{onSwitchToSimple && (
<div className="mt-auto pt-4 border-t border-border/50">
<div className="mt-auto pt-2 border-t border-border-subtle">
<button
onClick={onSwitchToSimple}
className="flex items-center gap-2 w-full text-left px-3 py-2 text-xs text-text-muted hover:text-text-secondary transition-colors"
className="flex items-center gap-1.5 w-full text-left px-2 py-1.5 text-[10px] text-text-muted hover:text-text-secondary transition-[color] duration-[50ms]"
>
<Sliders className="h-3.5 w-3.5" />
Switch to Simple mode
<Sliders className="h-3 w-3" />
Simple mode
</button>
</div>
)}

View File

@@ -180,7 +180,7 @@ export function LanDhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{activeLeases.length === 0 ? (
<p className="text-xs text-text-muted">No active DHCP leases</p>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-elevated/30">

View File

@@ -126,7 +126,7 @@ export function WifiSimplePanel({ tenantId, deviceId, active, routerosVersion }:
// No wireless hardware
if (wireless.entries.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-12 text-center">
<div className="rounded-lg border border-border bg-panel p-12 text-center">
<Wifi className="h-8 w-8 text-text-muted/50 mx-auto mb-3" />
<p className="text-sm font-medium text-text-secondary">
This device does not have wireless hardware

View File

@@ -57,15 +57,8 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
if (devicesLoading) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border bg-surface p-4 space-y-3 animate-pulse">
<div className="h-4 w-24 bg-elevated rounded" />
<div className="h-1.5 w-full bg-elevated rounded-full" />
<div className="h-1.5 w-full bg-elevated rounded-full" />
<div className="h-3 w-16 bg-elevated rounded" />
</div>
))}
<div className="py-8 text-center">
<span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div>
)
}
@@ -74,7 +67,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
if (devices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<div className="rounded-lg border border-border bg-panel p-8 text-center">
<p className="text-sm text-text-muted">
No devices assigned to this site. Assign devices from the fleet page.
</p>
@@ -103,7 +96,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId, deviceId: device.id }}
className={cn(
'rounded-lg border bg-surface p-4 space-y-2 hover:bg-elevated/50 transition-colors block',
'rounded-lg border bg-panel p-4 space-y-2 hover:bg-elevated/50 transition-colors block',
borderColor(device.status),
)}
>

View File

@@ -145,7 +145,7 @@ export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
}, [linksData])
if (sectorsLoading || devicesLoading) {
return <TableSkeleton rows={6} />
return <TableSkeleton />
}
const sectors = sectorData?.items ?? []
@@ -153,7 +153,7 @@ export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
if (sectors.length === 0 && unassignedDevices.length === 0) {
return (
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-3">
<div className="rounded-lg border border-border bg-panel p-8 text-center space-y-3">
<p className="text-sm text-text-muted">
No sectors defined. Create sectors to organize APs by direction.
</p>
@@ -315,7 +315,7 @@ function SectorSection({
const isUnassigned = !sector
return (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
<div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Section header */}
<button
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-elevated/50 transition-colors text-left"

View File

@@ -99,7 +99,7 @@ export function SiteTable({ tenantId, search, onCreateClick, onEditClick }: Site
}
if (isLoading) {
return <TableSkeleton rows={4} />
return <TableSkeleton />
}
if (!data || data.sites.length === 0) {

View File

@@ -101,7 +101,7 @@ export function PushProgressPanel({ tenantId, rolloutId, onClose }: PushProgress
return (
<div
key={job.device_id}
className="flex items-center gap-3 rounded-lg border border-border/50 bg-surface/50 px-3 py-2"
className="flex items-center gap-3 rounded-lg border border-border/50 bg-panel/50 px-3 py-2"
>
<Icon
className={cn(

View File

@@ -164,7 +164,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this template configure?"
rows={2}
className="w-full px-3 py-2 text-sm rounded-md bg-elevated/50 border border-border text-text-primary placeholder:text-text-muted resize-none focus:outline-none focus:ring-1 focus:ring-border-bright"
className="w-full px-3 py-2 text-sm rounded-md bg-elevated/50 border border-border text-text-primary placeholder:text-text-muted resize-none focus:outline-none focus:ring-1 focus:ring-border-default"
/>
</div>
@@ -176,7 +176,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
onChange={(e) => setContent(e.target.value)}
placeholder={`# Example: Set system identity\n/system identity set name={{ device.hostname }}-{{ site_name }}\n\n# Add IP address\n/ip address add address={{ mgmt_ip }}/24 interface=ether1`}
rows={16}
className="w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-success placeholder:text-text-muted font-mono resize-y focus:outline-none focus:ring-1 focus:ring-border-bright leading-relaxed"
className="w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-success placeholder:text-text-muted font-mono resize-y focus:outline-none focus:ring-1 focus:ring-border-default leading-relaxed"
/>
</div>
@@ -247,7 +247,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-surface border-b border-border">
<tr className="bg-panel border-b border-border">
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Name</th>
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted w-28">Type</th>
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Default</th>

View File

@@ -150,7 +150,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-surface border-border text-text-primary">
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-panel border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm flex items-center gap-2">
Push Template: {template.name}
@@ -193,7 +193,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
{devices?.map((device) => (
<label
key={device.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-surface cursor-pointer"
className="flex items-center gap-3 px-3 py-2 hover:bg-panel cursor-pointer"
>
<Checkbox
checked={selectedDeviceIds.has(device.id)}
@@ -237,7 +237,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
Provide values for template variables. Built-in device variables are auto-populated per device.
</div>
<div className="text-[10px] text-text-muted bg-surface rounded px-3 py-2">
<div className="text-[10px] text-text-muted bg-panel rounded px-3 py-2">
Auto-populated: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'}
</div>
@@ -321,7 +321,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
'text-xs px-2 py-1 rounded transition-colors',
previewDevice === d.id
? 'bg-elevated text-text-primary'
: 'bg-surface text-text-secondary hover:text-text-secondary',
: 'bg-panel text-text-secondary hover:text-text-secondary',
)}
>
{d.hostname}

View File

@@ -229,7 +229,7 @@ export function TemplatesPage() {
{templates.map((template) => (
<div
key={template.id}
className="rounded-lg border border-border bg-surface/50 p-4 hover:bg-surface transition-colors"
className="rounded-lg border border-border bg-panel/50 p-4 hover:bg-panel transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
@@ -313,7 +313,7 @@ export function TemplatesPage() {
open={!!deleteConfirmId}
onOpenChange={(o) => !o && setDeleteConfirmId(null)}
>
<DialogContent className="max-w-sm bg-surface border-border text-text-primary">
<DialogContent className="max-w-sm bg-panel border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm">Delete Template</DialogTitle>
</DialogHeader>

Some files were not shown because too many files have changed in this diff Show More