Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daa78a036a | |||
| 3deb750441 | |||
| 88554ef952 | |||
| 110592f89e | |||
| 8b8da5584c | |||
| 4260a6582c | |||
| a0f18dd237 | |||
| 00edc32aeb | |||
| cd6a788f77 | |||
| 9cf6a48b04 | |||
| 2c94616afa | |||
| c7be851c34 | |||
| fddfd0a769 | |||
| 074b49358b | |||
| a47b7649f0 | |||
| 7e08a85f73 | |||
| c7c5183356 | |||
| 7608d07255 | |||
| 1bc6798d76 | |||
| f6de7d1770 | |||
| 9dddd00858 | |||
| a5bf9c9db6 |
@@ -41,7 +41,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await run_scan(ranges, db, run_id)
|
try:
|
||||||
|
await run_scan(ranges, db, run_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scan run %s failed unexpectedly", run_id)
|
||||||
|
await db.rollback()
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run and run.status == "running":
|
||||||
|
run.status = "failed"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger", response_model=ScanRunResponse)
|
@router.post("/trigger", response_model=ScanRunResponse)
|
||||||
@@ -89,12 +97,10 @@ async def clear_pending(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
from sqlalchemy import delete as sa_delete
|
||||||
devices = result.scalars().all()
|
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
for device in devices:
|
|
||||||
await db.delete(device)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"deleted": len(devices)}
|
return {"deleted": result.rowcount}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
@@ -116,7 +122,7 @@ async def bulk_approve_devices(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
devices = result.scalars().all()
|
devices = result.scalars().all()
|
||||||
node_ids: list[str] = []
|
created_nodes: list[Node] = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node = Node(
|
node = Node(
|
||||||
@@ -128,9 +134,11 @@ async def bulk_approve_devices(
|
|||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
node_ids.append(node.id)
|
created_nodes.append(node)
|
||||||
await db.commit()
|
await db.flush() # populates node.id from Python-side default before reading
|
||||||
|
node_ids = [n.id for n in created_nodes]
|
||||||
approved_device_ids = [d.id for d in devices]
|
approved_device_ids = [d.id for d in devices]
|
||||||
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"approved": len(node_ids),
|
"approved": len(node_ids),
|
||||||
"node_ids": node_ids,
|
"node_ids": node_ids,
|
||||||
@@ -166,13 +174,24 @@ async def approve_device(
|
|||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if device:
|
if not device:
|
||||||
device.status = "approved"
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
node = Node(**node_data.model_dump())
|
if device.status != "pending":
|
||||||
db.add(node)
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
await db.commit()
|
device.status = "approved"
|
||||||
return {"approved": True, "node_id": node.id}
|
node = Node(
|
||||||
return {"approved": False}
|
label=node_data.label,
|
||||||
|
type=node_data.type,
|
||||||
|
ip=node_data.ip,
|
||||||
|
hostname=node_data.hostname,
|
||||||
|
status=node_data.status,
|
||||||
|
services=node_data.services or [],
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
await db.flush()
|
||||||
|
node_id = node.id
|
||||||
|
await db.commit()
|
||||||
|
return {"approved": True, "node_id": node_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/hide")
|
@router.post("/pending/{device_id}/hide")
|
||||||
@@ -212,10 +231,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
|||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
|
previous = settings.scanner_ranges
|
||||||
|
settings.scanner_ranges = payload.ranges
|
||||||
try:
|
try:
|
||||||
settings.scanner_ranges = payload.ranges
|
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
settings.scanner_ranges = previous
|
||||||
logger.error("Failed to save scan config: %s", exc)
|
logger.error("Failed to save scan config: %s", exc)
|
||||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||||
|
|||||||
@@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
|
|||||||
assert node["notes"] == "My NAS device"
|
assert node["notes"] == "My NAS device"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
||||||
|
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
||||||
|
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["services"] == services
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|||||||
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
|||||||
json=node_payload,
|
json=node_payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 404
|
||||||
assert res.json()["approved"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Hide device ---
|
# --- Hide device ---
|
||||||
@@ -478,6 +477,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
|||||||
data = res.json()
|
data = res.json()
|
||||||
assert data["approved"] == 2
|
assert data["approved"] == 2
|
||||||
assert len(data["node_ids"]) == 2
|
assert len(data["node_ids"]) == 2
|
||||||
|
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||||
assert len(data["device_ids"]) == 2
|
assert len(data["device_ids"]) == 2
|
||||||
assert data["skipped"] == 0
|
assert data["skipped"] == 0
|
||||||
# Pending list should now be empty
|
# Pending list should now be empty
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0",
|
"version": "1.10.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+11
-8
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
|
|||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
@@ -53,6 +53,7 @@ export default function App() {
|
|||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -305,15 +306,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(() => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
try {
|
setExportModalOpen(true)
|
||||||
await exportToPng(el)
|
|
||||||
toast.success('Exported as PNG')
|
|
||||||
} catch {
|
|
||||||
toast.error('Export failed')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
@@ -422,6 +418,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeModal
|
<NodeModal
|
||||||
|
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
||||||
open={addNodeOpen}
|
open={addNodeOpen}
|
||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
@@ -531,6 +528,12 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<ExportModal
|
||||||
|
open={exportModalOpen}
|
||||||
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
@@ -41,6 +44,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const glow = colors.border
|
const glow = colors.border
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
||||||
|
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -80,7 +84,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layers size={12} />
|
{createElement(resolvedIcon, { size: 12 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -106,6 +110,27 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
|
||||||
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={prop.key}
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden px-2.5 shrink-0"
|
||||||
|
style={{
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
paddingTop: i === 0 ? 4 : 2,
|
||||||
|
paddingBottom: i === arr.length - 1 ? 4 : 2,
|
||||||
|
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
|
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Inner area — React Flow places children here */}
|
{/* Inner area — React Flow places children here */}
|
||||||
<div className="flex-1 relative" />
|
<div className="flex-1 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Download, Loader2 } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
getElement: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
|
||||||
|
const [quality, setQuality] = useState<ExportQuality>('high')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const el = getElement()
|
||||||
|
if (!el) return
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await exportToPng(el, quality)
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
{EXPORT_QUALITY_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuality(opt.value)}
|
||||||
|
className={[
|
||||||
|
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
|
||||||
|
quality === opt.value
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
|
||||||
|
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-xs opacity-70">{opt.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={exporting}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
>
|
||||||
|
{exporting
|
||||||
|
? <><Loader2 size={14} className="animate-spin mr-1.5" />Exporting…</>
|
||||||
|
: <><Download size={14} className="mr-1.5" />Download</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
@@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{colorFields.map(({ key, label }) => (
|
{colorFields.map(({ key, label }) => {
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
<label
|
return (
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
style={{ borderColor: '#30363d' }}
|
<label
|
||||||
>
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
|
style={{ borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex6}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="range"
|
||||||
value={form[key]}
|
min={0}
|
||||||
onChange={(e) => set(key, e.target.value)}
|
max={100}
|
||||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
</label>
|
</div>
|
||||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ interface NodeModalProps {
|
|||||||
|
|
||||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
|
||||||
// initial value is enough — no need for a reset effect.
|
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ExportModal } from '../ExportModal'
|
||||||
|
|
||||||
|
const mockExportToPng = vi.fn()
|
||||||
|
vi.mock('@/utils/export', () => ({
|
||||||
|
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
|
||||||
|
EXPORT_QUALITY_OPTIONS: [
|
||||||
|
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
|
||||||
|
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
|
||||||
|
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const el = document.createElement('div')
|
||||||
|
const getElement = () => el
|
||||||
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
describe('ExportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockExportToPng.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all three quality options', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.getByText('Standard')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('High')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Ultra')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects High by default', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
const highBtn = screen.getByText('High').closest('button')!
|
||||||
|
expect(highBtn.className).toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes selection when another option is clicked', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('Ultra').closest('button')!)
|
||||||
|
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
|
||||||
|
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls exportToPng with selected quality on Download click', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByText('Standard').closest('button')!)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes after successful export', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={getElement} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call exportToPng when getElement returns null', async () => {
|
||||||
|
render(<ExportModal open onClose={onClose} getElement={() => null} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download/i }))
|
||||||
|
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
|
||||||
|
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
|
|||||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
expect(submitted.border_style).toBe('solid')
|
expect(submitted.border_style).toBe('solid')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows opacity sliders for all three color fields', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
expect(sliders).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('default background_color is 8-digit hex with low alpha', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.background_color).toBe('#00d4ff0d')
|
||||||
|
expect(submitted.background_color.length).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moving background opacity slider updates background_color alpha', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
// background slider is the third one (Text, Border, Background)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
fireEvent.change(sliders[2], { target: { value: '50' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
// alpha 50% → 0x80 = 128
|
||||||
|
expect(submitted.background_color).toBe('#00d4ff80')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moving border opacity slider to 0 makes border fully transparent', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
fireEvent.change(sliders[1], { target: { value: '0' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_color).toBe('#00d4ff00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills opacity from 8-digit initial background_color', () => {
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ background_color: '#ff6e0080' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const sliders = screen.getAllByRole('slider')
|
||||||
|
expect((sliders[2] as HTMLInputElement).value).toBe('50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows opacity percentage in label', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
// Background default is 5% opacity
|
||||||
|
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -130,6 +130,21 @@ describe('NodeModal', () => {
|
|||||||
expect(data.notes).toBe('rack A')
|
expect(data.notes).toBe('rack A')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets form values when reopened in Add mode', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
|
||||||
|
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
|
||||||
|
|
||||||
|
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
|
||||||
|
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
it('submits check_target', () => {
|
it('submits check_target', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ interface DetailPanelProps {
|
|||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
|
||||||
|
|
||||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||||
@@ -94,10 +94,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddService = () => {
|
const handleAddService = () => {
|
||||||
const port = parseInt(newSvc.port, 10)
|
const trimmedPort = newSvc.port.trim()
|
||||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||||
|
if (!newSvc.service_name.trim()) return
|
||||||
|
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
const path = newSvc.path.trim()
|
||||||
|
const svc: ServiceInfo = {
|
||||||
|
...(port != null ? { port } : {}),
|
||||||
|
protocol: newSvc.protocol,
|
||||||
|
service_name: newSvc.service_name.trim(),
|
||||||
|
...(path ? { path } : {}),
|
||||||
|
}
|
||||||
updateNode(node.id, { services: [...services, svc] })
|
updateNode(node.id, { services: [...services, svc] })
|
||||||
setNewSvc(EMPTY_FORM)
|
setNewSvc(EMPTY_FORM)
|
||||||
setAddingForNode(null)
|
setAddingForNode(null)
|
||||||
@@ -113,18 +121,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const handleStartEdit = (index: number) => {
|
const handleStartEdit = (index: number) => {
|
||||||
const svc = services[index]
|
const svc = services[index]
|
||||||
if (!svc) return
|
if (!svc) return
|
||||||
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
|
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
|
||||||
setEditingFor({ nodeId: node.id, index })
|
setEditingFor({ nodeId: node.id, index })
|
||||||
setAddingForNode(null)
|
setAddingForNode(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = () => {
|
||||||
if (editingIndex === null) return
|
if (editingIndex === null) return
|
||||||
const port = parseInt(editSvc.port, 10)
|
const trimmedPort = editSvc.port.trim()
|
||||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||||
|
if (!editSvc.service_name.trim()) return
|
||||||
|
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
|
const path = editSvc.path.trim()
|
||||||
const updated = services.map((svc, i) =>
|
const updated = services.map((svc, i) =>
|
||||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
i === editingIndex
|
||||||
|
? {
|
||||||
|
...svc,
|
||||||
|
protocol: editSvc.protocol,
|
||||||
|
service_name: editSvc.service_name.trim(),
|
||||||
|
...(port != null ? { port } : { port: undefined }),
|
||||||
|
...(path ? { path } : { path: undefined }),
|
||||||
|
}
|
||||||
|
: svc
|
||||||
)
|
)
|
||||||
updateNode(node.id, { services: updated })
|
updateNode(node.id, { services: updated })
|
||||||
setEditingFor(null)
|
setEditingFor(null)
|
||||||
@@ -280,7 +299,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,23 +499,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const setPort = (value: string) => {
|
||||||
|
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
|
||||||
|
onChange({ ...form, port: digitsOnly })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampPort = (value: string) => {
|
||||||
|
if (!value) return ''
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
if (!Number.isFinite(parsed)) return ''
|
||||||
|
return String(Math.max(1, Math.min(65535, parsed)))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||||
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
|
||||||
|
placeholder="Port"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||||
|
/>
|
||||||
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||||||
<option value="tcp">tcp</option>
|
<option value="tcp">tcp</option>
|
||||||
<option value="udp">udp</option>
|
<option value="udp">udp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||||
@@ -619,14 +661,17 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
|
const portLabel = svc.port != null ? String(svc.port) : 'host'
|
||||||
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
|
||||||
|
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
||||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
@@ -43,6 +44,7 @@ interface SidebarProps {
|
|||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [_collapsed, setCollapsed] = useState(false)
|
const [_collapsed, setCollapsed] = useState(false)
|
||||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
|
||||||
// When forceView is set, override local state without useEffect
|
// When forceView is set, override local state without useEffect
|
||||||
const collapsed = forceView ? false : _collapsed
|
const collapsed = forceView ? false : _collapsed
|
||||||
@@ -152,6 +154,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={LogOut}
|
||||||
|
label="Logout"
|
||||||
|
collapsed={collapsed}
|
||||||
|
onClick={logout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <VersionBadge />}
|
{!collapsed && <VersionBadge />}
|
||||||
@@ -359,7 +369,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||||
)}
|
)}
|
||||||
{devices.map((d) => {
|
{devices.map((d) => {
|
||||||
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
|
||||||
const titleService = namedService
|
const titleService = namedService
|
||||||
?? d.services.find((s) => s.port === 80)
|
?? d.services.find((s) => s.port === 80)
|
||||||
?? d.services.find((s) => s.port === 443)
|
?? d.services.find((s) => s.port === 443)
|
||||||
@@ -385,8 +395,8 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checkedIds.has(d.id)}
|
checked={checkedIds.has(d.id)}
|
||||||
onClick={(e) => toggleCheck(d.id, e)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={() => {}}
|
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-foreground truncate font-medium">{title}</span>
|
<span className="text-foreground truncate font-medium">{title}</span>
|
||||||
@@ -627,7 +637,7 @@ function SettingsPanel() {
|
|||||||
min={10}
|
min={10}
|
||||||
max={3600}
|
max={3600}
|
||||||
value={interval}
|
value={interval}
|
||||||
onChange={(e) => setIntervalValue(Number(e.target.value))}
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">seconds</span>
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
@@ -664,7 +674,7 @@ function VersionBadge() {
|
|||||||
</a>
|
</a>
|
||||||
{hasUpdate && latest && (
|
{hasUpdate && latest && (
|
||||||
<a
|
<a
|
||||||
href={latest.url}
|
href={latest.url.startsWith('https://') ? latest.url : '#'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
import type { ServiceInfo } from '@/types'
|
import type { ServiceInfo } from '@/types'
|
||||||
|
|
||||||
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
|
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
|
||||||
port,
|
...(port != null ? { port } : {}),
|
||||||
protocol,
|
protocol,
|
||||||
service_name,
|
service_name,
|
||||||
|
...(path ? { path } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getServiceUrl', () => {
|
describe('getServiceUrl', () => {
|
||||||
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
|
|||||||
it('uses host string directly (works with both IP and hostname)', () => {
|
it('uses host string directly (works with both IP and hostname)', () => {
|
||||||
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the node port when the host already includes one', () => {
|
||||||
|
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the service port override the node port', () => {
|
||||||
|
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends a normalized path to the final URL', () => {
|
||||||
|
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports path-only services inheriting the node port', () => {
|
||||||
|
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
|
|||||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||||
expect(updateNode).toHaveBeenCalledOnce()
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows adding a service without a port', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
|
||||||
|
selectedNodeId: 'n1',
|
||||||
|
selectedNodeIds: [],
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const addHeaders = screen.getAllByText('Add')
|
||||||
|
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||||
|
|
||||||
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
|
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
|
||||||
|
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls updateNode without the removed service when X is clicked', () => {
|
it('calls updateNode without the removed service when X is clicked', () => {
|
||||||
@@ -332,7 +358,7 @@ describe('DetailPanel', () => {
|
|||||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||||
|
|
||||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||||
setupStore({ services: [svc] })
|
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||||
const editBtn = screen.getByTitle('Edit service')
|
const editBtn = screen.getByTitle('Edit service')
|
||||||
@@ -341,6 +367,8 @@ describe('DetailPanel', () => {
|
|||||||
expect(nameInput.value).toBe('nginx')
|
expect(nameInput.value).toBe('nginx')
|
||||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||||
expect(portInput.value).toBe('80')
|
expect(portInput.value).toBe('80')
|
||||||
|
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
|
||||||
|
expect(pathInput.value).toBe('/admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls updateNode with updated values on Save', () => {
|
it('calls updateNode with updated values on Save', () => {
|
||||||
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
|
|||||||
|
|
||||||
const nameInput = screen.getByPlaceholderText('Service name')
|
const nameInput = screen.getByPlaceholderText('Service name')
|
||||||
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
expect(updateNode).toHaveBeenCalledOnce()
|
expect(updateNode).toHaveBeenCalledOnce()
|
||||||
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
||||||
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
|
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
|
||||||
|
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cancels edit without updating', () => {
|
it('cancels edit without updating', () => {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { Sidebar } from '../Sidebar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
const mockBulkApprove = vi.fn()
|
const mockBulkApprove = vi.fn()
|
||||||
const mockBulkHide = vi.fn()
|
const mockBulkHide = vi.fn()
|
||||||
@@ -60,6 +62,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mockToggleHideIp = vi.fn()
|
const mockToggleHideIp = vi.fn()
|
||||||
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
@@ -73,6 +76,12 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
|||||||
} as ReturnType<typeof useCanvasStore>)
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockAuth() {
|
||||||
|
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
|
||||||
|
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
@@ -86,6 +95,7 @@ const defaultProps = {
|
|||||||
describe('Sidebar', () => {
|
describe('Sidebar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStore()
|
mockStore()
|
||||||
|
mockAuth()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -267,6 +277,19 @@ describe('Sidebar', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows Logout button in normal mode', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Logout')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls logout when Logout is clicked', () => {
|
||||||
|
render(<Sidebar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Logout'))
|
||||||
|
expect(mockLogout).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
||||||
@@ -298,6 +321,7 @@ const DEVICE_B = {
|
|||||||
describe('PendingDevicesPanel — bulk select', () => {
|
describe('PendingDevicesPanel — bulk select', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStore()
|
mockStore()
|
||||||
|
mockAuth()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockBulkApprove.mockResolvedValue({
|
mockBulkApprove.mockResolvedValue({
|
||||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
|||||||
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
|
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
|
||||||
|
|
||||||
export interface ServiceInfo {
|
export interface ServiceInfo {
|
||||||
port: number
|
port?: number
|
||||||
protocol: 'tcp' | 'udp'
|
protocol: 'tcp' | 'udp'
|
||||||
service_name: string
|
service_name: string
|
||||||
|
path?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
category?: string
|
category?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
|
||||||
|
|
||||||
|
describe('hexToRgba', () => {
|
||||||
|
it('splits 8-digit hex into hex6 and alpha', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('#00d4ff0d')
|
||||||
|
expect(hex6).toBe('#00d4ff')
|
||||||
|
expect(alpha).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles fully opaque 8-digit hex (ff)', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('#00d4ffff')
|
||||||
|
expect(hex6).toBe('#00d4ff')
|
||||||
|
expect(alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles fully transparent 8-digit hex (00)', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('#00d4ff00')
|
||||||
|
expect(hex6).toBe('#00d4ff')
|
||||||
|
expect(alpha).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults alpha to 100 for 6-digit hex', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('#00d4ff')
|
||||||
|
expect(hex6).toBe('#00d4ff')
|
||||||
|
expect(alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles 6-digit hex without leading #', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('ff6e00')
|
||||||
|
expect(hex6).toBe('#ff6e00')
|
||||||
|
expect(alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles 8-digit hex without leading #', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('ff6e0080')
|
||||||
|
expect(hex6).toBe('#ff6e00')
|
||||||
|
expect(alpha).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for invalid input', () => {
|
||||||
|
const { hex6, alpha } = hexToRgba('invalid')
|
||||||
|
expect(hex6).toBe('#000000')
|
||||||
|
expect(alpha).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
const { hex6 } = hexToRgba('#00D4FF0D')
|
||||||
|
expect(hex6).toBe('#00D4FF')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rgbaToHex8', () => {
|
||||||
|
it('combines hex6 and alpha into 8-digit hex', () => {
|
||||||
|
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces ff for alpha 100', () => {
|
||||||
|
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces 00 for alpha 0', () => {
|
||||||
|
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces 80 for alpha 50', () => {
|
||||||
|
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps alpha below 0 to 0', () => {
|
||||||
|
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps alpha above 100 to 100', () => {
|
||||||
|
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pads single-digit alpha hex with leading zero', () => {
|
||||||
|
const result = rgbaToHex8('#000000', 1)
|
||||||
|
const alphaPart = result.slice(7)
|
||||||
|
expect(alphaPart.length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round-trip', () => {
|
||||||
|
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
|
||||||
|
const original = '#00d4ff0d'
|
||||||
|
const { hex6, alpha } = hexToRgba(original)
|
||||||
|
expect(rgbaToHex8(hex6, alpha)).toBe(original)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips fully opaque color', () => {
|
||||||
|
const original = '#a855f7ff'
|
||||||
|
const { hex6, alpha } = hexToRgba(original)
|
||||||
|
expect(rgbaToHex8(hex6, alpha)).toBe(original)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export'
|
||||||
|
|
||||||
|
const mockToPng = vi.fn()
|
||||||
|
vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) }))
|
||||||
|
|
||||||
|
describe('exportToPng', () => {
|
||||||
|
let el: HTMLElement
|
||||||
|
let clickSpy: ReturnType<typeof vi.fn>
|
||||||
|
let appendSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
let createSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
el = document.createElement('div')
|
||||||
|
clickSpy = vi.fn()
|
||||||
|
createSpy = vi.spyOn(document, 'createElement').mockReturnValue(
|
||||||
|
Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement
|
||||||
|
)
|
||||||
|
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n)
|
||||||
|
mockToPng.mockResolvedValue('data:image/png;base64,abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createSpy.mockRestore()
|
||||||
|
appendSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toPng with pixelRatio 1 for standard quality', async () => {
|
||||||
|
await exportToPng(el, 'standard')
|
||||||
|
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toPng with pixelRatio 2 for high quality', async () => {
|
||||||
|
await exportToPng(el, 'high')
|
||||||
|
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls toPng with pixelRatio 4 for ultra quality', async () => {
|
||||||
|
await exportToPng(el, 'ultra')
|
||||||
|
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to high quality when no quality arg given', async () => {
|
||||||
|
await exportToPng(el)
|
||||||
|
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers a download with the correct filename', async () => {
|
||||||
|
await exportToPng(el, 'high')
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes dark background color', async () => {
|
||||||
|
await exportToPng(el, 'standard')
|
||||||
|
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EXPORT_QUALITY_OPTIONS', () => {
|
||||||
|
it('has exactly three options', () => {
|
||||||
|
expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('options are standard, high, ultra in order', () => {
|
||||||
|
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pixel ratios are 1, 2, 4', () => {
|
||||||
|
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
|
import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react'
|
||||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
|
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
|
||||||
|
|
||||||
describe('PROPERTY_ICONS', () => {
|
describe('PROPERTY_ICONS', () => {
|
||||||
it('contains the hardware migration icons', () => {
|
it('contains the hardware migration icons', () => {
|
||||||
|
expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard)
|
||||||
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
|
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
|
||||||
|
expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort)
|
||||||
|
expect(PROPERTY_ICONS['Gpu']).toBe(Gpu)
|
||||||
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
|
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
|
||||||
|
expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort)
|
||||||
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
|
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
|
||||||
|
expect(PROPERTY_ICONS['Usb']).toBe(Usb)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has at least 10 icons', () => {
|
it('has at least 10 icons', () => {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100).
|
||||||
|
* 6-digit input returns alpha 100.
|
||||||
|
* Invalid input returns { hex6: '#000000', alpha: 100 }.
|
||||||
|
*/
|
||||||
|
export function hexToRgba(hex: string): { hex6: string; alpha: number } {
|
||||||
|
const clean = hex.replace('#', '')
|
||||||
|
if (clean.length === 8) {
|
||||||
|
const alphaByte = parseInt(clean.slice(6, 8), 16)
|
||||||
|
return {
|
||||||
|
hex6: `#${clean.slice(0, 6)}`,
|
||||||
|
alpha: Math.round((alphaByte / 255) * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clean.length === 6) {
|
||||||
|
return { hex6: `#${clean}`, alpha: 100 }
|
||||||
|
}
|
||||||
|
return { hex6: '#000000', alpha: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine a 6-digit hex color and an alpha (0–100) into an 8-digit hex.
|
||||||
|
*/
|
||||||
|
export function rgbaToHex8(hex6: string, alpha: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(100, alpha))
|
||||||
|
const alphaByte = Math.round((clamped / 100) * 255)
|
||||||
|
const alphaHex = alphaByte.toString(16).padStart(2, '0')
|
||||||
|
return `${hex6}${alphaHex}`
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { toPng } from 'html-to-image'
|
import { toPng } from 'html-to-image'
|
||||||
|
|
||||||
/**
|
export type ExportQuality = 'standard' | 'high' | 'ultra'
|
||||||
* Export the React Flow canvas as a PNG and trigger a browser download.
|
|
||||||
* Pass the `.react-flow` wrapper element.
|
export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [
|
||||||
*/
|
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
|
||||||
export async function exportToPng(element: HTMLElement): Promise<void> {
|
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
|
||||||
|
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise<void> {
|
||||||
|
const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1]
|
||||||
const dataUrl = await toPng(element, {
|
const dataUrl = await toPng(element, {
|
||||||
backgroundColor: '#0d1117',
|
backgroundColor: '#0d1117',
|
||||||
|
pixelRatio: option.pixelRatio,
|
||||||
style: {
|
style: {
|
||||||
// Exclude controls from the export
|
|
||||||
'--xy-controls-display': 'none',
|
'--xy-controls-display': 'none',
|
||||||
} as Partial<CSSStyleDeclaration>,
|
} as Partial<CSSStyleDeclaration>,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
|||||||
.map((n) => {
|
.map((n) => {
|
||||||
const d = n.data
|
const d = n.data
|
||||||
const services = d.services?.length
|
const services = d.services?.length
|
||||||
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
? d.services.map((s) => {
|
||||||
|
const port = s.port != null ? `:${s.port}` : ''
|
||||||
|
const path = s.path?.trim() ? s.path.trim() : ''
|
||||||
|
return `${s.service_name}${port}${path}`
|
||||||
|
}).join(', ')
|
||||||
: EMPTY
|
: EMPTY
|
||||||
return [
|
return [
|
||||||
cell(d.label),
|
cell(d.label),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Battery,
|
Battery,
|
||||||
Box,
|
Box,
|
||||||
|
CircuitBoard,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
Database,
|
Database,
|
||||||
|
EthernetPort,
|
||||||
Globe,
|
Globe,
|
||||||
|
Gpu,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
HdmiPort,
|
||||||
Hash,
|
Hash,
|
||||||
Key,
|
Key,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -17,6 +21,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Tag,
|
Tag,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
|
Usb,
|
||||||
Wifi,
|
Wifi,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -25,11 +30,15 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
||||||
Battery,
|
Battery,
|
||||||
Box,
|
Box,
|
||||||
|
CircuitBoard,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
Database,
|
Database,
|
||||||
|
EthernetPort,
|
||||||
Globe,
|
Globe,
|
||||||
|
Gpu,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
HdmiPort,
|
||||||
Hash,
|
Hash,
|
||||||
Key,
|
Key,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -41,6 +50,7 @@ export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
|||||||
Shield,
|
Shield,
|
||||||
Tag,
|
Tag,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
|
Usb,
|
||||||
Wifi,
|
Wifi,
|
||||||
Zap,
|
Zap,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([
|
|||||||
27017, 27018, // MongoDB
|
27017, 27018, // MongoDB
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function splitFirstHost(host: string): string {
|
||||||
|
return host.split(',')[0]?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(port: string): number | undefined {
|
||||||
|
if (!/^\d+$/.test(port)) return undefined
|
||||||
|
const parsed = Number.parseInt(port, 10)
|
||||||
|
return parsed >= 1 && parsed <= 65535 ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null {
|
||||||
|
const firstHost = splitFirstHost(host)
|
||||||
|
if (!firstHost) return null
|
||||||
|
|
||||||
|
if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) {
|
||||||
|
const url = new URL(firstHost)
|
||||||
|
return {
|
||||||
|
protocol: url.protocol === 'https:' ? 'https' : 'http',
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: parsePort(url.port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstHost.startsWith('[')) {
|
||||||
|
const bracketIndex = firstHost.indexOf(']')
|
||||||
|
if (bracketIndex === -1) return { hostname: firstHost }
|
||||||
|
const hostname = firstHost.slice(1, bracketIndex)
|
||||||
|
const remainder = firstHost.slice(bracketIndex + 1)
|
||||||
|
return {
|
||||||
|
hostname,
|
||||||
|
port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonCount = (firstHost.match(/:/g) ?? []).length
|
||||||
|
if (colonCount === 1) {
|
||||||
|
const [hostname, rawPort] = firstHost.split(':')
|
||||||
|
const parsedPort = parsePort(rawPort)
|
||||||
|
if (hostname && parsedPort != null) {
|
||||||
|
return { hostname, port: parsedPort }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hostname: firstHost }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path?: string): string {
|
||||||
|
const trimmed = path?.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed === '/') return '/'
|
||||||
|
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHostname(hostname: string): string {
|
||||||
|
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
|
||||||
|
}
|
||||||
|
|
||||||
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
|
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
|
||||||
if (!host) return null
|
if (!host) return null
|
||||||
if (svc.port === 22) return null // SSH — no browser
|
|
||||||
if (svc.protocol === 'udp') return null // UDP — not HTTP
|
if (svc.protocol === 'udp') return null // UDP — not HTTP
|
||||||
if (NON_HTTP_PORTS.has(svc.port)) return null
|
|
||||||
|
const parts = parseHostParts(host)
|
||||||
|
if (!parts?.hostname) return null
|
||||||
|
|
||||||
|
const effectivePort = svc.port ?? parts.port
|
||||||
|
if (effectivePort === 22) return null // SSH — no browser
|
||||||
|
if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null
|
||||||
|
|
||||||
const name = svc.service_name.toLowerCase()
|
const name = svc.service_name.toLowerCase()
|
||||||
const isHttps =
|
const protocol = parts.protocol ?? (
|
||||||
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
|
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
|
||||||
svc.port === 443 || svc.port === 8443
|
effectivePort === 443 || effectivePort === 8443
|
||||||
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
|
? 'https'
|
||||||
|
: 'http'
|
||||||
|
)
|
||||||
|
const base = `${protocol}://${formatHostname(parts.hostname)}`
|
||||||
|
const port = effectivePort != null ? `:${effectivePort}` : ''
|
||||||
|
return `${base}${port}${normalizePath(svc.path)}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user