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

View File

@@ -146,7 +146,7 @@ export function AnsiNfoModal({ open, onOpenChange }: AnsiNfoModalProps) {
}} }}
> >
{/* Retro title bar */} {/* 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"> <DialogTitle id="ansi-nfo-title" className="text-text-muted text-xs font-normal font-mono">
TOD.NFO ACiD View v1.0 TOD.NFO ACiD View v1.0
</DialogTitle> </DialogTitle>

View File

@@ -698,7 +698,7 @@ export function AlertRulesPage() {
No alert rules configured. No alert rules configured.
</p> </p>
) : ( ) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden"> <div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium"> <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> <span className="flex-1">Name</span>
@@ -710,7 +710,7 @@ export function AlertRulesPage() {
{rules.map((rule) => ( {rules.map((rule) => (
<div <div
key={rule.id} 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"> <div className="flex-1 min-w-0">
<span className="text-text-primary truncate block"> <span className="text-text-primary truncate block">
@@ -809,7 +809,7 @@ export function AlertRulesPage() {
{channels.map((ch) => ( {channels.map((ch) => (
<div <div
key={ch.id} 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 justify-between">
<div className="flex items-center gap-2"> <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() alert.silenced_until && new Date(alert.silenced_until) > new Date()
return ( 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} /> <StatusIcon status={alert.status} />
<SeverityBadge severity={alert.severity} /> <SeverityBadge severity={alert.severity} />
@@ -271,7 +271,7 @@ export function AlertsPage() {
description="All clear! No alerts have been triggered." 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) => ( {alerts.map((alert) => (
<AlertRow <AlertRow
key={alert.id} key={alert.id}
@@ -302,7 +302,7 @@ export function AlertsPage() {
description="Alert events will appear here as they are triggered and resolved." 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 */} {/* 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"> <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" /> <span className="w-5" />
@@ -315,7 +315,7 @@ export function AlertsPage() {
{alerts.map((alert) => ( {alerts.map((alert) => (
<div <div
key={alert.id} 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} /> <StatusIcon status={alert.status} />
<span className="w-16"> <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('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.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 === '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' if (action.startsWith('bulk_')) return 'bg-error/10 text-error border-error/20'
return 'bg-elevated text-text-secondary border-border' return 'bg-elevated text-text-secondary border-border'
} }
@@ -143,7 +143,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={actionFilter} value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }} onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
aria-label="Filter by action" 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) => ( {ACTION_TYPES.map((a) => (
<option key={a.value} value={a.value}> <option key={a.value} value={a.value}>
@@ -160,7 +160,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={dateFrom} value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }} onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
aria-label="Filter from date" 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> </div>
@@ -172,7 +172,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={dateTo} value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }} onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
aria-label="Filter to date" 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> </div>
@@ -185,7 +185,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
value={userSearch} value={userSearch}
onChange={(e) => setUserSearch(e.target.value)} onChange={(e) => setUserSearch(e.target.value)}
aria-label="Filter by user" 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> </div>
@@ -196,7 +196,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
<button <button
onClick={handleExport} onClick={handleExport}
disabled={exporting || !data?.total} 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" /> <Download className="h-3.5 w-3.5" />
{exporting ? 'Exporting...' : 'Export CSV'} {exporting ? 'Exporting...' : 'Export CSV'}
@@ -204,7 +204,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
</div> </div>
{/* Table */} {/* Table */}
<div className="rounded-lg border border-border bg-surface overflow-hidden"> <div className="rounded-lg border border-border bg-panel overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center"> <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" /> <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) setPage(1)
}} }}
aria-label="Rows per page" 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) => ( {PER_PAGE_OPTIONS.map((n) => (
<option key={n} value={n}> <option key={n} value={n}>

View File

@@ -220,7 +220,7 @@ export function EmergencyKitDialog({
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleCopy} 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 ? ( {copied ? (
<> <>
@@ -245,7 +245,7 @@ export function EmergencyKitDialog({
</div> </div>
{/* Instructions */} {/* 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. 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. You can also store the key in your password manager. Do NOT store it alongside your password.
</div> </div>

View File

@@ -134,7 +134,7 @@ export function SrpUpgradeDialog({
</div> </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> <p>
<strong>What happens:</strong> <strong>What happens:</strong>
</p> </p>

View File

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

View File

@@ -76,7 +76,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
if (!ca) { if (!ca) {
return ( return (
<div className="max-w-lg mx-auto"> <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"> <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" /> <Shield className="h-8 w-8 text-accent" />
</div> </div>
@@ -118,7 +118,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
return ( return (
<div <div
className={cn( 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', isExpired ? 'border-error/40' : 'border-success/30',
)} )}
> >

View File

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

View File

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

View File

@@ -204,7 +204,7 @@ export function CommandPalette() {
const visiblePages = pageCommands.filter((p) => p.visible) const visiblePages = pageCommands.filter((p) => p.visible)
const itemClass = 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 = 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' '[&_[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" 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" 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"> <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" /> <Search className="h-4 w-4 text-text-muted flex-shrink-0" />
<Command.Input <Command.Input

View File

@@ -136,7 +136,7 @@ export function CommandExecutor({ tenantId, deviceId, currentPath }: CommandExec
{results.length > 0 && ( {results.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-2"> <div className="max-h-48 overflow-y-auto space-y-2">
{results.map((r, i) => ( {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"> <div className="flex items-center gap-2 mb-1">
<span className="text-[10px] text-text-muted">{r.timestamp}</span> <span className="text-[10px] text-text-muted">{r.timestamp}</span>
<span className="text-xs font-mono text-text-secondary">{r.command}</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 */} {/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={(o) => !o && setDeleteConfirmOpen(false)}> <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> <DialogHeader>
<DialogTitle className="text-sm">Confirm Delete</DialogTitle> <DialogTitle className="text-sm">Confirm Delete</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@@ -143,7 +143,7 @@ export function EntryForm({ open, onClose, mode, entry, columns, onSubmit }: Ent
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <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> <DialogHeader>
<DialogTitle className="text-sm"> <DialogTitle className="text-sm">
{mode === 'add' ? 'Add New Entry' : 'Edit Entry'} {mode === 'add' ? 'Add New Entry' : 'Edit Entry'}

View File

@@ -42,7 +42,7 @@ export function EntryTable({
className={cn( className={cn(
'rounded-lg border p-4 text-sm', 'rounded-lg border p-4 text-sm',
isContainerPath isContainerPath
? 'border-border bg-surface text-text-secondary' ? 'border-border bg-panel text-text-secondary'
: 'border-error/30 bg-error/10 text-error', : 'border-error/30 bg-error/10 text-error',
)} )}
> >
@@ -64,14 +64,8 @@ export function EntryTable({
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-2"> <div className="py-8 text-center">
<div className="flex items-center justify-between mb-3"> <span className="text-[9px] text-text-muted">Loading&hellip;</span>
<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> </div>
) )
} }
@@ -128,7 +122,7 @@ export function EntryTable({
<tr <tr
key={entry['.id'] || i} key={entry['.id'] || i}
className={cn( 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', entry['dynamic'] === 'true' && 'text-text-muted',
)} )}
> >

View File

@@ -265,7 +265,7 @@ function TreeItem({
className={cn( className={cn(
'flex items-center gap-1.5 w-full px-2 py-1 text-xs rounded transition-colors', 'flex items-center gap-1.5 w-full px-2 py-1 text-xs rounded transition-colors',
isActive 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', : 'text-text-secondary hover:text-text-primary hover:bg-elevated/50',
)} )}
style={{ paddingLeft: `${depth * 12 + 8}px` }} style={{ paddingLeft: `${depth * 12 + 8}px` }}

View File

@@ -145,7 +145,7 @@ export function AddressListPanel({ tenantId, deviceId, active }: ConfigPanelProp
</Button> </Button>
</div> </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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<List className="h-4 w-4" /> <List className="h-4 w-4" />

View File

@@ -282,7 +282,7 @@ function AddressTable({
return ( return (
<> <>
{/* Table */} {/* 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />

View File

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

View File

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

View File

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

View File

@@ -138,11 +138,11 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
</div> </div>
{ports.entries.length === 0 ? ( {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. No bridge ports configured.
</div> </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"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
@@ -212,7 +212,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['interface'] || ''} value={formData['interface'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, interface: e.target.value }))} 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> <option value="">Select...</option>
{ifaceNames.map((name) => <option key={name} value={name}>{name}</option>)} {ifaceNames.map((name) => <option key={name} value={name}>{name}</option>)}
@@ -223,7 +223,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['bridge'] || ''} value={formData['bridge'] || ''}
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))} 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>)} {bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
</select> </select>
@@ -243,7 +243,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['frame-types'] || 'admit-all'} value={formData['frame-types'] || 'admit-all'}
onChange={(e) => setFormData((f) => ({ ...f, 'frame-types': e.target.value }))} 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>)} {FRAME_TYPES.map((ft) => <option key={ft} value={ft}>{ft}</option>)}
</select> </select>
@@ -253,7 +253,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['ingress-filtering'] || 'no'} value={formData['ingress-filtering'] || 'no'}
onChange={(e) => setFormData((f) => ({ ...f, 'ingress-filtering': e.target.value }))} 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="yes">Yes</option>
<option value="no">No</option> <option value="no">No</option>
@@ -264,7 +264,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['hw'] || 'yes'} value={formData['hw'] || 'yes'}
onChange={(e) => setFormData((f) => ({ ...f, hw: e.target.value }))} 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="yes">Yes</option>
<option value="no">No</option> <option value="no">No</option>
@@ -297,7 +297,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
<select <select
value={formData['edge'] || 'auto'} value={formData['edge'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, edge: e.target.value }))} 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="auto">Auto</option>
<option value="yes">Yes</option> <option value="yes">Yes</option>

View File

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

View File

@@ -81,9 +81,9 @@ export function ConfigDiffViewer({
const isLoading = loadingOld || loadingNew const isLoading = loadingOld || loadingNew
return ( return (
<div className="rounded-lg border border-border bg-surface overflow-hidden"> <div className="rounded-lg border border-border bg-panel overflow-hidden">
{/* Header */} {/* 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"> <div className="flex items-center gap-2 text-xs text-text-muted">
{isEncrypted && ( {isEncrypted && (
<span className="inline-flex items-center gap-1 text-info" title="Decrypted from encrypted backup"> <span className="inline-flex items-center gap-1 text-info" title="Decrypted from encrypted backup">
@@ -126,8 +126,8 @@ export function ConfigDiffViewer({
{/* Diff content */} {/* Diff content */}
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center text-sm text-text-muted animate-pulse"> <div className="py-8 text-center">
Loading diff... <span className="text-[9px] text-text-muted">Loading&hellip;</span>
</div> </div>
) : !diffFile ? ( ) : !diffFile ? (
<div className="p-8 text-center text-sm text-text-muted"> <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 ( 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"> <div className="flex items-center gap-2 mb-3">
<History className="h-4 w-4 text-text-muted" /> <History className="h-4 w-4 text-text-muted" />
<h3 className="text-sm font-medium text-text-muted">Configuration History</h3> <h3 className="text-sm font-medium text-text-muted">Configuration History</h3>
@@ -83,7 +83,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
)} )}
{isLoading ? ( {isLoading ? (
<TableSkeleton rows={3} /> <TableSkeleton />
) : !changes || changes.length === 0 ? ( ) : !changes || changes.length === 0 ? (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center py-6">
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span> <span className="text-xs text-text-muted">No configuration changes recorded yet.</span>

View File

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

View File

@@ -104,7 +104,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
</div> </div>
{/* Active connections count */} {/* 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"> <div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-accent" /> <Activity className="h-5 w-5 text-accent" />
<div> <div>
@@ -120,7 +120,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
</div> </div>
{/* Tracking settings */} {/* 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"> <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> <span className="text-sm font-medium text-text-secondary">Connection Tracking Settings</span>
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}> <Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
@@ -154,7 +154,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
<select <select
value={formData['enabled'] || 'auto'} value={formData['enabled'] || 'auto'}
onChange={(e) => setFormData((f) => ({ ...f, enabled: e.target.value }))} 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="auto">Auto</option>
<option value="yes">Yes</option> <option value="yes">Yes</option>

View File

@@ -206,7 +206,7 @@ export function DhcpClientPanel({ tenantId, deviceId, active }: ConfigPanelProps
</div> </div>
{/* DHCP Client table */} {/* 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />

View File

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

View File

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

View File

@@ -302,7 +302,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div> </div>
{/* Section 1: Resolver Settings */} {/* 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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server className="h-4 w-4 text-accent" /> <Server className="h-4 w-4 text-accent" />
@@ -393,7 +393,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
</div> </div>
{/* Section 2: Static DNS Entries */} {/* 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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-accent" /> <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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -643,7 +643,7 @@ function FilterRulesTable({
</Button> </Button>
</div> </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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -836,7 +836,7 @@ function NatRulesTable({
</Button> </Button>
</div> </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"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -1340,16 +1340,10 @@ function CellEmpty() {
function LoadingRows({ cols }: { cols: number }) { function LoadingRows({ cols }: { cols: number }) {
return ( return (
<> <tr>
{Array.from({ length: 5 }).map((_, i) => ( <td colSpan={cols} className="py-8 text-center">
<tr key={i} className="border-b border-border/50"> <LoadingText />
{Array.from({ length: cols }).map((_, j) => (
<td key={j} className="px-3 py-2">
<Skeleton className="h-4 w-full" />
</td> </td>
))}
</tr> </tr>
))}
</>
) )
} }

View File

@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -71,14 +71,14 @@ const SUB_TABS: { key: SubTab; label: string; icon: React.ElementType }[] = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
ether: '#3B82F6', ether: 'hsl(var(--accent))',
bridge: '#8B5CF6', bridge: 'hsl(var(--info))',
vlan: '#F59E0B', vlan: 'hsl(var(--warning))',
bonding: '#10B981', bonding: 'hsl(var(--success))',
pppoe: '#EF4444', pppoe: 'hsl(var(--error))',
l2tp: '#EC4899', l2tp: 'hsl(var(--error))',
ovpn: '#06B6D4', ovpn: 'hsl(var(--info))',
wlan: '#84CC16', 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 ( return (
<div className="rounded-lg border border-border bg-surface"> <div className="py-8 text-center">
<div className="p-3 border-b border-border"> <LoadingText />
<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> </div>
) )
} }
@@ -260,18 +250,18 @@ function InterfacesTable({
entries: Record<string, string>[] entries: Record<string, string>[]
isLoading: boolean isLoading: boolean
}) { }) {
if (isLoading) return <TableSkeleton /> if (isLoading) return <TableLoading />
if (entries.length === 0) { if (entries.length === 0) {
return ( 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. No interfaces found on this device.
</div> </div>
) )
} }
return ( 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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <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 ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -374,11 +364,11 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
</div> </div>
{entries.length === 0 ? ( {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. No IP addresses configured.
</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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <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 ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -633,11 +623,11 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
</div> </div>
{entries.length === 0 ? ( {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. No VLANs configured.
</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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <tr className="border-b border-border bg-elevated/30">
@@ -926,7 +916,7 @@ function BridgesTab({
}) })
} }
if (isLoading) return <TableSkeleton /> if (isLoading) return <TableLoading />
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -941,11 +931,11 @@ function BridgesTab({
</div> </div>
{bridges.length === 0 ? ( {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. No bridges configured.
</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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <tr className="border-b border-border bg-elevated/30">
@@ -1015,11 +1005,11 @@ function BridgesTab({
</div> </div>
{bridgePorts.length === 0 ? ( {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. No bridge ports configured.
</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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <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) => ( {SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} <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', 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.icon}{tab.label}
</button> </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="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="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Auth Method</Label> <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> <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> </select></div>
<div className="space-y-1"><Label className="text-xs text-text-secondary">Exchange Mode</Label> <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> <option value="main">Main</option><option value="aggressive">Aggressive</option><option value="ike2">IKEv2</option>
</select></div> </select></div>
</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 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>
<div className="grid grid-cols-3 gap-3"> <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">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-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">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 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>
</div> </div>
@@ -275,7 +275,7 @@ function ProposalsTab({ entries, panel }: { entries: ProposalEntry[]; panel: Pan
function SasTab({ entries }: { entries: SaEntry[] }) { function SasTab({ entries }: { entries: SaEntry[] }) {
return ( 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> <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> : ( {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"> <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 }) { function TableWrapper({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return ( 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 justify-between px-4 py-2 border-b border-border/50">
<span className="text-sm font-medium text-text-secondary">{title} ({count})</span> <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> <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)} onClick={() => setChainFilter(chain)}
className={cn( className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize', '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} {chain}
@@ -193,7 +193,7 @@ function MangleTable({ entries, panel }: { entries: MangleEntry[]; panel: PanelH
return ( 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />

View File

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

View File

@@ -275,7 +275,7 @@ function PoolTable({
return ( return (
<> <>
{/* Table */} {/* 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Layers className="h-4 w-4" /> <Layers className="h-4 w-4" />

View File

@@ -98,7 +98,7 @@ export function PppPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{SUB_TABS.map((tab) => ( {SUB_TABS.map((tab) => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} <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', 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.icon}{tab.label}
{tab.key === 'active' && activeConns.entries.length > 0 && ( {tab.key === 'active' && activeConns.entries.length > 0 && (
<span className="text-xs bg-accent/20 text-accent px-1 rounded">{activeConns.entries.length}</span> <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 ( 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 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> <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> <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 ( 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 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> <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> <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>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs text-text-secondary">Service</Label> <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>)} {PPP_SERVICES.map((s) => <option key={s} value={s}>{s}</option>)}
</select></div> </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" /> <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 ( 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"> <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> <span className="text-sm font-medium text-text-secondary">Active Connections ({entries.length})</span>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -230,7 +230,7 @@ function ServiceTable({
return ( 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
@@ -337,7 +337,7 @@ function ServiceTable({
<select <select
value={form.disabled} value={form.disabled}
onChange={(e) => setForm((f) => ({ ...f, disabled: e.target.value }))} 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="false">Enabled</option>
<option value="true">Disabled</option> <option value="true">Disabled</option>

View File

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

View File

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

View File

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

View File

@@ -106,14 +106,14 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
return ( return (
<div className="space-y-4"> <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="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-text-secondary">Interface</Label> <Label className="text-xs text-text-secondary">Interface</Label>
<select <select
value={iface} value={iface}
onChange={(e) => setIface(e.target.value)} 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.length > 0
? ifaceNames.map((name) => ( ? ifaceNames.map((name) => (
@@ -190,7 +190,7 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
)} )}
{entries.length > 0 && ( {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"> <div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Flame className="h-4 w-4 text-accent" /> <Flame className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary"> <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 && ( {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. No traffic captured. Try a different interface or remove filters.
</div> </div>
)} )}

View File

@@ -69,7 +69,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
return ( return (
<div className="space-y-4"> <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="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label> <Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
@@ -108,7 +108,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
<select <select
value={protocol} value={protocol}
onChange={(e) => setProtocol(e.target.value)} 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="icmp">ICMP</option>
<option value="udp">UDP</option> <option value="udp">UDP</option>
@@ -137,7 +137,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
)} )}
{hops.length > 0 && ( {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"> <div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
<Route className="h-4 w-4 text-accent" /> <Route className="h-4 w-4 text-accent" />
<span className="text-sm font-medium text-text-secondary"> <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 overview */}
{groups.length > 0 && ( {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"> <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" /> <Shield className="h-4 w-4 text-text-muted" />
<span className="text-sm font-medium text-text-secondary">User Groups</span> <span className="text-sm font-medium text-text-secondary">User Groups</span>
@@ -266,7 +266,7 @@ function UsersTable({
return ( 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 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"> <div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export function WirelessIssues({ tenantId }: WirelessIssuesProps) {
}) })
return ( 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"> <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" /> <Wifi className="h-4 w-4 text-text-muted" />
APs Needing Attention APs Needing Attention

View File

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

View File

@@ -256,7 +256,7 @@ function MassUpgradeProgress({
)} )}
{/* Device list */} {/* 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) => { {rollout.jobs.map((job) => {
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
const Icon = config.icon const Icon = config.icon

View File

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

View File

@@ -1,17 +1,17 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useAuth } from '@/lib/auth' 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 { useUIStore } from '@/lib/store'
import { alertsApi } from '@/lib/alertsApi' import { alertsApi } from '@/lib/alertsApi'
import { useEventStreamContext } from '@/contexts/EventStreamContext' import { useEventStreamContext } from '@/contexts/EventStreamContext'
import { LayoutDashboard } from 'lucide-react' import { LayoutDashboard, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
// ─── Dashboard Widgets ─────────────────────────────────────────────────────── // ─── Dashboard Widgets ───────────────────────────────────────────────────────
import { KpiCards } from '@/components/dashboard/KpiCards'
import { HealthScore } from '@/components/dashboard/HealthScore' import { HealthScore } from '@/components/dashboard/HealthScore'
import { EventsTimeline } from '@/components/dashboard/EventsTimeline' import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart' import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart'
@@ -30,39 +30,122 @@ const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
{ label: 'Off', value: false }, { label: 'Off', value: false },
] ]
// ─── Dashboard Skeleton ────────────────────────────────────────────────────── // ─── Dashboard Loading ───────────────────────────────────────────────────────
function DashboardSkeleton() { function DashboardLoading() {
return ( return (
<div className="space-y-4"> <div className="py-8 text-center">
{/* KPI cards skeleton */} <LoadingText />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> </div>
{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" /> // ─── 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>
{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> </div>
{/* Widget grid skeleton */} ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="py-5 text-center">
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3"> <span className="text-[9px] text-text-muted">No issues detected</span>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-48 w-full" />
</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>
<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>
)}
</div> </div>
) )
} }
@@ -96,9 +179,9 @@ export function FleetDashboard() {
isFetching: fleetFetching, isFetching: fleetFetching,
dataUpdatedAt, dataUpdatedAt,
} = useQuery({ } = useQuery({
queryKey: ['fleet-summary', isSuperAdmin ? 'all' : tenantId], queryKey: ['fleet-summary', isSuperAdmin && !selectedTenantId ? 'all' : tenantId],
queryFn: () => queryFn: () =>
isSuperAdmin isSuperAdmin && !selectedTenantId
? metricsApi.fleetSummaryAll() ? metricsApi.fleetSummaryAll()
: metricsApi.fleetSummary(tenantId), : metricsApi.fleetSummary(tenantId),
// Disable polling when SSE is connected (events update cache directly) // Disable polling when SSE is connected (events update cache directly)
@@ -128,6 +211,15 @@ export function FleetDashboard() {
const onlinePercent = const onlinePercent =
totalDevices > 0 ? (onlineDevices.length / totalDevices) * 100 : 0 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 // Alert counts
const alerts = alertsData?.items ?? [] const alerts = alertsData?.items ?? []
const criticalCount = alerts.filter((a) => a.severity === 'critical').length const criticalCount = alerts.filter((a) => a.severity === 'critical').length
@@ -183,10 +275,10 @@ export function FleetDashboard() {
return ( return (
<div className="space-y-6" data-testid="dashboard"> <div className="space-y-6" data-testid="dashboard">
{/* ── Page Header ─────────────────────────────────────────────────── */} {/* ── 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> <div>
<h1 className="text-xl font-semibold">Dashboard</h1> <h1 className="text-sm font-semibold text-text-primary">Overview</h1>
<p className="text-sm text-text-muted mt-0.5"> <p className="text-[9px] text-text-muted mt-0.5">
Fleet overview across{' '} Fleet overview across{' '}
{isSuperAdmin {isSuperAdmin
? selectedTenantId && selectedTenantName ? selectedTenantId && selectedTenantName
@@ -213,7 +305,7 @@ export function FleetDashboard() {
</span> </span>
)} )}
{/* Refresh interval selector */} {/* 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) => ( {REFRESH_OPTIONS.map((opt) => (
<button <button
key={opt.label} key={opt.label}
@@ -223,7 +315,7 @@ export function FleetDashboard() {
'px-2.5 py-1 text-xs font-medium transition-colors', 'px-2.5 py-1 text-xs font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md', 'first:rounded-l-md last:rounded-r-md',
refreshInterval === opt.value refreshInterval === opt.value
? 'bg-accent/15 text-accent' ? 'bg-accent-soft text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50', : 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)} )}
> >
@@ -236,7 +328,7 @@ export function FleetDashboard() {
{/* ── Dashboard Content ───────────────────────────────────────────── */} {/* ── Dashboard Content ───────────────────────────────────────────── */}
{fleetLoading ? ( {fleetLoading ? (
<DashboardSkeleton /> <DashboardLoading />
) : totalDevices === 0 ? ( ) : totalDevices === 0 ? (
<EmptyState <EmptyState
icon={LayoutDashboard} icon={LayoutDashboard}
@@ -245,13 +337,54 @@ export function FleetDashboard() {
/> />
) : ( ) : (
<> <>
{/* KPI Cards — full width, 4 columns */} {/* Metrics Strip — joined 4-column bar */}
<KpiCards <div className="flex gap-px mb-3.5 bg-border-default rounded-sm overflow-hidden">
totalDevices={totalDevices} <div className="flex-1 bg-panel px-3 py-2">
onlinePercent={onlinePercent} <div className="text-lg font-medium font-mono text-text-primary">
activeAlerts={totalAlerts} {totalDevices}
totalBandwidthBps={totalBandwidthBps} </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 */} {/* Widget Grid — responsive 3 columns */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <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 }) { function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: string }) {
return ( return (
<div <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}`} data-testid={`device-card-${device.hostname}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -345,7 +345,7 @@ export function FleetTable({
isFetching && !isLoading && 'opacity-70', isFetching && !isLoading && 'opacity-70',
)}> )}>
{isLoading ? ( {isLoading ? (
<TableSkeleton rows={3} /> <TableSkeleton />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState <EmptyState
icon={Monitor} icon={Monitor}
@@ -402,7 +402,7 @@ export function FleetTable({
data-index={virtualRow.index} data-index={virtualRow.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
className={cn( 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', selectedIndex === virtualRow.index && 'bg-elevated/50',
)} )}
style={{ style={{
@@ -460,7 +460,7 @@ export function FleetTable({
key={device.id} key={device.id}
data-testid={`device-row-${device.hostname}`} data-testid={`device-row-${device.hostname}`}
className={cn( 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', selectedIndex === idx && 'bg-elevated/50',
)} )}
> >

View File

@@ -162,26 +162,26 @@ export function RemoteWinBoxButton({ tenantId, deviceId }: RemoteWinBoxButtonPro
if (state === 'idle' || state === 'failed' || state === 'terminated') { if (state === 'idle' || state === 'failed' || state === 'terminated') {
return ( return (
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<button <button
onClick={handleOpen} onClick={handleOpen}
disabled={createMutation.isPending} 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 ? ( {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>
<button <button
onClick={handleReset} 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" 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 all remote WinBox sessions for this device" title="Reset remote sessions"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-3 w-3" />
Reset
</button> </button>
</div> </div>
{state === 'failed' && error && ( {state === 'failed' && error && (

View File

@@ -162,10 +162,11 @@ export function SSHTerminal({ tenantId, deviceId, deviceName }: SSHTerminalProps
return ( return (
<button <button
onClick={handleOpen} 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" /> <TerminalIcon className="h-3 w-3" />
SSH Terminal SSH
</button> </button>
) )
} }

View File

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

View File

@@ -78,14 +78,15 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
openMutation.mutate() openMutation.mutate()
}} }}
disabled={openMutation.isPending} 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 ? ( {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> </button>
{error && <p className="mt-2 text-sm text-error">{error}</p>} {error && <p className="mt-2 text-sm text-error">{error}</p>}
</div> </div>
@@ -94,18 +95,17 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
if (state === 'ready' && tunnelInfo) { if (state === 'ready' && tunnelInfo) {
return ( return (
<div className="rounded-md border p-4 space-y-3"> <div className="rounded-sm border border-border-default bg-panel p-2.5 space-y-2">
<p className="font-medium text-sm">WinBox tunnel ready</p> <p className="text-xs text-text-primary">
<p className="text-sm text-text-muted"> Tunnel ready: <code className="font-mono text-[10px] text-text-secondary">{tunnelInfo.host}:{tunnelInfo.port}</code>
Connect to: <code className="font-mono">{tunnelInfo.host}:{tunnelInfo.port}</code>
</p> </p>
<div className="flex gap-2"> <div className="flex gap-1">
<button <button
onClick={copyAddress} 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" /> <Copy className="h-2.5 w-2.5" />
{copied ? 'Copied!' : 'Copy Address'} {copied ? 'Copied' : 'Copy'}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -113,15 +113,13 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
closeMutation.mutate() closeMutation.mutate()
}} }}
disabled={closeMutation.isPending} 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" /> <X className="h-2.5 w-2.5" />
Close Tunnel Close
</button> </button>
</div> </div>
<p className="text-xs text-text-muted"> <p className="text-[9px] text-text-muted">Closes after 5 min idle</p>
Tunnel closes after 5 min of inactivity
</p>
</div> </div>
) )
} }

View File

@@ -1,15 +1,20 @@
import { type ReactNode } from 'react' import { type ReactNode, useEffect } from 'react'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { ContextStrip } from './ContextStrip'
import { ShortcutsDialog } from './ShortcutsDialog' import { ShortcutsDialog } from './ShortcutsDialog'
import { CommandPalette } from '@/components/command-palette/CommandPalette' import { CommandPalette } from '@/components/command-palette/CommandPalette'
import { Toaster } from '@/components/ui/toast' import { Toaster } from '@/components/ui/toast'
import { useUIStore } from '@/lib/store'
interface AppLayoutProps { interface AppLayoutProps {
children: ReactNode children: ReactNode
} }
export function AppLayout({ children }: AppLayoutProps) { 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 ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<a <a
@@ -20,7 +25,6 @@ export function AppLayout({ children }: AppLayoutProps) {
</a> </a>
<Sidebar /> <Sidebar />
<div className="flex flex-col flex-1 min-w-0 overflow-hidden"> <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"> <main id="main-content" tabIndex={-1} className="flex-1 overflow-auto p-5">
{children} {children}
</main> </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 { useEffect, useRef } from 'react'
import { APP_VERSION } from '@/lib/version' import { APP_VERSION } from '@/lib/version'
import { Link, useRouterState } from '@tanstack/react-router' import { Link, useRouterState, useNavigate } from '@tanstack/react-router'
import { import {
Monitor, Monitor,
Building2, Building2,
Users, Users,
Settings, Settings,
ChevronLeft, LayoutDashboard,
ChevronRight, Wifi,
Download, MapPin,
Bell,
Map,
Terminal, Terminal,
FileCode, FileCode,
LayoutDashboard, Download,
ClipboardList,
Wifi,
BarChart3,
MapPin,
ShieldCheck,
KeyRound,
Info,
Bell,
Network,
Map,
Layers,
Wrench, Wrench,
ClipboardList,
BellRing,
Calendar, Calendar,
FileBarChart, FileBarChart,
Eye, ShieldCheck,
BellRing, KeyRound,
Sun,
Moon,
LogOut,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth' import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store' 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 { RugLogo } from '@/components/brand/RugLogo'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { ConnectionState } from '@/hooks/useEventStream'
// ─── Types ──────────────────────────────────────────────────────────────────
interface NavItem { interface NavItem {
label: string label: string
href: string href: string
icon: React.FC<{ className?: string }> icon: React.FC<{ className?: string }>
exact?: boolean exact?: boolean
badge?: number
} }
interface NavSection { // ─── Constants ──────────────────────────────────────────────────────────────
label: string
items: NavItem[] const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
visible: boolean
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() { export function Sidebar() {
const { user } = useAuth() const { user, logout } = useAuth()
const { sidebarCollapsed, toggleSidebar, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore() const {
sidebarCollapsed,
toggleSidebar,
mobileSidebarOpen,
setMobileSidebarOpen,
selectedTenantId,
setSelectedTenantId,
theme,
setTheme,
uiScale,
setUIScale,
} = useUIStore()
const { connectionState } = useEventStreamContext()
const routerState = useRouterState() const routerState = useRouterState()
const currentPath = routerState.location.pathname 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(() => { useEffect(() => {
if (!mobileSidebarOpen) return if (!mobileSidebarOpen) return
@@ -86,9 +224,8 @@ export function Sidebar() {
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [mobileSidebarOpen, setMobileSidebarOpen]) }, [mobileSidebarOpen, setMobileSidebarOpen])
const navRef = useRef<HTMLElement>(null) // ─── Keyboard shortcut: [ to toggle ───────────────────────────────────
// Keyboard toggle: [ key collapses/expands sidebar
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if (
@@ -105,262 +242,253 @@ export function Sidebar() {
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar]) }, [toggleSidebar])
const sections: NavSection[] = [ // ─── Logout handler ───────────────────────────────────────────────────
{
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,
},
],
},
]
const visibleSections = sections.filter((s) => s.visible) const handleLogout = async () => {
await logout()
const isActive = (item: NavItem) => { void navigate({ to: '/login' })
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 sidebarContent = (showCollapsed: boolean) => ( // ─── Render helpers ───────────────────────────────────────────────────
<>
{/* Logo */}
<div
className={cn(
'flex items-center h-12 px-3 border-b border-border',
showCollapsed ? 'justify-center' : 'gap-2',
)}
>
<RugLogo size={showCollapsed ? 24 : 28} className="flex-shrink-0" />
{!showCollapsed && (
<span className="text-sm font-semibold text-text-primary truncate">
TOD
</span>
)}
</div>
{/* Navigation */} const renderNavItem = (item: NavItem, collapsed: boolean) => {
<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 Icon = item.icon
const active = isActive(item) const active = isActive(item)
return ( return (
<Link <Link
key={`${section.label}-${item.label}`} key={item.href}
to={item.href} to={item.href}
onClick={() => setMobileSidebarOpen(false)} onClick={() => setMobileSidebarOpen(false)}
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`} data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
className={cn( className={cn(
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded-md text-sm transition-colors min-h-[44px]', 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 active
? 'bg-[hsl(var(--accent-muted))] text-accent rounded-md' ? navItemActive
: 'text-text-muted hover:text-text-primary hover:bg-elevated/50 rounded-md', : collapsed
showCollapsed && 'justify-center px-0', ? 'text-text-secondary'
: navItemInactive,
)} )}
title={showCollapsed ? item.label : undefined} title={collapsed ? item.label : undefined}
aria-label={showCollapsed ? item.label : undefined} aria-label={collapsed ? item.label : undefined}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" /> <Icon className={iconClass} aria-hidden="true" />
{!showCollapsed && ( {!collapsed && (
<span className="truncate">{item.label}</span> <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> </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 area */}
<div
className={cn(
'flex items-center border-b border-border-subtle px-3 py-2',
collapsed ? 'justify-center h-12' : 'gap-2 min-h-[48px]',
)}
>
<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>
)}
</div>
{/* 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"
>
{tenant.name}
</DropdownMenuItem>
))} ))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{superAdmin && collapsed && (
<div className="border-b border-border-subtle" />
)}
{/* 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>
)}
{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> </nav>
{/* Version identifier */} {/* Footer */}
{!showCollapsed && ( <div className="border-t border-border-subtle px-3 py-2">
<div className="px-3 py-1 text-center"> {!collapsed ? (
<span className="font-mono text-[9px] text-text-muted">TOD {APP_VERSION}</span> <>
</div> {/* 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">
{/* Collapse toggle (hidden on mobile) */} {user?.email}
</span>
<button <button
onClick={toggleSidebar} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="hidden lg:flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-secondary transition-colors" className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
title={showCollapsed ? 'Expand sidebar ([)' : 'Collapse sidebar ([)'} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-label={showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-testid="sidebar-toggle"
> >
{showCollapsed ? ( {theme === 'dark' ? (
<ChevronRight className="h-4 w-4" /> <Sun className="h-3 w-3" />
) : ( ) : (
<ChevronLeft className="h-4 w-4" /> <Moon className="h-3 w-3" />
)} )}
</button> </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-testid="sidebar"
data-sidebar data-sidebar
className={cn( className={cn(
'hidden lg:flex flex-col border-r border-border bg-sidebar transition-all duration-200', 'hidden lg:flex flex-col border-r border-border-default bg-sidebar transition-[width] duration-200',
sidebarCollapsed ? 'w-14' : 'w-[180px]', sidebarCollapsed ? 'w-14' : 'w-[172px]',
)} )}
> >
{sidebarContent(sidebarCollapsed)} {sidebarContent(sidebarCollapsed)}
</aside> </aside>
{/* Mobile hamburger (rendered outside sidebar for AppLayout) */}
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileSidebarOpen && ( {mobileSidebarOpen && (
<> <>
@@ -390,7 +520,7 @@ export function Sidebar() {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Navigation" 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)} {sidebarContent(false)}
</aside> </aside>

View File

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

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { MapPin } from 'lucide-react' import { MapPin } from 'lucide-react'
import { metricsApi, tenantsApi } from '@/lib/api' import { metricsApi, tenantsApi } from '@/lib/api'
import { useAuth, isSuperAdmin } from '@/lib/auth' import { useAuth, isSuperAdmin } from '@/lib/auth'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
import { FleetMap } from './FleetMap' import { FleetMap } from './FleetMap'
export function MapPage() { export function MapPage() {
@@ -55,7 +55,11 @@ export function MapPage() {
}, [superAdmin, selectedTenant, user]) }, [superAdmin, selectedTenant, user])
if (devicesLoading) { 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) { if (devicesError) {
@@ -88,7 +92,7 @@ export function MapPage() {
<select <select
value={selectedTenant} value={selectedTenant}
onChange={(e) => setSelectedTenant(e.target.value)} 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> <option value="all">All Organizations</option>
{tenants.map((t) => ( {tenants.map((t) => (

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ interface SparklineProps {
height?: number 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 })) const chartData = data.map((v, i) => ({ v, i }))
return ( return (
<LineChart width={width} height={height} data={chartData}> <LineChart width={width} height={height} data={chartData}>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api' import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
interface InterfaceGaugesProps { interface InterfaceGaugesProps {
tenantId: string tenantId: string
@@ -101,21 +101,15 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-3"> <div className="py-8 text-center">
{[0, 1, 2].map((i) => ( <LoadingText />
<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> </div>
) )
} }
if (!interfaces || interfaces.length === 0) { if (!interfaces || interfaces.length === 0) {
return ( 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. No interface data available.
</div> </div>
) )
@@ -149,7 +143,7 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
const values = latestByIface.get(ifaceName) ?? { rx: 0, tx: 0 } const values = latestByIface.get(ifaceName) ?? { rx: 0, tx: 0 }
return ( 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"> <div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-text-primary">{ifaceName}</span> <span className="text-sm font-medium text-text-primary">{ifaceName}</span>
<span className="text-[10px] text-text-muted"> <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 { useQuery } from '@tanstack/react-query'
import { Search, RefreshCw } from 'lucide-react' import { Search, RefreshCw } from 'lucide-react'
import { networkApi, type LogEntry } from '@/lib/networkApi' import { networkApi, type LogEntry } from '@/lib/networkApi'
import { Skeleton } from '@/components/ui/skeleton' import { LoadingText } from '@/components/ui/skeleton'
interface LogsTabProps { interface LogsTabProps {
tenantId: string tenantId: string
@@ -62,16 +62,10 @@ function TopicBadge({ topics }: { topics: string }) {
) )
} }
function TableSkeleton() { function TableLoading() {
return ( return (
<div className="space-y-1"> <div className="py-8 text-center">
{Array.from({ length: 8 }).map((_, i) => ( <LoadingText />
<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> </div>
) )
} }
@@ -187,9 +181,9 @@ export function LogsTab({ tenantId, deviceId, active }: LogsTabProps) {
</div> </div>
{/* Log table */} {/* 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 ? ( {isLoading ? (
<TableSkeleton /> <TableLoading />
) : error ? ( ) : error ? (
<div className="p-6 text-center text-sm text-error"> <div className="p-6 text-center text-sm text-error">
Failed to fetch device logs. The device may be offline or unreachable. Failed to fetch device logs. The device may be offline or unreachable.

View File

@@ -99,7 +99,7 @@ function DeviceNode({ data }: NodeProps<DeviceNodeData>) {
return ( return (
<div <div
className={cn( 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', 'transition-colors',
isOnline ? 'border-border' : 'border-error/30', isOnline ? 'border-border' : 'border-error/30',
)} )}
@@ -339,7 +339,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
> >
<Background color="hsl(var(--muted))" gap={20} size={1} /> <Background color="hsl(var(--muted))" gap={20} size={1} />
<Controls <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 <MiniMap
nodeColor={(node) => { nodeColor={(node) => {
@@ -349,7 +349,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
: 'hsl(var(--error))' : 'hsl(var(--error))'
}} }}
maskColor="hsl(var(--background) / 0.7)" maskColor="hsl(var(--background) / 0.7)"
className="!bg-surface !border-border" className="!bg-panel !border-border"
/> />
</ReactFlow> </ReactFlow>
@@ -357,7 +357,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
{tooltip && <NodeTooltip data={tooltip} onClose={() => setTooltip(null)} />} {tooltip && <NodeTooltip data={tooltip} onClose={() => setTooltip(null)} />}
{/* Legend */} {/* 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"> <div className="flex items-center gap-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-success" /> Online <span className="h-2 w-2 rounded-full bg-success" /> Online

View File

@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Shield, Lock, Globe } from 'lucide-react' import { Shield, Lock, Globe } from 'lucide-react'
import { networkApi, type VpnTunnel } from '@/lib/networkApi' 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' import { Badge } from '@/components/ui/badge'
interface VpnTabProps { interface VpnTabProps {
@@ -26,17 +26,17 @@ const VPN_TYPE_CONFIG = {
wireguard: { wireguard: {
icon: Shield, icon: Shield,
label: 'WireGuard', label: 'WireGuard',
color: '#a855f7', // purple color: 'hsl(var(--accent))',
}, },
ipsec: { ipsec: {
icon: Lock, icon: Lock,
label: 'IPsec', label: 'IPsec',
color: '#3b82f6', // blue color: 'hsl(var(--info))',
}, },
l2tp: { l2tp: {
icon: Globe, icon: Globe,
label: 'L2TP', label: 'L2TP',
color: '#22c55e', // green color: 'hsl(var(--success))',
}, },
} as const } as const
@@ -102,17 +102,15 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-4 space-y-2"> <div className="mt-4 py-8 text-center">
<Skeleton className="h-10 w-full" /> <LoadingText />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div> </div>
) )
} }
if (error) { if (error) {
return ( 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. Failed to load VPN tunnels. The device may not support this feature.
</div> </div>
) )
@@ -120,7 +118,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
if (!data || data.tunnels.length === 0) { if (!data || data.tunnels.length === 0) {
return ( 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" /> <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"> <p className="text-sm font-medium text-text-primary mb-1">
No active VPN tunnels No active VPN tunnels
@@ -134,7 +132,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
} }
return ( 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"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-border bg-elevated/50"> <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: Enter Command */}
{step === 2 && ( {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> <div>
<h3 className="text-sm font-semibold">Enter RouterOS Command</h3> <h3 className="text-sm font-semibold">Enter RouterOS Command</h3>
<p className="text-xs text-text-muted mt-0.5"> <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: Review & Execute */}
{step === 3 && ( {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> <div>
<h3 className="text-sm font-semibold">Review & Execute</h3> <h3 className="text-sm font-semibold">Review & Execute</h3>
<p className="text-xs text-text-muted mt-0.5"> <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"> <div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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"> <th className="text-left px-3 py-2 text-xs font-medium text-text-muted">
Device Device
</th> </th>
@@ -611,11 +611,11 @@ function DeviceSelectionStep({
} }
if (devicesLoading) { if (devicesLoading) {
return <TableSkeleton rows={5} /> return <TableSkeleton />
} }
return ( 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> <div>
<h3 className="text-sm font-semibold">Select Target Devices</h3> <h3 className="text-sm font-semibold">Select Target Devices</h3>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-xs text-text-muted mt-0.5">
@@ -641,7 +641,7 @@ function DeviceSelectionStep({
className={cn( className={cn(
'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors', 'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors',
mode === opt.value mode === opt.value
? 'bg-surface text-text-primary shadow-sm' ? 'bg-panel text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary', : '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"> <div className="rounded-md border border-border/50 overflow-hidden max-h-72 overflow-y-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0"> <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"> <th className="px-3 py-2 w-8">
<Checkbox <Checkbox
checked={ checked={

View File

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

View File

@@ -166,7 +166,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
action={{ label: 'Create API Key', onClick: () => setShowCreateDialog(true) }} 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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <tr className="border-b border-border bg-elevated/30">
@@ -264,7 +264,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
</label> </label>
<input <input
type="text" 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" placeholder="e.g. Monitoring Integration"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -300,7 +300,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
</label> </label>
<input <input
type="date" 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} value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)} onChange={(e) => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]} min={new Date().toISOString().split('T')[0]}

View File

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

View File

@@ -19,7 +19,7 @@ export function SimpleFormSection({
children, children,
}: SimpleFormSectionProps) { }: SimpleFormSectionProps) {
return ( 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"> <div className="flex items-center gap-2.5">
<Icon className="h-4.5 w-4.5 text-accent flex-shrink-0" /> <Icon className="h-4.5 w-4.5 text-accent flex-shrink-0" />
<div> <div>

View File

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

View File

@@ -16,7 +16,7 @@ export function SimpleStatusBanner({ items, isLoading }: SimpleStatusBannerProps
<div key={i} className="flex flex-col"> <div key={i} className="flex flex-col">
<span className="text-xs text-text-muted">{item.label}</span> <span className="text-xs text-text-muted">{item.label}</span>
{isLoading ? ( {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"> <span className="text-sm font-medium text-text-primary">
{item.value || '\u2014'} {item.value || '\u2014'}

View File

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

View File

@@ -180,7 +180,7 @@ export function LanDhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
{activeLeases.length === 0 ? ( {activeLeases.length === 0 ? (
<p className="text-xs text-text-muted">No active DHCP leases</p> <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"> <table className="w-full text-xs">
<thead> <thead>
<tr className="border-b border-border bg-elevated/30"> <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 // No wireless hardware
if (wireless.entries.length === 0) { if (wireless.entries.length === 0) {
return ( 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" /> <Wifi className="h-8 w-8 text-text-muted/50 mx-auto mb-3" />
<p className="text-sm font-medium text-text-secondary"> <p className="text-sm font-medium text-text-secondary">
This device does not have wireless hardware This device does not have wireless hardware

View File

@@ -57,15 +57,8 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
if (devicesLoading) { if (devicesLoading) {
return ( return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"> <div className="py-8 text-center">
{Array.from({ length: 6 }).map((_, i) => ( <span className="text-[9px] text-text-muted">Loading&hellip;</span>
<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> </div>
) )
} }
@@ -74,7 +67,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
if (devices.length === 0) { if (devices.length === 0) {
return ( 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"> <p className="text-sm text-text-muted">
No devices assigned to this site. Assign devices from the fleet page. No devices assigned to this site. Assign devices from the fleet page.
</p> </p>
@@ -103,7 +96,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
to="/tenants/$tenantId/devices/$deviceId" to="/tenants/$tenantId/devices/$deviceId"
params={{ tenantId, deviceId: device.id }} params={{ tenantId, deviceId: device.id }}
className={cn( 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), borderColor(device.status),
)} )}
> >

View File

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

View File

@@ -101,7 +101,7 @@ export function PushProgressPanel({ tenantId, rolloutId, onClose }: PushProgress
return ( return (
<div <div
key={job.device_id} 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 <Icon
className={cn( className={cn(

View File

@@ -164,7 +164,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="What does this template configure?" placeholder="What does this template configure?"
rows={2} 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> </div>
@@ -176,7 +176,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
onChange={(e) => setContent(e.target.value)} 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`} 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} 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> </div>
@@ -247,7 +247,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
<div className="rounded-lg border border-border overflow-hidden"> <div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <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">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 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> <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 ( return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}> <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> <DialogHeader>
<DialogTitle className="text-sm flex items-center gap-2"> <DialogTitle className="text-sm flex items-center gap-2">
Push Template: {template.name} Push Template: {template.name}
@@ -193,7 +193,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
{devices?.map((device) => ( {devices?.map((device) => (
<label <label
key={device.id} 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 <Checkbox
checked={selectedDeviceIds.has(device.id)} 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. Provide values for template variables. Built-in device variables are auto-populated per device.
</div> </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 }}'} Auto-populated: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'}
</div> </div>
@@ -321,7 +321,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
'text-xs px-2 py-1 rounded transition-colors', 'text-xs px-2 py-1 rounded transition-colors',
previewDevice === d.id previewDevice === d.id
? 'bg-elevated text-text-primary' ? '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} {d.hostname}

View File

@@ -229,7 +229,7 @@ export function TemplatesPage() {
{templates.map((template) => ( {templates.map((template) => (
<div <div
key={template.id} 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 items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -313,7 +313,7 @@ export function TemplatesPage() {
open={!!deleteConfirmId} open={!!deleteConfirmId}
onOpenChange={(o) => !o && setDeleteConfirmId(null)} 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> <DialogHeader>
<DialogTitle className="text-sm">Delete Template</DialogTitle> <DialogTitle className="text-sm">Delete Template</DialogTitle>
</DialogHeader> </DialogHeader>

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