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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user