feat: add frontend source (fix submodule), compact node design

- Remove nested .git from Vite scaffold
- Horizontal node layout: icon left, label+IP right, reduced height
This commit is contained in:
Pouzor
2026-03-06 23:31:15 +01:00
parent 4310a5cc2d
commit 9bb34c99cc
35 changed files with 9902 additions and 1 deletions
@@ -0,0 +1,125 @@
import { X, Edit, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS } from '@/types'
interface DetailPanelProps {
onEdit: (id: string) => void
}
export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId)
if (!node) return null
const { data } = node
const statusColor = STATUS_COLORS[data.status]
const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) {
deleteNode(node.id)
}
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} />
</button>
</div>
{/* Status */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
{data.response_time_ms !== undefined && (
<span className="ml-auto font-mono text-xs text-muted-foreground">{data.response_time_ms}ms</span>
)}
</div>
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && (
<DetailRow
label="Last Seen"
value={new Date(data.last_seen).toLocaleString()}
/>
)}
</div>
{/* Services */}
{data.services.length > 0 && (
<div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-2">Services</div>
<div className="flex flex-wrap gap-1.5">
{data.services.map((svc) => (
<Badge
key={`${svc.port}-${svc.protocol}`}
variant="secondary"
className="font-mono text-xs bg-[#21262d] text-[#8b949e] border-[#30363d]"
>
{svc.port}/{svc.protocol} {svc.service_name}
</Badge>
))}
</div>
</div>
)}
{/* Notes */}
{data.notes && (
<div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div>
<p className="text-xs text-foreground/80 whitespace-pre-wrap">{data.notes}</p>
</div>
)}
{/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button
size="sm"
variant="secondary"
className="flex-1 gap-1.5"
onClick={() => onEdit(node.id)}
>
<Edit size={14} /> Edit
</Button>
<Button
size="sm"
variant="destructive"
className="gap-1.5"
onClick={handleDelete}
>
<Trash2 size={14} />
</Button>
</div>
</aside>
)
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
<span
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
title={value}
>
{value}
</span>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
const VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
]
interface SidebarProps {
onAddNode: () => void
onScan: () => void
onSave: () => void
}
export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas')
const { nodes, hasUnsavedChanges } = useCanvasStore()
const onlineCount = nodes.filter((n) => n.data.status === 'online').length
const offlineCount = nodes.filter((n) => n.data.status === 'offline').length
return (
<aside
className="flex flex-col border-r border-border bg-[#161b22] transition-all duration-200 relative shrink-0"
style={{ width: collapsed ? 48 : 220 }}
>
{/* Toggle */}
<button
onClick={() => setCollapsed((c) => !c)}
className="absolute -right-3 top-6 z-10 flex items-center justify-center w-6 h-6 rounded-full border border-border bg-[#21262d] text-muted-foreground hover:text-foreground transition-colors"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
</button>
{/* Logo */}
<div className="flex items-center gap-2 px-3 py-4 border-b border-border">
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[#00d4ff]/10 text-[#00d4ff] shrink-0">
<Network size={16} />
</div>
{!collapsed && (
<span className="font-semibold text-sm tracking-wide text-foreground">Homelable</span>
)}
</div>
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2 flex-1">
{VIEWS.map(({ id, icon: Icon, label }) => (
<SidebarItem
key={id}
icon={Icon}
label={label}
collapsed={collapsed}
active={activeView === id}
onClick={() => setActiveView(id)}
/>
))}
</nav>
{/* Stats */}
{!collapsed && (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Total</span>
<span className="text-foreground font-mono">{nodes.length}</span>
</div>
<div className="flex justify-between">
<span className="text-[#39d353]">Online</span>
<span className="font-mono text-[#39d353]">{onlineCount}</span>
</div>
<div className="flex justify-between">
<span className="text-[#f85149]">Offline</span>
<span className="font-mono text-[#f85149]">{offlineCount}</span>
</div>
</div>
)}
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={onScan} />
<SidebarItem
icon={Save}
label="Save Canvas"
collapsed={collapsed}
onClick={onSave}
badge={hasUnsavedChanges}
accent
/>
</div>
</aside>
)
}
interface SidebarItemProps {
icon: React.ElementType
label: string
collapsed: boolean
active?: boolean
badge?: boolean
accent?: boolean
onClick?: () => void
}
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
const btn = (
<button
onClick={onClick}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${
active
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
: accent
? 'text-[#00d4ff] hover:bg-[#00d4ff]/10'
: 'text-muted-foreground hover:text-foreground hover:bg-[#21262d]'
}`}
>
<Icon size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
{badge && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-[#e3b341]" />
)}
</button>
)
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger>{btn}</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
)
}
return btn
}
@@ -0,0 +1,39 @@
import { Save, LayoutDashboard, Download } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useCanvasStore } from '@/stores/canvasStore'
interface ToolbarProps {
onSave: () => void
onAutoLayout: () => void
onExport: () => void
}
export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) {
const { hasUnsavedChanges } = useCanvasStore()
return (
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
<div className="flex-1" />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
<Download size={14} /> Export
</Button>
<Button
size="sm"
className="gap-1.5 relative"
style={{
background: hasUnsavedChanges ? '#00d4ff' : undefined,
color: hasUnsavedChanges ? '#0d1117' : undefined,
}}
onClick={onSave}
>
{hasUnsavedChanges && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" />
)}
<Save size={14} /> Save
</Button>
</header>
)
}