feat: interactive site redesign with dot matrix effects and component polish

This commit is contained in:
Matt
2026-08-25 13:08:21 +00:00
parent efbdf2356f
commit 563d9c0eeb
33 changed files with 969 additions and 505 deletions
+11 -12
View File
@@ -18,6 +18,7 @@ import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail'
import { SettingsPage } from '@/components/settings/SettingsPage'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { ToastProvider, useToast } from '@/components/ui/toast'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Server, AlertTriangle } from 'lucide-react'
import { useConnectionStore } from '@/stores/connectionStore'
import { useUIStore } from '@/stores/uiStore'
@@ -236,15 +237,13 @@ function AppContent() {
if (!activeConnectionId) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
<div className="flex h-full items-center justify-center p-6 dot-grid">
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-border bg-card p-8 shadow-card">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-5 w-5 text-primary" />
</div>
<h2 className="text-lg font-semibold tracking-tight">
No connection selected
</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<DotMatrixText text="NO CONNECTION" size="sm" className="text-foreground" />
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Connect to a Proxmox server to get started
</p>
</div>
@@ -375,11 +374,11 @@ function AppContent() {
}, [activeConnectionId, activeServerType, view.type])
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex h-screen flex-col bg-background dot-grid">
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
<div className="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
<div className="flex items-center gap-2 border-b border-dotted border-warning/30 bg-warning/10 px-4 py-1.5 dot-grid">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<p className="text-xs font-medium text-warning">Operating on a fallback node</p>
<DotMatrixText text="FALLBACK NODE" size="xs" className="text-warning" />
<p className="truncate font-mono text-[11px] tabular-nums text-warning/80">
{activeConnection.currentEndpointUrl}
</p>
@@ -391,9 +390,9 @@ function AppContent() {
activeView={view.type}
onNavigate={(v) => handleNavigate(v as View)}
/>
<main className="flex min-h-0 flex-1 flex-col">
<main className="flex min-h-0 flex-1 flex-col bg-background/60">
{activeConnectionId && (
<div className="shrink-0 border-b px-3 py-1.5">
<div className="shrink-0 border-b border-dotted border-border px-3 py-1.5 dot-grid">
<TaskStatusBar
connectionId={activeConnectionId}
onClick={() => handleNavigate({ type: 'tasks' })}
+21 -21
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Activity, Clock, Loader2 } from 'lucide-react'
@@ -16,20 +17,20 @@ function isRunningTask(status?: string): boolean {
function getStatusBadgeClass(status?: string): string {
switch (status) {
case 'OK':
return 'border-success/25 bg-success/10 text-success'
return 'border-dotted border-success/30 bg-success/10 text-success'
case 'unknown':
return 'border-warning/25 bg-warning/10 text-warning'
return 'border-dotted border-warning/30 bg-warning/10 text-warning'
default:
if (!status || status === 'Running') {
return 'border-info/25 bg-info/10 text-info'
return 'border-dotted border-info/30 bg-info/10 text-info'
}
return 'border-destructive/25 bg-destructive/10 text-destructive'
return 'border-dotted border-destructive/30 bg-destructive/10 text-destructive'
}
}
function getStatusLabel(status?: string): string {
if (!status || status === 'Running') return 'Running'
return status
if (!status || status === 'Running') return 'RUNNING'
return status.toUpperCase()
}
function formatDuration(starttime: number, endtime?: number): string {
@@ -52,7 +53,6 @@ function formatTime(timestamp: number): string {
}
function getTaskTypeLabel(type: string): string {
// Capitalize and format Proxmox task types
return type
.replace(/qm/gi, 'VM')
.replace(/vz/gi, 'CT')
@@ -68,16 +68,16 @@ function getTaskTypeLabel(type: string): string {
export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="space-y-2">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
@@ -91,30 +91,30 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
const recentTasks = tasks?.slice(0, 15) ?? []
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
{recentTasks.length > 0 ? (
<ScrollArea className="h-[300px]">
<div className="space-y-1.5">
<div className="space-y-0">
{recentTasks.map((task) => (
<div
key={task.upid}
className="flex items-center justify-between rounded-md border border-border/70 bg-background/30 px-3 py-2.5 transition-colors duration-150 hover:border-border hover:bg-accent/40"
className="flex items-center justify-between px-3 py-2.5 transition-colors duration-150 hover:bg-accent/30 feed-rule"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[13px] font-medium">{getTaskTypeLabel(task.type)}</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[11px] font-medium ${getStatusBadgeClass(task.status)}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border border-dotted px-1.5 py-0.5 text-[10px] font-medium tracking-widest ${getStatusBadgeClass(task.status)}`}
>
{isRunningTask(task.status) && (
<Loader2 className="h-3 w-3 animate-spin" />
+18 -27
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
@@ -20,30 +21,23 @@ function getStatusTextColor(status: 'online' | 'offline'): string {
return status === 'online' ? 'text-success' : 'text-destructive'
}
function getStatusLabel(status: 'online' | 'offline'): string {
return status === 'online' ? 'Online' : 'Offline'
}
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3 border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<span className={cn('h-2 w-2 rounded-full', getStatusColor(node.status))} />
<CardTitle className="font-mono text-sm font-semibold tracking-tight">
<span className="font-mono text-sm font-semibold tracking-tight text-foreground">
{node.node}
</CardTitle>
</span>
</div>
<span className={cn('text-xs font-medium', getStatusTextColor(node.status))}>
{getStatusLabel(node.status)}
</span>
<DotMatrixText text={node.status === 'online' ? 'ONLINE' : 'OFFLINE'} size="xs" className={getStatusTextColor(node.status)} />
</CardHeader>
<CardContent className="space-y-3">
{/* Stats row */}
<CardContent className="space-y-3 pt-3">
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 shrink-0" />
@@ -58,9 +52,7 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
<span>{(diskPercent * 100).toFixed(0)}%</span>
</div>
</div>
{/* Bottom info */}
<div className="flex items-center justify-between border-t pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center justify-between border-t border-dotted border-border/60 pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 shrink-0" />
<span>{formatUptime(node.uptime)}</span>
@@ -80,7 +72,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
}
export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
// Count VMs per node
const vmCounts = useMemo(() => {
const counts: Record<string, number> = {}
vms?.forEach((vm) => {
@@ -91,13 +82,13 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
if (!nodes || nodes.length === 0) {
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
@@ -108,16 +99,16 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
}
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.node} node={node} vmCount={vmCounts[node.node] ?? 0} />
+19 -18
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Button } from '@/components/ui/button'
import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield, Zap } from 'lucide-react'
@@ -10,65 +11,65 @@ interface QuickActionsProps {
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Quick Actions</CardTitle>
<DotMatrixText text="QUICK ACTIONS" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="text-xs font-medium">Refresh</span>
<DotMatrixText text="REFRESH" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('nodes')}
>
<Server className="h-4 w-4" />
<span className="text-xs font-medium">Nodes</span>
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('vms')}
>
<Box className="h-4 w-4" />
<span className="text-xs font-medium">VMs</span>
<DotMatrixText text="VMS" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('storage')}
>
<HardDrive className="h-4 w-4" />
<span className="text-xs font-medium">Storage</span>
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('tasks')}
>
<ListTodo className="h-4 w-4" />
<span className="text-xs font-medium">Tasks</span>
<DotMatrixText text="TASKS" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('backups')}
>
<Shield className="h-4 w-4" />
<span className="text-xs font-medium">Backups</span>
<DotMatrixText text="BACKUPS" size="xs" className="text-muted-foreground" />
</Button>
</div>
</CardContent>
+13 -19
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { formatBytes } from '@/lib/format'
@@ -46,45 +47,40 @@ function getTrackColor(): string {
export function ResourceGauge({ label, used, total, icon, formatValue }: ResourceGaugeProps) {
const percent = total > 0 ? used / total : 0
const clampedPercent = Math.min(Math.max(percent, 0), 1)
const displayPercent = (clampedPercent * 100).toFixed(1)
const displayPercent = `${(clampedPercent * 100).toFixed(1)}%`
const Icon = iconMap[icon]
// SVG circle parameters
const radius = 40
const strokeWidth = 8
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - clampedPercent)
const defaultFormat =
icon === 'cpu'
? formatCores
: formatBytes
const defaultFormat = icon === 'cpu' ? formatCores : formatBytes
const formatter = formatValue ?? defaultFormat
const gaugeStyle = getGaugeStyle(clampedPercent)
return (
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text={label} size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<div className="relative">
<svg width="108" height="108" viewBox="0 0 100 100">
{/* Background track */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeWidth={strokeWidth - 1}
strokeDasharray="1 3"
strokeLinecap="round"
className={getTrackColor()}
opacity="0.35"
/>
{/* Progress arc */}
<circle
cx="50"
cy="50"
@@ -92,7 +88,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDasharray={`${circumference * 0.018} ${circumference * 0.012}`}
strokeDashoffset={strokeDashoffset}
className={gaugeStyle.className}
style={{
@@ -104,9 +100,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-mono text-xl font-semibold leading-none tracking-tight tabular-nums">
{displayPercent}%
</span>
<DotMatrixText text={displayPercent} size="xs" className="text-foreground" />
</div>
</div>
<div className="text-center font-mono text-xs tabular-nums text-muted-foreground">
+31 -47
View File
@@ -1,7 +1,8 @@
import { useCallback, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Server, Cpu, HardDrive, MemoryStick, AlertCircle } from 'lucide-react'
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
@@ -31,9 +32,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
// Track actual refresh activity rather than piggybacking on tasksLoading,
// which is true whenever the tasks query is fetching (e.g. background
// polling) and would keep the Refresh button spinning continuously.
const [refreshing, setRefreshing] = useState(false)
const handleRefresh = useCallback(async () => {
@@ -60,19 +58,19 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const hasError = nodesError || vmsError || tasksError
if (hasError) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
<div className="flex h-full items-center justify-center p-6 dot-grid">
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-destructive/30 bg-card p-8 shadow-card">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-destructive/30 bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<h3 className="text-lg font-semibold tracking-tight">Unable to load dashboard</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<DotMatrixText text="LOAD FAILED" size="sm" className="text-destructive" />
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Check the connection to your Proxmox server and try again.
</p>
<Button variant="outline" className="mt-4" onClick={handleRetry}>
<Button variant="outline" className="mt-4 border-dotted" onClick={handleRetry}>
Retry
</Button>
</div>
@@ -81,80 +79,69 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
}
return (
<div className="h-full overflow-auto p-6">
<div className="h-full overflow-auto p-6 dot-grid">
<div className="space-y-6">
<div>
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
Dashboard
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<DotMatrixText text="DASHBOARD" size="md" className="text-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
Cluster overview and resource usage
</p>
<hr className="dot-rule mt-3" />
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{nodes?.filter((n) => n.status === 'online').length ?? 0}
<span className="text-muted-foreground"> / {nodes?.length ?? 0}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">Online</p>
<DotMatrixText text={`${nodes?.filter((n) => n.status === 'online').length ?? 0} / ${nodes?.length ?? 0}`} size="sm" className="text-foreground" />
<p className="mt-1.5 text-xs tracking-widest text-muted-foreground">ONLINE</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="CPU USAGE" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
</div>
<DotMatrixText text={totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{usedCPU.toFixed(1)} / {totalCPU} cores
</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="MEMORY" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'}
</div>
<DotMatrixText text={totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedMem)} / {formatBytes(totalMem)}
</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'}
</div>
<DotMatrixText text={totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedDisk)} / {formatBytes(totalDisk)}
</p>
@@ -162,7 +149,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
</Card>
</div>
{/* Resource Gauges */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ResourceGauge
label="CPU"
@@ -172,14 +158,14 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
formatValue={(v) => `${v.toFixed(1)} cores`}
/>
<ResourceGauge
label="Memory"
label="MEMORY"
used={usedMem}
total={totalMem}
icon="memory"
formatValue={formatBytes}
/>
<ResourceGauge
label="Storage"
label="STORAGE"
used={usedDisk}
total={totalDisk}
icon="disk"
@@ -187,10 +173,8 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
/>
</div>
{/* Node Health Grid */}
<NodeHealthGrid nodes={nodes} vms={vms} />
{/* Activity Feed + Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
+33 -34
View File
@@ -1,6 +1,7 @@
import { useConnectionStore } from '@/stores/connectionStore'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -36,16 +37,13 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
}
return (
<div className="flex w-64 flex-col border-r border-border bg-card">
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
<img src="/icon.png" alt="Clustri" className="h-7 w-7 rounded-md" />
<div className="flex w-64 flex-col border-r border-dotted border-border bg-card dot-grid">
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-dotted border-border px-4 bg-card/80">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm border border-dotted border-border bg-primary/10 text-primary">
<Server className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 leading-tight">
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">
Clustri
</h1>
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Ops Console
</p>
<DotMatrixText text="CLUSTRI" size="xs" className="text-foreground" />
</div>
</div>
<ScrollArea className="flex-1">
@@ -55,10 +53,10 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
<button
onClick={() => setActiveConnection(connection.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150',
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150 border border-transparent',
activeConnectionId === connection.id
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'bg-accent text-accent-foreground border-dotted border-border'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/60',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusColor(connection.status))} />
@@ -79,59 +77,60 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
<>
<SidebarItem
icon={LayoutDashboard}
label="Overview"
label="OVERVIEW"
active={activeView === 'pbs-overview'}
onClick={() => onNavigate?.({ type: 'pbs-overview' })}
/>
<SidebarItem
icon={HardDrive}
label="Datastores"
label="DATASTORES"
active={activeView === 'pbs-datastores' || activeView === 'pbs-datastore-detail'}
onClick={() => onNavigate?.({ type: 'pbs-datastores' })}
/>
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
</>
) : (
<>
<SidebarItem
icon={LayoutDashboard}
label="Dashboard"
label="DASHBOARD"
active={activeView === 'dashboard'}
onClick={() => onNavigate?.({ type: 'dashboard' })}
/>
<SidebarItem
icon={Server}
label="Nodes"
label="NODES"
active={activeView === 'nodes'}
onClick={() => onNavigate?.({ type: 'nodes' })}
/>
<SidebarItem
icon={Box}
label="VMs"
label="VMS"
active={activeView === 'vms' || activeView === 'vm-detail'}
onClick={() => onNavigate?.({ type: 'vms' })}
/>
<SidebarItem
icon={Box}
label="Containers"
label="CONTAINERS"
active={activeView === 'containers'}
onClick={() => onNavigate?.({ type: 'containers' })}
/>
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
<SidebarItem icon={HardDrive} label="STORAGE" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="BACKUPS" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
</>
)}
{connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && (
<>
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Cluster nodes
</p>
<div className="px-3 pb-1 pt-3">
<DotMatrixText text="CLUSTER NODES" size="xs" className="text-muted-foreground opacity-60" />
</div>
<hr className="dot-rule mx-2" />
{connection.nodes.map((node) => (
<button
key={node.url}
onClick={() => onNavigate?.({ type: 'node-detail', nodeName: node.name })}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground"
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground border border-transparent hover:border-dotted hover:border-border/50"
>
<span
className={cn(
@@ -141,7 +140,7 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
/>
<span className="min-w-0 flex-1 truncate font-mono">{node.name}</span>
{node.isPrimary && (
<span className="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px text-[10px] uppercase text-primary">
<span className="shrink-0 rounded-sm border border-dotted border-primary/40 bg-primary/10 px-1 py-px text-[10px] uppercase tracking-widest text-primary">
primary
</span>
)}
@@ -161,16 +160,16 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
))}
</div>
</ScrollArea>
<div className="space-y-1 border-t border-border p-2">
<div className="space-y-1 border-t border-dotted border-border p-2 bg-card/60">
<SidebarItem
icon={Settings}
label="Settings"
label="SETTINGS"
active={activeView === 'settings'}
onClick={() => onNavigate?.({ type: 'settings' })}
/>
<Button
variant="outline"
className="w-full justify-start gap-2"
className="w-full justify-start gap-2 border-dotted"
onClick={onAddConnection}
>
<Plus className="h-4 w-4" />
@@ -199,19 +198,19 @@ function SidebarItem({
onClick={onClick}
disabled={disabled}
className={cn(
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150',
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150 border border-transparent',
disabled
? 'pointer-events-none cursor-not-allowed opacity-50'
: active
? 'bg-primary/10 text-primary hover:bg-primary/15'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'bg-primary/10 text-primary border-dotted border-primary/20'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/50',
)}
>
{active && (
<span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
)}
<Icon className="h-4 w-4 shrink-0" />
<span>{label}</span>
<DotMatrixText text={label} size="xs" className={active ? 'text-primary' : 'text-muted-foreground'} dotClassName={active ? 'text-primary' : ''} />
</button>
)
}
+6 -7
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { ListTodo, Loader2 } from 'lucide-react'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { useTasks } from '@/hooks/useProxmox'
import type { ProxmoxTask } from '@/types/proxmox'
@@ -27,21 +28,19 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
return (
<button
onClick={onClick}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors duration-150 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
className="group flex w-full items-center gap-2 rounded-md border border-transparent px-3 py-2 text-sm transition-colors duration-150 hover:border-dotted hover:border-border/60 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
>
<ListTodo className="h-4 w-4" />
<ListTodo className="h-4 w-4 transition-colors group-hover:text-foreground" />
<span className="flex-1 text-left">
{runningCount > 0 ? (
<span className="flex items-center gap-1.5">
<span className="font-medium text-info">{runningCount} task{runningCount !== 1 ? 's' : ''} running</span>
<DotMatrixText text={`${runningCount} TASK${runningCount !== 1 ? 'S' : ''} RUNNING`} size="xs" className="text-info group-hover:text-foreground transition-colors" />
</span>
) : (
<span>No tasks running</span>
<DotMatrixText text="NO TASKS RUNNING" size="xs" className="text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</span>
{runningCount > 0 && (
<Loader2 className="h-3 w-3 text-info animate-spin" />
)}
{runningCount > 0 && <Loader2 className="h-3 w-3 text-info group-hover:text-foreground transition-colors animate-spin" />}
</button>
)
}
+3 -3
View File
@@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
'border border-dotted border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost:
@@ -31,7 +31,7 @@ const buttonVariants = cva(
variant: 'default',
size: 'default',
},
}
},
)
export interface ButtonProps
@@ -50,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
)
}
},
)
Button.displayName = 'Button'
+3 -3
View File
@@ -8,8 +8,8 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-card',
className
'rounded-lg border border-dotted bg-card text-card-foreground shadow-card',
className,
)}
{...props}
/>
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
'font-semibold leading-none tracking-tight text-foreground',
className
className,
)}
{...props}
/>
+6 -6
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
className,
)}
{...props}
/>
@@ -32,8 +32,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-dotted bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dot-grid',
className,
)}
{...props}
>
@@ -54,7 +54,7 @@ const DialogHeader = ({
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
className,
)}
{...props}
/>
@@ -68,7 +68,7 @@ const DialogFooter = ({
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
className,
)}
{...props}
/>
@@ -83,7 +83,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
'text-base font-semibold leading-none tracking-tight',
className
className,
)}
{...props}
/>
+107
View File
@@ -0,0 +1,107 @@
import { GLYPH_COLS, GLYPH_ROWS, getGlyph } from '@/lib/dotMatrixGlyphs'
import { cn } from '@/lib/utils'
type DotMatrixSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
const sizeMap: Record<DotMatrixSize, { dot: number; gap: number; charGap: number }> = {
xs: { dot: 1.8, gap: 1.3, charGap: 5 },
sm: { dot: 2.2, gap: 1.5, charGap: 6 },
md: { dot: 2.6, gap: 1.8, charGap: 7 },
lg: { dot: 3.0, gap: 2.0, charGap: 9 },
xl: { dot: 3.8, gap: 2.4, charGap: 12 },
}
interface DotMatrixTextProps {
text: string
size?: DotMatrixSize
className?: string
dotClassName?: string
'aria-label'?: string
}
function CharSvg({
glyph,
dot,
gap,
}: {
glyph: readonly string[]
dot: number
gap: number
}) {
const w = GLYPH_COLS * dot + (GLYPH_COLS - 1) * gap
const h = GLYPH_ROWS * dot + (GLYPH_ROWS - 1) * gap
const r = dot / 2
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-hidden="true"
className="shrink-0"
shapeRendering="geometricPrecision"
>
{glyph.map((row, y) =>
Array.from(row).map((cell, x) => {
if (cell === ' ') return null
const cx = x * (dot + gap) + r
const cy = y * (dot + gap) + r
return <circle key={`${x}-${y}`} cx={cx} cy={cy} r={r} fill="currentColor" />
}),
)}
</svg>
)
}
export function DotMatrixText({
text,
size = 'sm',
className,
dotClassName,
'aria-label': ariaLabel,
}: DotMatrixTextProps) {
const { dot, gap, charGap } = sizeMap[size]
const chars = Array.from(text.toUpperCase())
const label = ariaLabel ?? text
return (
<span
aria-label={label}
role="text"
className={cn('inline-flex items-center leading-none', dotClassName, className)}
style={{ gap: charGap }}
>
{chars.map((ch, i) => {
if (ch === ' ') {
return <span key={i} style={{ width: charGap * 1.8 }} aria-hidden="true" />
}
const glyph = getGlyph(ch)
if (!glyph) {
return (
<span
key={i}
className="font-mono text-[0.7em] leading-none"
aria-hidden="true"
>
{ch}
</span>
)
}
return <CharSvg key={i} glyph={glyph} dot={dot} gap={gap} />
})}
</span>
)
}
export function DotMatrixHeading({
text,
size = 'md',
className,
dotClassName,
as: Tag = 'span',
...rest
}: DotMatrixTextProps & { as?: 'h1' | 'h2' | 'h3' | 'span' | 'p' | 'div' }) {
return (
<Tag className={cn('block', className)} {...rest}>
<DotMatrixText text={text} size={size} dotClassName={dotClassName} />
</Tag>
)
}
+5 -4
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { DotMatrixText } from '@/components/ui/dot-matrix'
interface EmptyStateProps {
icon: LucideIcon
@@ -10,13 +11,13 @@ interface EmptyStateProps {
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<div className="flex flex-col items-center justify-center px-6 py-12 text-center dot-grid">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/50">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">{title}</p>
<DotMatrixText text={title.toUpperCase()} size="xs" className="text-foreground" />
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
+3 -3
View File
@@ -7,14 +7,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
className
'flex h-9 w-full rounded-md border border-dotted border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
}
},
)
Input.displayName = 'Input'
+14 -14
View File
@@ -10,33 +10,33 @@ interface StatusBadgeProps {
const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = {
running: {
label: 'Running',
color: 'border-success/25 bg-success/10 text-success',
label: 'RUNNING',
color: 'border-dotted border-success/30 bg-success/10 text-success',
icon: CheckCircle,
},
stopped: {
label: 'Stopped',
color: 'border-border bg-muted/60 text-muted-foreground',
label: 'STOPPED',
color: 'border-dotted border-border bg-muted/60 text-muted-foreground',
icon: Square,
},
paused: {
label: 'Paused',
color: 'border-warning/25 bg-warning/10 text-warning',
label: 'PAUSED',
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
icon: Pause,
},
suspended: {
label: 'Suspended',
color: 'border-warning/25 bg-warning/10 text-warning',
label: 'SUSPENDED',
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
icon: Clock,
},
online: {
label: 'Online',
color: 'border-success/25 bg-success/10 text-success',
label: 'ONLINE',
color: 'border-dotted border-success/30 bg-success/10 text-success',
icon: CheckCircle,
},
offline: {
label: 'Offline',
color: 'border-destructive/25 bg-destructive/10 text-destructive',
label: 'OFFLINE',
color: 'border-dotted border-destructive/30 bg-destructive/10 text-destructive',
icon: XCircle,
},
}
@@ -48,9 +48,9 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
'inline-flex items-center gap-1.5 rounded-sm border border-dotted px-2 py-0.5 text-[11px] font-medium tracking-widest',
config.color,
className
className,
)}
>
<Icon className="h-3 w-3" />
+5 -5
View File
@@ -11,8 +11,8 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground',
className
'inline-flex h-9 items-center justify-center rounded-md border border-dotted border-border bg-secondary p-1 text-muted-foreground dot-grid',
className,
)}
{...props}
/>
@@ -26,8 +26,8 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
'inline-flex items-center justify-center whitespace-nowrap rounded-sm border border-transparent px-3 py-1 text-sm font-medium tracking-wide ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:border-dotted data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
className,
)}
{...props}
/>
+3 -3
View File
@@ -39,7 +39,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
setToasts((prev) => [...prev, { id, message, variant }])
setTimeout(() => removeToast(id), 5000)
},
[removeToast]
[removeToast],
)
return (
@@ -104,8 +104,8 @@ function ToastItem({
return (
<div
className={cn(
'flex items-start gap-3 rounded-md border border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40',
config.bar
'flex items-start gap-3 rounded-md border border-dotted border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40 dot-grid',
config.bar,
)}
style={{
animation: 'toast-slide-in 0.3s ease-out',
+157 -47
View File
@@ -1,9 +1,6 @@
@import 'tailwindcss';
@theme {
/* ------------------------------------------------------------------
* Typography — offline-safe variable fonts (bundled via fontsource)
* ------------------------------------------------------------------ */
--font-sans:
'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
'Segoe UI', sans-serif;
@@ -11,9 +8,6 @@
'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
Menlo, monospace;
/* ------------------------------------------------------------------
* Radius scale — tighter on inner elements, softer on containers
* ------------------------------------------------------------------ */
--radius: 0.625rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
@@ -21,60 +15,59 @@
--radius-xl: 0.75rem;
/* ------------------------------------------------------------------
* Light theme (default) — warm paper neutrals, cool ink, Proxmox accent
* Light — cream paper, ink black, Proxmox orange ribbon
* ------------------------------------------------------------------ */
--color-background: oklch(0.972 0.004 85);
--color-foreground: oklch(0.24 0.012 250);
--color-card: oklch(0.998 0.002 85);
--color-card-foreground: oklch(0.24 0.012 250);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.24 0.012 250);
--color-background: oklch(0.985 0.006 85);
--color-foreground: oklch(0.18 0.012 250);
--color-card: oklch(0.994 0.003 85);
--color-card-foreground: oklch(0.18 0.012 250);
--color-popover: oklch(0.998 0.002 85);
--color-popover-foreground: oklch(0.18 0.012 250);
--color-primary: oklch(0.62 0.15 55);
--color-primary-foreground: oklch(0.985 0.008 60);
--color-secondary: oklch(0.95 0.005 85);
--color-secondary-foreground: oklch(0.3 0.012 250);
--color-muted: oklch(0.95 0.005 85);
--color-muted-foreground: oklch(0.51 0.014 250);
--color-accent: oklch(0.935 0.008 85);
--color-accent-foreground: oklch(0.28 0.012 250);
--color-secondary: oklch(0.94 0.01 85);
--color-secondary-foreground: oklch(0.22 0.012 250);
--color-muted: oklch(0.94 0.01 85);
--color-muted-foreground: oklch(0.56 0.012 250);
--color-accent: oklch(0.93 0.012 85);
--color-accent-foreground: oklch(0.22 0.012 250);
--color-destructive: oklch(0.56 0.19 25);
--color-destructive-foreground: oklch(0.98 0.008 25);
--color-border: oklch(0.905 0.006 85);
--color-input: oklch(0.885 0.007 85);
--color-border: oklch(0.88 0.012 85);
--color-input: oklch(0.86 0.014 85);
--color-ring: oklch(0.62 0.15 55);
/* Semantic status colors — data semantics, not UI accents */
--color-success: oklch(0.55 0.13 155);
--color-warning: oklch(0.62 0.13 80);
--color-info: oklch(0.55 0.11 245);
/* Tinted card shadow — carries the warm hue of the surface */
--shadow-card:
0 1px 2px 0 oklch(0.3 0.02 60 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 60 / 0.05);
0 1px 0 0 oklch(0.18 0.012 250 / 0.06) inset,
0 1px 3px 0 oklch(0.18 0.012 250 / 0.07), 0 4px 12px -4px oklch(0.18 0.012 250 / 0.06);
}
.dark {
/* ------------------------------------------------------------------
* Dark theme — blue-black ink surfaces, cool neutrals, muted orange
* Dark — dark ink-paper, light ink, same orange ribbon
* ------------------------------------------------------------------ */
--color-background: oklch(0.16 0.011 250);
--color-foreground: oklch(0.965 0.004 250);
--color-card: oklch(0.19 0.012 250);
--color-card-foreground: oklch(0.965 0.004 250);
--color-popover: oklch(0.215 0.013 250);
--color-popover-foreground: oklch(0.965 0.004 250);
--color-background: oklch(0.145 0.012 250);
--color-foreground: oklch(0.96 0.005 85);
--color-card: oklch(0.182 0.014 250);
--color-card-foreground: oklch(0.96 0.005 85);
--color-popover: oklch(0.2 0.015 250);
--color-popover-foreground: oklch(0.96 0.005 85);
--color-primary: oklch(0.72 0.15 60);
--color-primary-foreground: oklch(0.18 0.02 50);
--color-secondary: oklch(0.235 0.011 250);
--color-secondary-foreground: oklch(0.94 0.004 250);
--color-muted: oklch(0.235 0.011 250);
--color-muted-foreground: oklch(0.655 0.012 250);
--color-accent: oklch(0.275 0.012 250);
--color-accent-foreground: oklch(0.965 0.004 250);
--color-secondary: oklch(0.22 0.014 250);
--color-secondary-foreground: oklch(0.92 0.004 85);
--color-muted: oklch(0.22 0.014 250);
--color-muted-foreground: oklch(0.68 0.012 250);
--color-accent: oklch(0.24 0.014 250);
--color-accent-foreground: oklch(0.96 0.005 85);
--color-destructive: oklch(0.645 0.18 25);
--color-destructive-foreground: oklch(0.97 0.015 25);
--color-border: oklch(0.255 0.012 250);
--color-input: oklch(0.3 0.013 250);
--color-border: oklch(0.26 0.014 250);
--color-input: oklch(0.28 0.015 250);
--color-ring: oklch(0.72 0.15 60);
--color-success: oklch(0.7 0.14 155);
@@ -82,7 +75,118 @@
--color-info: oklch(0.68 0.11 245);
--shadow-card:
0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
0 1px 0 0 oklch(1 0 0 / 0.05) inset, 0 1px 2px 0 oklch(0 0 0 / 0.45),
0 8px 24px -8px oklch(0 0 0 / 0.5);
}
/* ── Dot-matrix texture utilities ───────────────────────────────── */
.dot-grid {
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.09) 1px,
transparent 1px
);
background-size: 14px 14px;
}
.dark .dot-grid {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.07) 1px,
transparent 1px
);
}
.dot-grid-strong {
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.13) 1.1px,
transparent 1.1px
);
background-size: 10px 10px;
}
.dark .dot-grid-strong {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 1.1px,
transparent 1.1px
);
}
.dot-rule {
border: none;
border-top: 1.5px dotted oklch(0.18 0.012 250 / 0.18);
height: 0;
}
.dark .dot-rule {
border-top-color: oklch(1 0 0 / 0.12);
}
.perf-edge {
position: relative;
}
.perf-edge::before,
.perf-edge::after {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
width: 6px;
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.14) 2px,
transparent 2px
);
background-size: 6px 12px;
background-repeat: repeat-y;
pointer-events: none;
}
.perf-edge::before {
left: 4px;
}
.perf-edge::after {
right: 4px;
}
.dark .perf-edge::before,
.dark .perf-edge::after {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 2px,
transparent 2px
);
}
.halftone-fill {
position: relative;
}
.halftone-fill::after {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.55) 0.9px,
transparent 0.9px
);
background-size: 5px 5px;
opacity: 0.9;
pointer-events: none;
border-radius: inherit;
}
.dark .halftone-fill::after {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.35) 0.9px,
transparent 0.9px
);
}
.feed-rule {
border-bottom: 1px dotted oklch(0.18 0.012 250 / 0.12);
}
.dark .feed-rule {
border-bottom-color: oklch(1 0 0 / 0.08);
}
.feed-rule:last-child {
border-bottom: none;
}
@layer base {
@@ -102,12 +206,10 @@
@apply bg-background font-sans text-foreground antialiased;
}
/* Keep native form controls and webview widgets on theme */
::selection {
@apply bg-primary/20 text-foreground;
}
/* Monospace and code contexts always use tabular numerals */
code,
kbd,
samp,
@@ -115,12 +217,10 @@
font-variant-numeric: tabular-nums;
}
/* Consistent focus ring everywhere, including custom elements */
:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Native scrollbars tuned to the surface language */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
@@ -147,20 +247,19 @@
background-clip: padding-box;
}
/* Very subtle fixed grain overlay to break digital flatness */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.02;
opacity: 0.018;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
html.dark body::after {
opacity: 0.04;
opacity: 0.03;
}
}
@@ -174,3 +273,14 @@
opacity: 1;
}
}
@keyframes dot-reveal {
from {
opacity: 0;
filter: blur(1px);
}
to {
opacity: 1;
filter: blur(0);
}
}
+74
View File
@@ -0,0 +1,74 @@
export type Glyph = readonly string[]
export const GLYPH_COLS = 5
export const GLYPH_ROWS = 7
export const GLYPHS: Record<string, Glyph> = {
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
G: [' ### ', '# #', '# ', '# ## ', '# #', '# #', ' ### '],
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
I: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', '#####'],
J: ['#####', ' # ', ' # ', ' # ', ' # ', '# # ', ' ## '],
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
M: ['# #', '## ##', '# # #', '# # #', '# #', '# #', '# #'],
N: ['# #', '## #', '# # #', '# # #', '# ##', '# #', '# #'],
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
Q: [' ### ', '# #', '# #', '# #', '# # #', '# # ', ' ## #'],
R: ['#### ', '# #', '# #', '#### ', '# # ', '# # ', '# #'],
S: [' ### ', '# #', '# ', ' ### ', ' #', '# #', ' ### '],
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
W: ['# #', '# #', '# #', '# # #', '# # #', '## ##', '# #'],
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
'0': [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
'1': [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
'2': [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
'3': ['#### ', ' #', ' #', ' ### ', ' #', ' #', '#### '],
'4': [' # ', ' ## ', ' # # ', '# # ', '#####', ' # ', ' # '],
'5': ['#####', '# ', '# ', '#### ', ' #', '# #', ' ### '],
'6': [' ### ', '# #', '# ', '#### ', '# #', '# #', ' ### '],
'7': ['#####', ' #', ' # ', ' # ', ' # ', ' # ', ' # '],
'8': [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
'9': [' ### ', '# #', '# #', ' ####', ' #', '# #', ' ### '],
'-': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
'.': [' ', ' ', ' ', ' ', ' ', ' ## ', ' ## '],
':': [' ', ' ## ', ' ## ', ' ', ' ## ', ' ## ', ' '],
'/': [' #', ' #', ' # ', ' # ', ' # ', '# ', '# '],
'%': ['## #', '## #', ' # ', ' # ', ' # ', '# ##', '# ##'],
'(': [' ## ', ' # ', '# ', '# ', '# ', ' # ', ' ## '],
')': [' ## ', ' # ', ' #', ' #', ' #', ' # ', ' ## '],
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
_: [' ', ' ', ' ', ' ', ' ', ' ', '#####'],
'@': [' ### ', '# #', '# ## ', '# ## ', '# ## ', '# ', ' ### '],
'#': [' # # ', ' # # ', '#####', ' # # ', '#####', ' # # ', ' # # '],
'!': [' # ', ' # ', ' # ', ' # ', ' # ', ' ', ' # '],
'?': [' ### ', '# #', ' #', ' ## ', ' # ', ' ', ' # '],
'*': [' ', '# #', ' # # ', '#####', ' # # ', '# #', ' '],
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
',': [' ', ' ', ' ', ' ', ' ## ', ' ## ', ' # '],
"'": [' # ', ' # ', ' # ', ' ', ' ', ' ', ' '],
'"': [' # # ', ' # # ', ' ', ' ', ' ', ' ', ' '],
'[': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
']': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
'<': [' # ', ' # ', ' # ', '# ', ' # ', ' # ', ' # '],
'>': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
'|': [' # ', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
'&': [' ## ', '# # ', '# # ', ' ## ', '# # ', '# # ', ' ## #'],
$: [' ### ', '# # ', '# ', ' ### ', ' #', ' # #', ' ### '],
}
export function getGlyph(char: string): Glyph | null {
const upper = char.toUpperCase()
return GLYPHS[upper] ?? null
}