feat: Phase 2 — auth, node/edge modals, API wiring

- Login page: full-screen dark, grid bg, JWT flow
- Auth store (Zustand) + API client (axios + interceptors)
- NodeModal: add/edit form (type, label, hostname, IP, check method)
- EdgeModal: link type picker + VLAN ID + label on connect
- App: auth gate, canvas load from API (fallback to demo), Ctrl+S save
- CanvasContainer: expose onConnect prop for edge modal flow
This commit is contained in:
Pouzor
2026-03-06 23:33:55 +01:00
parent 9bb34c99cc
commit 150302c3f7
10 changed files with 776 additions and 29 deletions
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { Network, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await authApi.login(username, password)
login(res.data.access_token)
} catch {
setError('Invalid username or password')
} finally {
setLoading(false)
}
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
{/* Grid background */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(#00d4ff 1px, transparent 1px), linear-gradient(90deg, #00d4ff 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative w-full max-w-sm px-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
<Network size={28} className="text-[#00d4ff]" />
</div>
<div className="text-center">
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">config.yml</span>
</p>
</div>
</div>
)
}
@@ -7,6 +7,7 @@ import {
BackgroundVariant,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -14,10 +15,14 @@ import { nodeTypes } from './nodes'
import { edgeTypes } from './edges'
import type { NodeData, EdgeData } from '@/types'
export function CanvasContainer() {
interface CanvasContainerProps {
onConnect?: (connection: Connection) => void
}
export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerProps) {
const {
nodes, edges,
onNodesChange, onEdgesChange, onConnect,
onNodesChange, onEdgesChange,
setSelectedNode,
} = useCanvasStore()
@@ -36,7 +41,7 @@ export function CanvasContainer() {
edges={edges as Edge<EdgeData>[]}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnect={onConnectProp}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EDGE_TYPE_LABELS, type EdgeData, type EdgeType } from '@/types'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
interface EdgeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: EdgeData) => void
}
export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
const [type, setType] = useState<EdgeType>('ethernet')
const [label, setLabel] = useState('')
const [vlanId, setVlanId] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
type,
label: label || undefined,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
})
onClose()
setType('ethernet')
setLabel('')
setVlanId('')
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-xs">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Connect Nodes</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3 mt-2">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Link Type</Label>
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{EDGE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{type === 'vlan' && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">VLAN ID</Label>
<Input
type="number"
min={1}
max={4094}
value={vlanId}
onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 20"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..."
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
Connect
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
const CHECK_METHODS: CheckMethod[] = ['ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
label: '',
hostname: '',
ip: '',
status: 'unknown',
check_method: 'ping',
services: [],
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
}
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
useEffect(() => {
setForm({ ...DEFAULT_DATA, ...initial })
}, [initial, open])
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.label?.trim()) return
onSubmit(form)
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3">
{/* Type */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Label */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label>
<Input
value={form.label ?? ''}
onChange={(e) => set('label', e.target.value)}
placeholder="My Server"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
required
/>
</div>
{/* Hostname */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Hostname</Label>
<Input
value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Check method */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Check target */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label>
<Input
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}