Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9787fdcbb | |||
| 06ec18a137 | |||
| adb4474687 | |||
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 |
@@ -84,6 +84,20 @@ The backend runs as a systemd service, the frontend is served via nginx on port
|
|||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/homelable/scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
|||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
if not token or not decode_token(token):
|
# Accept first so we can send a close frame with a reason code
|
||||||
await websocket.close(code=1008) # Policy Violation
|
|
||||||
return
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
token = payload.get("token", "")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
token = ""
|
||||||
|
if not token or not decode_token(token):
|
||||||
|
await websocket.close(code=1008) # Policy Violation
|
||||||
|
return
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
_connections.append(websocket)
|
_connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class Node(Base):
|
|||||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class NodeSave(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool = False
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class NodeBase(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool = False
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -46,6 +51,11 @@ class NodeUpdate(BaseModel):
|
|||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
cpu_count: int | None = None
|
||||||
|
cpu_model: str | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
show_hardware: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -138,3 +138,56 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
|
|||||||
async def test_save_canvas_requires_auth(client: AsyncClient):
|
async def test_save_canvas_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] == 8
|
||||||
|
assert node["cpu_model"] == "Intel i7-12700K"
|
||||||
|
assert node["ram_gb"] == 32.0
|
||||||
|
assert node["disk_gb"] == 500.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=4, ram_gb=16.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] == 4
|
||||||
|
assert node["ram_gb"] == 16.0
|
||||||
|
assert node["cpu_model"] is None
|
||||||
|
assert node["disk_gb"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
|
||||||
|
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]["show_hardware"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
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]["show_hardware"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["cpu_count"] is None
|
||||||
|
assert node["ram_gb"] is None
|
||||||
|
|||||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_websocket_rejected_without_token():
|
def test_websocket_rejected_without_token():
|
||||||
"""Connection with no token must be closed before being accepted."""
|
"""Connection that sends no token field must be closed with 1008."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({})) # missing token field
|
||||||
|
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_rejected_with_invalid_token():
|
def test_websocket_rejected_with_invalid_token():
|
||||||
"""Connection with a garbage token must be closed."""
|
"""Connection that sends a garbage token must be closed."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejected_with_malformed_json():
|
||||||
|
"""Connection that sends non-JSON as auth must be closed."""
|
||||||
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
|
ws.send_text("not-json")
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_accepted_with_valid_token():
|
def test_websocket_accepted_with_valid_token():
|
||||||
"""Connection with a valid JWT must be accepted and kept open."""
|
"""Connection that sends a valid JWT as first message must be accepted."""
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
# Connection is open — we can send a ping and it should not raise
|
ws.send_text(json.dumps({"token": token}))
|
||||||
|
# Connection is open — subsequent messages should not raise
|
||||||
ws.send_text("ping")
|
ws.send_text("ping")
|
||||||
# Server keeps the connection open (no disconnect expected)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy WebSocket (must be before /api/ to take priority)
|
||||||
|
location /api/v1/status/ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API to backend
|
# Proxy API to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
@@ -11,7 +21,7 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy WebSocket
|
# Proxy legacy /ws/ path
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Generated
+3
-3
@@ -5474,9 +5474,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-2
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
@@ -102,6 +103,11 @@ export default function App() {
|
|||||||
container_mode: n.data.container_mode ?? false,
|
container_mode: n.data.container_mode ?? false,
|
||||||
custom_colors: n.data.custom_colors ?? null,
|
custom_colors: n.data.custom_colors ?? null,
|
||||||
custom_icon: n.data.custom_icon ?? null,
|
custom_icon: n.data.custom_icon ?? null,
|
||||||
|
cpu_count: n.data.cpu_count ?? null,
|
||||||
|
cpu_model: n.data.cpu_model ?? null,
|
||||||
|
ram_gb: n.data.ram_gb ?? null,
|
||||||
|
disk_gb: n.data.disk_gb ?? null,
|
||||||
|
show_hardware: n.data.show_hardware ?? false,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
@@ -238,7 +244,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Children position is relative to parent; place near top-left with padding
|
||||||
@@ -260,7 +266,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
@@ -272,6 +278,7 @@ export default function App() {
|
|||||||
services: [],
|
services: [],
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
@@ -294,6 +301,7 @@ export default function App() {
|
|||||||
custom_colors: {
|
custom_colors: {
|
||||||
...existing?.data.custom_colors,
|
...existing?.data.custom_colors,
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
@@ -524,6 +532,7 @@ export default function App() {
|
|||||||
text_color: rc.text_color ?? '#e6edf3',
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
text_position: rc.text_position ?? 'top-left',
|
text_position: rc.text_position ?? 'top-left',
|
||||||
border_color: rc.border ?? '#00d4ff',
|
border_color: rc.border ?? '#00d4ff',
|
||||||
|
border_style: rc.border_style ?? 'solid',
|
||||||
background_color: rc.background ?? '#00d4ff0d',
|
background_color: rc.background ?? '#00d4ff0d',
|
||||||
z_order: rc.z_order ?? 1,
|
z_order: rc.z_order ?? 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, type LucideIcon } 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 { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
@@ -13,6 +13,11 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
|||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatStorage(gb: number): string {
|
||||||
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||||
|
return `${gb} GB`
|
||||||
|
}
|
||||||
|
|
||||||
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
|
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -47,36 +53,77 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Main row */}
|
||||||
<div
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
{/* Icon */}
|
||||||
style={{
|
<div
|
||||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
background: theme.colors.nodeIconBackground,
|
style={{
|
||||||
}}
|
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||||
>
|
background: theme.colors.nodeIconBackground,
|
||||||
{createElement(resolvedIcon, { size: 15 })}
|
}}
|
||||||
|
>
|
||||||
|
{createElement(resolvedIcon, { size: 15 })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + IP */}
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||||
|
style={{ color: theme.colors.nodeLabelColor }}
|
||||||
|
title={data.label}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
{data.ip && (
|
||||||
|
<div
|
||||||
|
className="font-mono text-[10px] truncate"
|
||||||
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
|
title={data.ip}
|
||||||
|
>
|
||||||
|
{hideIp ? maskIp(data.ip) : data.ip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Hardware section */}
|
||||||
<div className="flex flex-col min-w-0">
|
{showHardware && (
|
||||||
<div
|
<>
|
||||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
style={{ color: theme.colors.nodeLabelColor }}
|
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||||
title={data.label}
|
{/* Line 1: CPU */}
|
||||||
>
|
{(data.cpu_model || data.cpu_count != null) && (
|
||||||
{data.label}
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
</div>
|
<Cpu size={9} className="shrink-0" />
|
||||||
{data.ip && (
|
{data.cpu_model && (
|
||||||
<div
|
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||||
className="font-mono text-[10px] truncate"
|
)}
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
{data.cpu_count != null && (
|
||||||
title={data.ip}
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
>
|
)}
|
||||||
{hideIp ? maskIp(data.ip) : data.ip}
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Line 2: RAM + Disk */}
|
||||||
|
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
|
{data.ram_gb != null && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MemoryStick size={9} className="shrink-0" />
|
||||||
|
{formatStorage(data.ram_gb)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.disk_gb != null && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<HardDrive size={9} className="shrink-0" />
|
||||||
|
{formatStorage(data.disk_gb)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
|
const borderStyle = rc.border_style ?? 'solid'
|
||||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||||
const textColor = rc.text_color ?? '#e6edf3'
|
const textColor = rc.text_color ?? '#e6edf3'
|
||||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||||
@@ -61,7 +62,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
justifyContent: posStyle.justifyContent,
|
justifyContent: posStyle.justifyContent,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: backgroundColor,
|
background: backgroundColor,
|
||||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -22,4 +22,5 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
|||||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||||
|
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const nodeTypes = {
|
|||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
|
docker: DockerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,34 @@ 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'
|
||||||
|
|
||||||
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
export interface GroupRectFormData {
|
export interface GroupRectFormData {
|
||||||
label: string
|
label: string
|
||||||
font: string
|
font: string
|
||||||
text_color: string
|
text_color: string
|
||||||
text_position: TextPosition
|
text_position: TextPosition
|
||||||
border_color: string
|
border_color: string
|
||||||
|
border_style: BorderStyle
|
||||||
background_color: string
|
background_color: string
|
||||||
z_order: number
|
z_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
|
||||||
|
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||||
|
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||||
|
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||||
|
{ value: 'double', label: 'Double', preview: '═══' },
|
||||||
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
|
]
|
||||||
|
|
||||||
const DEFAULT_FORM: GroupRectFormData = {
|
const DEFAULT_FORM: GroupRectFormData = {
|
||||||
label: '',
|
label: '',
|
||||||
font: 'inter',
|
font: 'inter',
|
||||||
text_color: '#e6edf3',
|
text_color: '#e6edf3',
|
||||||
text_position: 'top-left',
|
text_position: 'top-left',
|
||||||
border_color: '#00d4ff',
|
border_color: '#00d4ff',
|
||||||
|
border_style: 'solid',
|
||||||
background_color: '#00d4ff0d',
|
background_color: '#00d4ff0d',
|
||||||
z_order: 1,
|
z_order: 1,
|
||||||
}
|
}
|
||||||
@@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Border style */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||||
|
const isSelected = form.border_style === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => set('border_style', value)}
|
||||||
|
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||||
|
<span className="text-[9px]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Z-order */}
|
{/* Z-order */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
|
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||||
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
|
]
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
|
|
||||||
@@ -44,6 +49,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
const [labelError, setLabelError] = useState(false)
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
||||||
|
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
@@ -76,10 +83,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{NODE_TYPES.map(([value, label]) => (
|
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||||
<SelectItem key={value} value={value} className="text-sm">
|
<>
|
||||||
{label}
|
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||||
</SelectItem>
|
<SelectGroup key={group.label}>
|
||||||
|
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||||
|
{group.label}
|
||||||
|
</SelectLabel>
|
||||||
|
{group.types.map((type) => (
|
||||||
|
<SelectItem key={type} value={type} className="text-sm pl-4">
|
||||||
|
{NODE_TYPE_LABELS[type]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -315,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware specs (hidden for groupRect) */}
|
||||||
|
{form.type !== 'groupRect' && (
|
||||||
|
<div className="flex flex-col gap-2 col-span-2">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHardwareOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Hardware</span>
|
||||||
|
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{hardwareOpen && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!form.show_hardware}
|
||||||
|
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||||
|
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||||
|
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||||
|
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hardwareOpen && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||||
|
<Input
|
||||||
|
value={form.cpu_model ?? ''}
|
||||||
|
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||||
|
placeholder="e.g. Intel Xeon E5-2680"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.cpu_count ?? ''}
|
||||||
|
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||||
|
placeholder="e.g. 8"
|
||||||
|
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">RAM (GB)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
value={form.ram_gb ?? ''}
|
||||||
|
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
placeholder="e.g. 32"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={form.disk_gb ?? ''}
|
||||||
|
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
placeholder="e.g. 500"
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -80,4 +80,56 @@ describe('GroupRectModal', () => {
|
|||||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
expect(submitted.text_position).toBe('bottom-right')
|
expect(submitted.text_position).toBe('bottom-right')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Border Style section', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Border Style')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Solid')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Dashed')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Dotted')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('Double')).toBeDefined()
|
||||||
|
expect(screen.getByTitle('None')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults border_style to solid', () => {
|
||||||
|
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.border_style).toBe('solid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects border style on click', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByTitle('Dashed'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('dashed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills border_style from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ border_style: 'dotted' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('dotted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles border style — clicking selected style deselects back to solid', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByTitle('Dotted'))
|
||||||
|
fireEvent.click(screen.getByTitle('Solid'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.border_style).toBe('solid')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,4 +62,109 @@ describe('NodeModal', () => {
|
|||||||
fireEvent.click(screen.getByText('Cancel'))
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Hardware section', () => {
|
||||||
|
it('renders Hardware toggle button', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hardware fields are hidden by default', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands hardware fields on toggle click', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits hardware fields when filled', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0]
|
||||||
|
expect(submitted.cpu_model).toBe('Intel i7-12700K')
|
||||||
|
expect(submitted.cpu_count).toBe(12)
|
||||||
|
expect(submitted.ram_gb).toBe(64)
|
||||||
|
expect(submitted.disk_gb).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-expands when initial has hardware data', () => {
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides hardware section for groupRect type', () => {
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ type: 'groupRect' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Hardware')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show on node toggle is hidden when section is collapsed', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Show on node')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show on node toggle appears when section is expanded', () => {
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
expect(screen.getByText('Show on node')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show_hardware defaults to false', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggling show on node sets show_hardware to true', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||||
|
fireEvent.click(screen.getByText('Hardware'))
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills show_hardware from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<NodeModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware */}
|
||||||
|
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||||
|
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||||
|
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||||
|
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||||
|
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -202,6 +213,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatStorage(gb: number): string {
|
||||||
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||||
|
return `${gb} GB`
|
||||||
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { DetailPanel } from '../DetailPanel'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: data.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Test Node',
|
||||||
|
type: 'server',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode(nodeData)],
|
||||||
|
selectedNodeId: 'n1',
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DetailPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when no node is selected', () => {
|
||||||
|
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders node label and status', () => {
|
||||||
|
setupStore({ label: 'My Server', status: 'online' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('My Server')).toBeDefined()
|
||||||
|
expect(screen.getByText('online')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing for groupRect nodes', () => {
|
||||||
|
setupStore({ type: 'groupRect', label: 'Zone' })
|
||||||
|
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hardware section', () => {
|
||||||
|
it('does not render hardware section when no hardware data', () => {
|
||||||
|
setupStore({ label: 'Server' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Hardware')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders hardware section when cpu_count is set', () => {
|
||||||
|
setupStore({ cpu_count: 8 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('8')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cpu_model', () => {
|
||||||
|
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats ram_gb in GB', () => {
|
||||||
|
setupStore({ ram_gb: 32 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('32 GB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats ram_gb >= 1024 as TB', () => {
|
||||||
|
setupStore({ ram_gb: 2048 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('2 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats disk_gb in GB', () => {
|
||||||
|
setupStore({ disk_gb: 500 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('500 GB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats disk_gb >= 1024 as TB', () => {
|
||||||
|
setupStore({ disk_gb: 1536 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all hardware fields together', () => {
|
||||||
|
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Hardware')).toBeDefined()
|
||||||
|
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||||
|
expect(screen.getByText('16')).toBeDefined()
|
||||||
|
expect(screen.getByText('128 GB')).toBeDefined()
|
||||||
|
expect(screen.getByText('4 TB')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,12 +23,17 @@ export function useStatusPolling() {
|
|||||||
if (STANDALONE || !isAuthenticated || !token) return
|
if (STANDALONE || !isAuthenticated || !token) return
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
const host = window.location.hostname
|
const host = window.location.host // includes port when non-standard
|
||||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
// Send token as first message (not in URL to avoid log/history exposure)
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ token }))
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
if (state.clipboard.length === 0) return state
|
if (state.clipboard.length === 0) return state
|
||||||
const newNodes = state.clipboard.map((n) => ({
|
const newNodes = state.clipboard.map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||||
selected: false,
|
selected: false,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type NodeType =
|
|||||||
| 'printer'
|
| 'printer'
|
||||||
| 'computer'
|
| 'computer'
|
||||||
| 'cpl'
|
| 'cpl'
|
||||||
|
| 'docker'
|
||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
|
|
||||||
@@ -55,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
last_seen?: string
|
last_seen?: string
|
||||||
response_time_ms?: number
|
response_time_ms?: number
|
||||||
notes?: string
|
notes?: string
|
||||||
|
cpu_count?: number
|
||||||
|
cpu_model?: string
|
||||||
|
ram_gb?: number
|
||||||
|
disk_gb?: number
|
||||||
|
show_hardware?: boolean
|
||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
custom_colors?: {
|
custom_colors?: {
|
||||||
@@ -65,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
text_color?: string
|
text_color?: string
|
||||||
text_position?: TextPosition
|
text_position?: TextPosition
|
||||||
font?: string
|
font?: string
|
||||||
|
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
z_order?: number
|
z_order?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -99,6 +106,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
printer: 'Printer',
|
printer: 'Printer',
|
||||||
computer: 'Computer',
|
computer: 'Computer',
|
||||||
cpl: 'CPL / Powerline',
|
cpl: 'CPL / Powerline',
|
||||||
|
docker: 'Docker Host',
|
||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
|
|||||||
expect(keys).toContain('play') // Jellyfin
|
expect(keys).toContain('play') // Jellyfin
|
||||||
expect(keys).toContain('shield') // Pi-hole
|
expect(keys).toContain('shield') // Pi-hole
|
||||||
expect(keys).toContain('anchor') // Portainer
|
expect(keys).toContain('anchor') // Portainer
|
||||||
|
expect(keys).toContain('package') // Docker Host
|
||||||
expect(keys).toContain('key') // Vaultwarden
|
expect(keys).toContain('key') // Vaultwarden
|
||||||
expect(keys).toContain('database') // DB services
|
expect(keys).toContain('database') // DB services
|
||||||
expect(keys).toContain('cctv') // IP Camera / CCTV
|
expect(keys).toContain('cctv') // IP Camera / CCTV
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
|||||||
|
|
||||||
const NODE_TYPES: NodeType[] = [
|
const NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect',
|
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
|
||||||
]
|
]
|
||||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||||
@@ -84,6 +84,7 @@ describe('THEMES', () => {
|
|||||||
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||||
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||||
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
||||||
|
expect(d.nodeAccents.docker.border).toBe('#2496ED')
|
||||||
expect(d.nodeCardBackground).toBe('#21262d')
|
expect(d.nodeCardBackground).toBe('#21262d')
|
||||||
expect(d.nodeIconBackground).toBe('#161b22')
|
expect(d.nodeIconBackground).toBe('#161b22')
|
||||||
expect(d.canvasBackground).toBe('#0d1117')
|
expect(d.canvasBackground).toBe('#0d1117')
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { generateUUID } from '../uuid'
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
describe('generateUUID', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.randomUUID when available', () => {
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.getRandomValues fallback', () => {
|
||||||
|
vi.spyOn(crypto, 'randomUUID' as never).mockImplementation(undefined as never)
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique IDs', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 100 }, () => generateUUID()))
|
||||||
|
expect(ids.size).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
// Transfers & sync
|
// Transfers & sync
|
||||||
Download, Upload, RefreshCw,
|
Download, Upload, RefreshCw,
|
||||||
// Containers & Dev
|
// Containers & Dev
|
||||||
Anchor, GitBranch, Terminal, Code2, Settings,
|
Anchor, Package, GitBranch, Terminal, Code2, Settings,
|
||||||
// Communications
|
// Communications
|
||||||
Mail, MessageSquare, Phone,
|
Mail, MessageSquare, Phone,
|
||||||
// Misc devices
|
// Misc devices
|
||||||
@@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
|||||||
|
|
||||||
// --- Containers & Dev ---
|
// --- Containers & Dev ---
|
||||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||||
|
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
|
||||||
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
||||||
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
||||||
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
||||||
},
|
},
|
||||||
@@ -109,6 +110,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
||||||
},
|
},
|
||||||
@@ -162,6 +164,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||||
cpl: { border: '#b45309', icon: '#b45309' },
|
cpl: { border: '#b45309', icon: '#b45309' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
||||||
},
|
},
|
||||||
@@ -215,6 +218,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||||
|
docker: { border: '#00aaff', icon: '#00aaff' },
|
||||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
||||||
},
|
},
|
||||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#005500', icon: '#005500' },
|
printer: { border: '#005500', icon: '#005500' },
|
||||||
computer: { border: '#008822', icon: '#008822' },
|
computer: { border: '#008822', icon: '#008822' },
|
||||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||||
|
docker: { border: '#00cc88', icon: '#00cc88' },
|
||||||
generic: { border: '#006600', icon: '#006600' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Generates a UUID v4.
|
||||||
|
* Falls back to a manual implementation when crypto.randomUUID is unavailable
|
||||||
|
* (HTTP non-secure contexts, older browsers).
|
||||||
|
*/
|
||||||
|
export function generateUUID(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
// Fallback: RFC 4122 v4 UUID using crypto.getRandomValues if available
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
return [...bytes]
|
||||||
|
.map((b, i) => ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
// Last resort: Math.random based (not cryptographically secure)
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,5 +28,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "src/test/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Homelable — update to latest version
|
||||||
|
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
||||||
|
# bash /opt/homelable/scripts/update.sh
|
||||||
|
# Or pull-and-run directly:
|
||||||
|
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR=/opt/homelable
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
||||||
|
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
||||||
|
|
||||||
|
# ── Pull latest code ──────────────────────────────────────────────────────────
|
||||||
|
info "Pulling latest code..."
|
||||||
|
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||||
|
git -C "$INSTALL_DIR" pull --quiet
|
||||||
|
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||||
|
|
||||||
|
if [[ "$BEFORE" == "$AFTER" ]]; then
|
||||||
|
info "Already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Changes since last update:"
|
||||||
|
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Stop backend ─────────────────────────────────────────────────────────────
|
||||||
|
info "Stopping backend service..."
|
||||||
|
systemctl stop homelable-backend
|
||||||
|
|
||||||
|
# ── Backend deps ─────────────────────────────────────────────────────────────
|
||||||
|
info "Updating Python dependencies..."
|
||||||
|
cd "$INSTALL_DIR/backend"
|
||||||
|
.venv/bin/pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
# ── Frontend build ────────────────────────────────────────────────────────────
|
||||||
|
info "Rebuilding frontend..."
|
||||||
|
cd "$INSTALL_DIR/frontend"
|
||||||
|
npm ci --silent
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ── nginx config ─────────────────────────────────────────────────────────────
|
||||||
|
info "Updating nginx config..."
|
||||||
|
sed \
|
||||||
|
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
||||||
|
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
||||||
|
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
# ── Restart backend ───────────────────────────────────────────────────────────
|
||||||
|
info "Starting backend service..."
|
||||||
|
systemctl start homelable-backend
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
||||||
|
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user