Compare commits

...

24 Commits

Author SHA1 Message Date
Pouzor 529c75a175 feat(scripts): lxc-mcp-install env-var overrides + repo clone fallback
- All prompted values overridable via env vars (MCP_API_KEY,
  MCP_SERVICE_KEY, BACKEND_URL, INSTALL_DIR, etc.).
- Clone the repo into INSTALL_DIR if it isn't already present, so the
  script can be fetched and run directly inside a fresh LXC created by
  the community-scripts/ProxmoxVE helper (no manual git clone first).
- README: clarify the Proxmox flow (community-scripts creates the LXC,
  user runs this script inside it).
2026-05-27 22:27:54 +02:00
Pouzor fc765fa255 ci(docker): publish homelable-mcp image to GHCR
Add mcp matrix entry in docker-publish workflow so the MCP server
image is built and pushed alongside backend/frontend. Also wire the
prebuilt compose file so users can pull and run it directly.

Closes #162
2026-05-27 21:32:55 +02:00
Pouzor 77159ce1cd feat(scripts): add LXC/bare-metal MCP install script
Adds scripts/lxc-mcp-install.sh for Proxmox LXC and other non-Docker
hosts. Creates a homelable-mcp systemd service, prompts for
MCP_API_KEY / MCP_SERVICE_KEY (auto-generated on Enter), and skips
prompts when mcp/.env already exists so user edits are preserved.

Closes #132
2026-05-26 15:13:14 +02:00
Pouzor f8635df1c5 bump: version 2.1.1 2026-05-17 00:30:21 +02:00
Rémy 1cc9b7c52f Merge pull request #155 from Pouzor/fix/liveview-parity
fix(liveview): nest docker children, apply theme & custom style
2026-05-16 22:28:38 +02:00
Pouzor fdf2b1f2be chore(liveview): include setTheme/setCustomStyle in effect deps 2026-05-16 21:52:52 +02:00
Pouzor 5630e7d202 fix(liveview): nest docker children, apply theme & custom style
- Live view rebuilt parent map from proxmox/group only, so docker_container
  (and any vm/lxc with container_mode=true) lost parentId and rendered
  outside its host. Match App.tsx logic: include any node with
  container_mode=true.
- Live view ignored viewport.theme_id and custom_style on load, so the
  shared canvas always rendered in default theme. Apply both like the
  authenticated canvas does.
- Backend /liveview response now includes custom_style from CanvasState.

Adds regression tests on both ends.
2026-05-16 21:49:55 +02:00
Pouzor be705f0cb9 bump: version 2.1.0 2026-05-16 18:18:46 +02:00
Rémy 1790d24ee7 Merge pull request #154 from Pouzor/feat/docker-container-lxc-parent
feat(node-modal): sanitize parent_id on type change and exclude descendants
2026-05-16 18:03:31 +02:00
Pouzor 8b97f578c6 feat(node-modal): sanitize parent_id on type change and exclude descendants
- App.tsx: filter descendants of edited node from parentCandidates to
  prevent picking a child as parent (would create a cycle).
- NodeModal.tsx: clear parent_id when switching to a type with no valid
  parents, and revalidate parent_id on submit against the type's allowlist.
- Tests: cover getValidParentTypes for lxc/vm/docker_container and types
  that disallow parents.

ha-relevant: yes
2026-05-16 17:41:58 +02:00
Rémy 48e7b3c690 Merge pull request #153 from Pouzor/feat/docker-container-lxc-parent
feat(docker): allow LXC as parent for docker_container
2026-05-16 17:11:50 +02:00
Pouzor 3a4df578ec feat(node-modal): restore Parent Container selector for child types
Adds back the Parent Container selector in NodeModal, shown when:
- child type is lxc or vm (parents: proxmox/vm/lxc/docker_host)
- child type is docker_container (parents: docker_host/lxc/vm/proxmox)

Selector hidden when no valid candidate node exists on the canvas.
Centralizes the parent-type allowlist in getValidParentTypes
(shared with the virtual-edge auto-parent helper).

ha-relevant: yes
2026-05-16 17:08:06 +02:00
Pouzor 43426e96ac feat(docker): allow vm and proxmox as parent for docker_container
Extend docker_container parent rule to accept vm and proxmox nodes
in addition to docker_host and lxc, covering nested virtualization
topologies (Docker on a VM, Docker directly on Proxmox host).

ha-relevant: yes
2026-05-16 16:47:14 +02:00
Pouzor 280d5a2ae1 feat(docker): allow LXC as parent for docker_container
Previously docker_container could only attach to a docker_host via
virtual edge. Extend the rule so an LXC node can also act as parent,
enabling docker_container nesting under LXC without requiring an
intermediate docker_host.

Extracts the virtual-edge parent rule into a pure helper
(resolveVirtualEdgeParent) with unit tests covering all type pairs.

ha-relevant: yes
2026-05-15 22:54:51 +02:00
Rémy cdc6091bd0 Merge pull request #152 from Pouzor/feat/group-edge-handles
feat(group): add 4 side handles for edge connections
2026-05-15 21:19:31 +02:00
Pouzor 14597ab86f feat(group): add 4 side handles for edge connections
Group nodes can now be source/target of edges via snap points on
each side (top/right/bottom/left). Each side exposes a source handle
plus an invisible target overlay, matching the pattern used by
BaseNode.
2026-05-15 21:12:10 +02:00
Rémy 14719a5c39 Merge pull request #136 from findthelorax/feat/multiple-ips
feat: multiple ips in detail panel
2026-05-15 14:40:47 +02:00
Pouzor ea451885af refactor(ip-parsing): centralize multi-delim splitting in splitIps
Extend splitIps to accept comma/semicolon/newline delimiters with dedupe,
and reuse it in DetailPanel for both the IP list and the service-URL host.
Fixes ServiceBadge links pointing at concatenated string when IPs were
entered with ';' or newline separators. Adds tests for new delimiters.
2026-05-15 13:36:15 +02:00
findthelorax 9cd93ef294 updated tests for multiple ips 2026-05-15 11:43:09 +02:00
findthelorax 8d937b2a0d removed comma and allow multiple in a row 2026-05-15 11:31:25 +02:00
findthelorax 60ea0fa11b made multiple ips all clickable separately 2026-05-15 11:31:25 +02:00
Rémy ff0d33128c Merge pull request #150 from Pouzor/feat/edge-endpoint-reconnect
feat(canvas): edge endpoint reconnect + proxmox container snap points
2026-05-15 11:10:47 +02:00
Pouzor 17d4ddd070 feat(canvas): edge endpoint reconnect + proxmox container snap points
- Selected edge shows interactive dots at both endpoints; drag onto any
  handle (different snap point or different node) to reconnect. Edge
  follows the cursor live during drag; drop on empty space leaves edge
  unchanged.
- Endpoint dots render in EdgeLabelRenderer above the node layer and walk
  the elementsFromPoint stack so the underlying handle wins over the
  dragged dot itself.
- ProxmoxGroupNode in container mode now renders bottom_handles snap
  points (was always single 'bottom' handle), matching BaseNode.
- New canvasStore.reconnectEdge action: swaps source/target/handles via
  normalizeHandle, snapshots history, marks dirty.
2026-05-15 03:05:38 +02:00
Rémy 4795dc40b8 Merge pull request #149 from Pouzor/feat/homepage-widget-stats
feat(api): stats endpoint for gethomepage widget
2026-05-15 01:16:48 +02:00
29 changed files with 1014 additions and 54 deletions
+9 -2
View File
@@ -16,14 +16,21 @@ jobs:
matrix: matrix:
include: include:
- image: ghcr.io/pouzor/homelable-backend - image: ghcr.io/pouzor/homelable-backend
context: .
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend
build_args: "" build_args: ""
- image: ghcr.io/pouzor/homelable-frontend - image: ghcr.io/pouzor/homelable-frontend
context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
build_args: "" build_args: ""
- image: ghcr.io/pouzor/homelable-frontend-standalone - image: ghcr.io/pouzor/homelable-frontend-standalone
context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
build_args: "VITE_STANDALONE=true" build_args: "VITE_STANDALONE=true"
- image: ghcr.io/pouzor/homelable-mcp
context: ./mcp
dockerfile: Dockerfile.mcp
build_args: ""
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -55,8 +62,8 @@ jobs:
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }} file: ${{ matrix.context }}/${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+6
View File
@@ -223,6 +223,12 @@ docker compose up -d mcp
# MCP server is now listening on http://<your-homelab-ip>:8001 # MCP server is now listening on http://<your-homelab-ip>:8001
``` ```
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
**3. Configure your AI client:** **3. Configure your AI client:**
**Claude Code** — run this command in your terminal: **Claude Code** — run this command in your terminal:
+1 -1
View File
@@ -1 +1 @@
2.0.3 2.1.1
+2
View File
@@ -34,8 +34,10 @@ async def liveview_canvas(
edges = (await db.execute(select(Edge))).scalars().all() edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse( return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes], nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges], edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport, viewport=viewport,
custom_style=custom_style,
) )
+22
View File
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
assert nodes[0]["label"] == "Live Node" assert nodes[0]["label"] == "Live Node"
# ── custom_style + theme propagation ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
settings.liveview_key = "test-key"
headers = await auth_headers()
payload = {
"nodes": [],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
body = res.json()
assert body["viewport"].get("theme_id") == "matrix"
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
# ── Re-disable after enabling ───────────────────────────────────────────────── # ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
+14
View File
@@ -24,6 +24,20 @@ services:
networks: networks:
- homelable - homelable
mcp:
image: ghcr.io/pouzor/homelable-mcp:latest
restart: unless-stopped
ports:
- "8001:8001"
env_file:
- .env
environment:
BACKEND_URL: "http://backend:8000"
depends_on:
- backend
networks:
- homelable
volumes: volumes:
backend_data: backend_data:
+65 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.0.3", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "2.0.3", "version": "2.1.1",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
@@ -2520,6 +2520,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2534,6 +2537,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2548,6 +2554,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2562,6 +2571,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2576,6 +2588,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2590,6 +2605,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2604,6 +2622,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2618,6 +2639,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2632,6 +2656,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2646,6 +2673,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2660,6 +2690,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2674,6 +2707,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2688,6 +2724,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2936,6 +2975,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2953,6 +2995,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2970,6 +3015,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2987,6 +3035,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6884,6 +6935,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6905,6 +6959,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6926,6 +6983,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6947,6 +7007,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2.0.3", "version": "2.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+29 -11
View File
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout' 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 { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { ExportModal } from '@/components/modals/ExportModal' import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
@@ -37,7 +38,6 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
@@ -450,16 +450,14 @@ export default function App() {
if (edgeData.type === 'virtual') { if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source) const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target) const tgt = nodes.find((n) => n.id === pendingConnection.target)
const srcType = src?.data.type as NodeData['type'] if (src && tgt) {
const tgtType = tgt?.data.type as NodeData['type'] const assignment = resolveVirtualEdgeParent(
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) { { id: src.id, type: src.data.type as NodeData['type'] },
updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) { id: tgt.id, type: tgt.data.type as NodeData['type'] },
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { )
updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) if (assignment) {
} else if (srcType === 'docker_container' && tgtType === 'docker_host') { updateNode(assignment.childId, { parent_id: assignment.parentId })
updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) }
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
} }
} }
setPendingConnection(null) setPendingConnection(null)
@@ -552,6 +550,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)} onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode} onSubmit={handleAddNode}
title="Add Node" title="Add Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
/> />
{/* key forces re-mount when editing a different node, resetting form state */} {/* key forces re-mount when editing a different node, resetting form state */}
@@ -562,6 +561,25 @@ export default function App() {
onSubmit={handleUpdateNode} onSubmit={handleUpdateNode}
initial={editNode?.data} initial={editNode?.data}
title="Edit Node" title="Edit Node"
parentCandidates={(() => {
const descendants = new Set<string>()
if (editNodeId) {
const queue = [editNodeId]
while (queue.length) {
const id = queue.shift()!
for (const n of nodes) {
if (n.data.parent_id === id && !descendants.has(n.id)) {
descendants.add(n.id)
queue.push(n.id)
}
}
}
}
return nodes
.filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
})()}
currentNodeId={editNodeId ?? undefined}
/> />
<EdgeModal <EdgeModal
+9 -4
View File
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes' import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { liveviewApi } from '@/api/client' import { liveviewApi } from '@/api/client'
import type { NodeData } from '@/types' import type { NodeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas' const STORAGE_KEY = 'homelable_canvas'
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore() const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const setTheme = useThemeStore((s) => s.setTheme)
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect): // Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required) // - standalone → always ready (localStorage, no key required)
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>( const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group') .filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) .map((n: ApiNode) => [n.id, true])
) )
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas( loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge), (apiEdges as ApiEdge[]).map(deserializeApiEdge),
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
const detail: string = err.response.data?.detail ?? '' const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key') setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
}) })
}, [loadCanvas]) }, [loadCanvas, setTheme, setCustomStyle])
useEffect(() => { useEffect(() => {
if (!fitViewPending || nodes.length === 0) return if (!fitViewPending || nodes.length === 0) return
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ──────────────────────────────────────────────── // ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
expect(nodes.find((n) => n.id === 'n1')).toBeDefined() expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
}) })
// ── Nested children (docker_container inside docker_host) ────────────────
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
setSearch('?key=valid')
const nestedPayload = {
data: {
nodes: [
{
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
services: [], pos_x: 0, pos_y: 0, container_mode: true,
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
},
{
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
expect(ctr?.parentId).toBe('host')
expect(ctr?.extent).toBe('parent')
})
// ── Theme + custom_style applied from payload ────────────────────────────
it('applies viewport.theme_id and custom_style from the payload', async () => {
setSearch('?key=valid')
const styledPayload = {
data: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
},
}
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(useThemeStore.getState().activeTheme).toBe('matrix')
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
})
// ── No editing props passed ─────────────────────────────────────────────── // ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => { it('does not show any Access Denied when key is valid', async () => {
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
NodeResizer: ({ isVisible }: { isVisible: boolean }) => ( NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
<div data-testid="node-resizer" data-visible={isVisible} /> <div data-testid="node-resizer" data-visible={isVisible} />
), ),
Handle: () => null,
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({}), useReactFlow: () => ({}),
})) }))
+138 -2
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react' import { useCallback, useState } from 'react'
import { import {
BaseEdge, BaseEdge,
EdgeLabelRenderer, EdgeLabelRenderer,
@@ -179,9 +179,113 @@ function segmentMidpoints(
}) })
} }
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
interface EndpointDotProps {
edgeId: string
role: 'source' | 'target'
x: number
y: number
position?: string
color: string
source: string
target: string
sourceHandle: string | null | undefined
targetHandle: string | null | undefined
onDrag: (pos: { x: number; y: number } | null) => void
}
/**
* Interactive endpoint marker rendered above the node layer (via
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
* the new endpoint. Drop on empty space leaves the edge unchanged.
*
* Handles are nudged 3px inward (toward the node) because React Flow's edge
* endpoint coords sit at the outer edge of the handle box, not its center.
*/
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
const { screenToFlowPosition } = useReactFlow()
const offset = 3
let dx = 0, dy = 0
if (position === 'bottom') dy = -offset
else if (position === 'top') dy = offset
else if (position === 'left') dx = offset
else if (position === 'right') dx = -offset
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons !== 1) return
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
}, [onDrag, screenToFlowPosition])
const onPointerUp = useCallback((e: React.PointerEvent) => {
e.currentTarget.releasePointerCapture(e.pointerId)
// Find the topmost handle under cursor, skipping the dragged dot itself.
const stack = document.elementsFromPoint(e.clientX, e.clientY)
let handleEl: HTMLElement | null = null
for (const node of stack) {
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
if (h) { handleEl = h; break }
}
onDrag(null)
if (!handleEl) return // dropped on empty space → keep edge unchanged
const newHandleId = handleEl.getAttribute('data-handleid')
const newNodeId = handleEl.getAttribute('data-nodeid')
if (!newHandleId || !newNodeId) return
if (role === 'source') {
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
} else {
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
}
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
return (
<div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
width: 15,
height: 15,
borderRadius: '50%',
background: color,
border: '2px solid #0d1117',
cursor: 'grab',
pointerEvents: 'all',
zIndex: 1000,
touchAction: 'none',
}}
title="Drag to reconnect"
/>
)
}
// ── Main edge component ────────────────────────────────────────────────────── // ── Main edge component ──────────────────────────────────────────────────────
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) { export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
}, [])
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
}, [])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type) const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
@@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
</div> </div>
)} )}
{/* Endpoint dots — visual indicators for reconnection targets */}
{selected && (
<>
<EndpointDot
edgeId={id}
role="source"
x={sourceX}
y={sourceY}
position={sourcePosition}
color={strokeColor}
source={source}
target={target}
sourceHandle={sourceHandleId}
targetHandle={targetHandleId}
onDrag={onSourceDrag}
/>
<EndpointDot
edgeId={id}
role="target"
x={targetX}
y={targetY}
position={targetPosition}
color={strokeColor}
source={source}
target={target}
sourceHandle={sourceHandleId}
targetHandle={targetHandleId}
onDrag={onTargetDrag}
/>
</>
)}
{/* Existing waypoint drag handles */} {/* Existing waypoint drag handles */}
{selected && waypoints.map((wp, idx) => { {selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
@@ -1,11 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react' import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
import { Layers, Pencil, Check, X } from 'lucide-react' import { Layers, Pencil, Check, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { STATUS_COLORS, type NodeData } from '@/types' import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) { export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore() const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const showBorder = data.custom_colors?.show_border !== false const showBorder = data.custom_colors?.show_border !== false
const isVisible = showBorder || selected const isVisible = showBorder || selected
@@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }} handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
/> />
{/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */}
{([
['group-top', Position.Top],
['group-right', Position.Right],
['group-bottom', Position.Bottom],
['group-left', Position.Left],
] as const).map(([hid, pos]) => (
<span key={hid}>
<Handle
type="source"
position={pos}
id={hid}
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={pos}
id={`${hid}-t`}
style={{ opacity: 0, width: 12, height: 12 }}
/>
</span>
))}
{/* Header */} {/* Header */}
{isVisible && ( {isVisible && (
<div <div
@@ -1,5 +1,5 @@
import { createElement } from 'react' import { createElement, useEffect } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, 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'
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp' import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) { export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props const { id, data, selected } = props
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
@@ -149,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/> />
<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 }} />
<Handle {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
type="source" const sourceId = bottomHandleId(idx)
position={Position.Bottom} const targetId = `${sourceId}-t`
id="bottom" return (
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} <span key={sourceId}>
/> <Handle
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} /> type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
{/* Cluster handles */} {/* Cluster handles */}
<Handle <Handle
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { GroupNode } from '../GroupNode'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import type { NodeData } from '@/types'
import type { NodeProps, Node } from '@xyflow/react'
function renderNode(data: Partial<NodeData> = {}, selected = false) {
const fullData: NodeData = {
label: 'Group A',
type: 'group',
status: 'unknown',
services: [],
...data,
}
const props = {
id: 'g1',
data: fullData,
selected,
type: 'group',
zIndex: 0,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
deletable: true,
draggable: true,
selectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
width: 300,
height: 200,
dragHandle: undefined,
parentId: undefined,
sourcePosition: undefined,
targetPosition: undefined,
} as unknown as NodeProps<Node<NodeData>>
return render(
<ReactFlowProvider>
<GroupNode {...props} />
</ReactFlowProvider>
)
}
describe('GroupNode', () => {
beforeEach(() => {
useCanvasStore.setState({ nodes: [], hideIp: false })
useThemeStore.setState({ activeTheme: 'default' })
})
it('renders label', () => {
const { getByText } = renderNode({ label: 'My Group' })
expect(getByText('My Group')).toBeDefined()
})
it('renders 4 source handles (one per side)', () => {
const { container } = renderNode()
expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull()
})
it('renders 4 target handles (one per side)', () => {
const { container } = renderNode()
expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull()
expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull()
})
it('source handles carry side-specific ids', () => {
const { container } = renderNode()
expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull()
expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull()
expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull()
expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull()
})
})
@@ -102,6 +102,18 @@ describe('ProxmoxGroupNode', () => {
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull() expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
}) })
it('container mode renders bottom_handles snap points', () => {
const { container } = renderNode({ bottom_handles: 4 })
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
expect(sourceHandles.length).toBe(4)
})
it('container mode default has single bottom handle', () => {
const { container } = renderNode({})
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
expect(sourceHandles.length).toBe(1)
})
it('renders cluster handles in both modes', () => { it('renders cluster handles in both modes', () => {
const { container: groupC } = renderNode({}) const { container: groupC } = renderNode({})
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
+59 -2
View File
@@ -11,6 +11,7 @@ import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker' import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils' import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
@@ -48,17 +49,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
custom_icon: undefined, custom_icon: undefined,
} }
interface ParentCandidate {
id: string
label: string
type: NodeType
}
interface NodeModalProps { interface NodeModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSubmit: (data: Partial<NodeData>) => void onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData> initial?: Partial<NodeData>
title?: string title?: string
parentCandidates?: ParentCandidate[]
currentNodeId?: string
} }
// NodeModal is always mounted with a key that changes on open/edit, so useState // NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect. // initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) { export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial } const merged = { ...DEFAULT_DATA, ...initial }
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none' if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged) const [form, setForm] = useState<Partial<NodeData>>(merged)
@@ -86,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setLabelError(false) setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType)
let safeParentId = form.parent_id
if (validParentTypes.length === 0) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
}
onSubmit({ onSubmit({
...form, ...form,
parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false, container_mode: canUseContainerMode ? !!form.container_mode : false,
}) })
onClose() onClose()
@@ -107,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Type</Label> <Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => { <Select value={form.type} onValueChange={(v) => {
const t = v as NodeType const t = v as NodeType
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) })) setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
return next
})
}}> }}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue> <SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
@@ -320,6 +343,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </div>
)} )}
{/* Parent Container */}
{(() => {
const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType)
if (validParentTypes.length === 0) return null
const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
)
if (validParents.length === 0) return null
return (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue>
{form.parent_id
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
: 'None'}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None</SelectItem>
{validParents.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})()}
{/* Container mode */} {/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && ( {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1"> <div className="flex items-center justify-between col-span-2 py-1">
@@ -311,18 +311,48 @@ describe('NodeModal', () => {
expect(screen.queryByText('Reset to defaults')).toBeNull() expect(screen.queryByText('Reset to defaults')).toBeNull()
}) })
// ── Parent Container selector removed ──────────────────────────────── // ── Parent Container selector ─────────────────────────────────────────
it('does not render the Parent Container selector', () => { it('does not render Parent Container for non-child types', () => {
renderModal({ initial: BASE }) renderModal({
initial: BASE,
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
})
expect(screen.queryByText('Parent Container')).toBeNull() expect(screen.queryByText('Parent Container')).toBeNull()
}) })
it('does not render Parent Container for docker_container either', () => { it('does not render Parent Container when no valid candidates exist', () => {
renderModal({ initial: { ...BASE, type: 'docker_container' } }) renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [],
})
expect(screen.queryByText('Parent Container')).toBeNull() expect(screen.queryByText('Parent Container')).toBeNull()
}) })
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('renders Parent Container for lxc when proxmox candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'lxc' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
// ── Appearance ──────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => { it('renders 3 color swatch labels (border, background, icon)', () => {
+22 -8
View File
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import { primaryIp } from '@/utils/maskIp' import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const { data } = node const { data } = node
const services = data.services ?? [] const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status] const statusColor = STATUS_COLORS[data.status]
const host = data.ip ?? data.hostname const ipAddresses = data.ip ? splitIps(data.ip) : []
const host = ipAddresses[0] ?? data.hostname
const handleDelete = () => { const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) { if (confirm(`Delete "${data.label}"?`)) {
@@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</a> </a>
</div> </div>
)} )}
{data.ip && ( {ipAddresses.length > 0 && (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-start">
<span className="text-muted-foreground text-xs shrink-0">IP Address</span> <span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}> <div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
{data.ip}<ExternalLink size={10} className="shrink-0" /> {ipAddresses.map((ip, index) => (
</a> <span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
<a
href={`http://${ip}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
title={ip}
>
{ip}
<ExternalLink size={10} className="shrink-0" />
</a>
</span>
))}
</div>
</div> </div>
)} )}
{data.mac && <DetailRow label="MAC" value={data.mac} mono />} {data.mac && <DetailRow label="MAC" value={data.mac} mono />}
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
it('displays full comma-separated IP string as link text', () => { it('displays full comma-separated IP string as link text', () => {
setupStore({ ip: '192.168.1.10, 192.168.1.11' }) setupStore({ ip: '192.168.1.10, 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined() expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined()
expect(screen.queryByText(',')).toBeNull()
})
it('renders separate links for semicolon-separated IPs', () => {
setupStore({ ip: '192.168.1.10; 192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
})
it('renders separate links for newline-separated IPs', () => {
setupStore({ ip: '192.168.1.10\n192.168.1.11' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
}) })
}) })
+7
View File
@@ -11,6 +11,13 @@
to { stroke-dashoffset: 0; } to { stroke-dashoffset: 0; }
} }
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
its own interactive endpoint dots in EdgeLabelRenderer (above the node
layer) so the node Handle DOM cannot steal the reconnection drag. */
.react-flow__edgeupdater {
display: none;
}
/* Homelable dark theme — always dark */ /* Homelable dark theme — always dark */
:root { :root {
--background: #0d1117; --background: #0d1117;
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
}) })
it('reconnectEdge swaps source/target and normalizes handles', () => {
useCanvasStore.setState((s) => ({
edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }],
}))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().reconnectEdge('e1', {
source: 'n1',
target: 'n3',
sourceHandle: 'bottom-2-t',
targetHandle: 'top-t',
})
const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(edge?.target).toBe('n3')
expect(edge?.source).toBe('n1')
expect(edge?.sourceHandle).toBe('bottom-2')
expect(edge?.targetHandle).toBe('top')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('reconnectEdge snapshots history for undo', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] }))
useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null })
expect(useCanvasStore.getState().past.length).toBe(1)
})
it('deleteEdge removes the edge and marks unsaved', () => { it('deleteEdge removes the edge and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
+19
View File
@@ -44,6 +44,7 @@ interface CanvasState {
updateNode: (id: string, data: Partial<NodeData>) => void updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void updateEdge: (id: string, data: Partial<EdgeData>) => void
reconnectEdge: (id: string, connection: Connection) => void
deleteEdge: (id: string) => void deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
@@ -292,6 +293,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true, hasUnsavedChanges: true,
})), })),
reconnectEdge: (id, connection) =>
set((state) => ({
edges: state.edges.map((e) =>
e.id === id
? {
...e,
source: connection.source ?? e.source,
target: connection.target ?? e.target,
sourceHandle: normalizeHandle(connection.sourceHandle),
targetHandle: normalizeHandle(connection.targetHandle),
}
: e
),
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
})),
deleteEdge: (id) => deleteEdge: (id) =>
set((state) => ({ set((state) => ({
edges: state.edges.filter((e) => e.id !== id), edges: state.edges.filter((e) => e.id !== id),
@@ -55,6 +55,27 @@ describe('splitIps', () => {
expect(splitIps('')).toEqual([]) expect(splitIps('')).toEqual([])
expect(splitIps(' ')).toEqual([]) expect(splitIps(' ')).toEqual([])
}) })
it('splits on semicolons', () => {
expect(splitIps('10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
})
it('splits on newlines', () => {
expect(splitIps('10.0.0.1\n10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
})
it('splits on mixed delimiters', () => {
expect(splitIps('10.0.0.1,10.0.0.2; 10.0.0.3\n10.0.0.4')).toEqual([
'10.0.0.1',
'10.0.0.2',
'10.0.0.3',
'10.0.0.4',
])
})
it('deduplicates repeated IPs', () => {
expect(splitIps('10.0.0.1, 10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
})
}) })
describe('primaryIp', () => { describe('primaryIp', () => {
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
describe('getValidParentTypes', () => {
it('returns container-mode types for lxc', () => {
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns container-mode types for vm', () => {
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
})
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
})
it('returns empty list for types that cannot have a parent', () => {
expect(getValidParentTypes('server')).toEqual([])
expect(getValidParentTypes('router')).toEqual([])
expect(getValidParentTypes('proxmox')).toEqual([])
expect(getValidParentTypes('docker_host')).toEqual([])
})
})
describe('resolveVirtualEdgeParent', () => {
it('nests lxc under proxmox (container-mode parent)', () => {
const res = resolveVirtualEdgeParent(
{ id: 'lxc1', type: 'lxc' },
{ id: 'px1', type: 'proxmox' },
)
expect(res).toEqual({ childId: 'lxc1', parentId: 'px1' })
})
it('nests vm under proxmox regardless of edge direction', () => {
const res = resolveVirtualEdgeParent(
{ id: 'px1', type: 'proxmox' },
{ id: 'vm1', type: 'vm' },
)
expect(res).toEqual({ childId: 'vm1', parentId: 'px1' })
})
it('nests docker_container under docker_host', () => {
const res = resolveVirtualEdgeParent(
{ id: 'dc1', type: 'docker_container' },
{ id: 'dh1', type: 'docker_host' },
)
expect(res).toEqual({ childId: 'dc1', parentId: 'dh1' })
})
it('nests docker_container under lxc (reverse direction)', () => {
const res = resolveVirtualEdgeParent(
{ id: 'lxc1', type: 'lxc' },
{ id: 'dc1', type: 'docker_container' },
)
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
})
it('nests docker_container under lxc (forward direction)', () => {
const res = resolveVirtualEdgeParent(
{ id: 'dc1', type: 'docker_container' },
{ id: 'lxc1', type: 'lxc' },
)
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
})
it('nests docker_container under vm', () => {
const res = resolveVirtualEdgeParent(
{ id: 'dc1', type: 'docker_container' },
{ id: 'vm1', type: 'vm' },
)
expect(res).toEqual({ childId: 'dc1', parentId: 'vm1' })
})
it('nests docker_container under proxmox (reverse direction)', () => {
const res = resolveVirtualEdgeParent(
{ id: 'px1', type: 'proxmox' },
{ id: 'dc1', type: 'docker_container' },
)
expect(res).toEqual({ childId: 'dc1', parentId: 'px1' })
})
it('returns null when docker_container links to unsupported parent type', () => {
const res = resolveVirtualEdgeParent(
{ id: 'dc1', type: 'docker_container' },
{ id: 'srv1', type: 'server' },
)
expect(res).toBeNull()
})
it('returns null for unrelated type pairs', () => {
const res = resolveVirtualEdgeParent(
{ id: 'srv1', type: 'server' },
{ id: 'rt1', type: 'router' },
)
expect(res).toBeNull()
})
})
+5 -4
View File
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
*/ */
export function maskIp(ip: string): string { export function maskIp(ip: string): string {
if (!ip) return ip if (!ip) return ip
return ip.split(',').map(maskSingle).join(', ') return splitIps(ip).map(maskSingle).join(', ')
} }
/** /**
* Split a comma-separated IP string into an array of trimmed values. * Split an IP string into trimmed values. Accepts comma, semicolon, or newline
* Empty string returns []. * delimiters (or any combination). Duplicates removed, empty entries dropped.
*/ */
export function splitIps(ip: string): string[] { export function splitIps(ip: string): string[] {
if (!ip?.trim()) return [] if (!ip?.trim()) return []
return ip.split(',').map((s) => s.trim()).filter(Boolean) const parts = ip.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
return parts.filter((v, i) => parts.indexOf(v) === i)
} }
/** /**
+48
View File
@@ -0,0 +1,48 @@
import type { NodeData } from '@/types'
export type NodeType = NodeData['type']
const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
export interface VirtualEdgeEndpoint {
id: string
type: NodeType
}
export interface ParentAssignment {
childId: string
parentId: string
}
export function getValidParentTypes(childType: NodeType): NodeType[] {
if (childType === 'lxc' || childType === 'vm') {
return ['proxmox', 'vm', 'lxc', 'docker_host']
}
if (childType === 'docker_container') {
return ['docker_host', 'lxc', 'vm', 'proxmox']
}
return []
}
export function resolveVirtualEdgeParent(
source: VirtualEdgeEndpoint,
target: VirtualEdgeEndpoint,
): ParentAssignment | null {
const { type: srcType, id: srcId } = source
const { type: tgtType, id: tgtId } = target
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
return { childId: srcId, parentId: tgtId }
}
if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
return { childId: tgtId, parentId: srcId }
}
if (srcType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(tgtType)) {
return { childId: srcId, parentId: tgtId }
}
if (tgtType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(srcType)) {
return { childId: tgtId, parentId: srcId }
}
return null
}
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
# Install / enable the Homelable MCP server as a systemd service.
#
# Run interactively as root, inside an LXC or any Debian/Ubuntu host.
# Typical Proxmox VE flow: create the LXC via the community-scripts/ProxmoxVE
# helper, then run this script inside that LXC.
#
# Idempotent: re-running is safe. If mcp/.env already exists, the script
# keeps it untouched and only refreshes the venv + systemd unit.
#
# Optional env vars (override defaults / skip the matching prompt):
# INSTALL_DIR repo root (default: /opt/homelable)
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
# REPO_REF branch/tag/commit when cloning (default: main)
# SERVICE_USER systemd User= (default: homelable)
# MCP_PORT listen port (default: 8001)
# MCP_API_KEY client → MCP key (default: prompt, auto-gen on empty)
# MCP_SERVICE_KEY MCP → backend key (default: prompt, auto-gen on empty; must match backend .env)
# BACKEND_URL backend base URL (default: http://127.0.0.1:8000)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
REPO_REF="${REPO_REF:-main}"
SERVICE_USER="${SERVICE_USER:-homelable}"
SERVICE_NAME="homelable-mcp"
MCP_PORT="${MCP_PORT:-8001}"
DEFAULT_BACKEND_URL="http://127.0.0.1:8000"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
log "Installing OS dependencies (git, python3-venv, curl)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
git python3 python3-venv python3-pip curl iproute2 >/dev/null
MCP_DIR="$INSTALL_DIR/mcp"
if [[ ! -d "$MCP_DIR" ]]; then
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
fi
[[ -f "$MCP_DIR/requirements.txt" ]] || fail "Missing $MCP_DIR/requirements.txt — repo layout unexpected."
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${MCP_PORT}$"; then
warn "Port $MCP_PORT already in use. If it's a previous $SERVICE_NAME instance this is fine; otherwise abort and free the port."
fi
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating service user '$SERVICE_USER'"
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
ENV_FILE="$MCP_DIR/.env"
gen_key() { python3 -c "import secrets;print('$1' + secrets.token_hex(24))"; }
if [[ -f "$ENV_FILE" ]]; then
log ".env already present at $ENV_FILE — keeping existing values"
else
[[ -f "$MCP_DIR/.env.example" ]] || fail "Missing $MCP_DIR/.env.example"
log "No .env found — generating one (press Enter to accept defaults)"
api_key="${MCP_API_KEY:-}"
svc_key="${MCP_SERVICE_KEY:-}"
backend_url="${BACKEND_URL:-}"
if [[ -z "$api_key" ]]; then
default_api_key="$(gen_key mcp_sk_)"
read -rp "MCP_API_KEY (client → MCP) [default: auto-generate]: " api_key
api_key="${api_key:-$default_api_key}"
fi
if [[ -z "$svc_key" ]]; then
default_svc_key="$(gen_key svc_)"
read -rp "MCP_SERVICE_KEY (MCP → backend, must match backend .env) [default: auto-generate]: " svc_key
svc_key="${svc_key:-$default_svc_key}"
fi
if [[ -z "$backend_url" ]]; then
read -rp "BACKEND_URL [$DEFAULT_BACKEND_URL]: " backend_url
backend_url="${backend_url:-$DEFAULT_BACKEND_URL}"
fi
umask 077
cat >"$ENV_FILE" <<EOF
MCP_API_KEY=$api_key
MCP_SERVICE_KEY=$svc_key
BACKEND_URL=$backend_url
EOF
log "Wrote $ENV_FILE (mode 600)"
warn "If the backend runs elsewhere, set the SAME MCP_SERVICE_KEY in its .env."
fi
VENV="$MCP_DIR/.venv"
if [[ ! -d "$VENV" ]]; then
log "Creating venv at $VENV"
python3 -m venv "$VENV"
fi
log "Installing Python deps"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -r "$MCP_DIR/requirements.txt"
chown -R "$SERVICE_USER":"$SERVICE_USER" "$MCP_DIR"
chmod 600 "$ENV_FILE"
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
log "Writing $UNIT"
cat >"$UNIT" <<EOF
[Unit]
Description=Homelable MCP server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$MCP_DIR
EnvironmentFile=$ENV_FILE
ExecStart=$VENV/bin/uvicorn app.main:app --host 0.0.0.0 --port $MCP_PORT
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
log "Waiting for MCP to come up on :$MCP_PORT"
ok=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://127.0.0.1:${MCP_PORT}/health" >/dev/null 2>&1; then
ok=1; break
fi
sleep 1
done
if [[ "$ok" -ne 1 ]]; then
warn "MCP did not respond on /health within 10s. Check: journalctl -u $SERVICE_NAME -n 50"
else
log "MCP server is up."
fi
LXC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VALUE="$(grep -E '^MCP_API_KEY=' "$ENV_FILE" | cut -d= -f2-)"
cat <<EOF
----------------------------------------------------------------
MCP server installed.
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
Listen: http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp
Env file: $ENV_FILE
Logs: journalctl -u $SERVICE_NAME -f
Claude Code client setup:
claude mcp add --transport sse homelable http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp \\
--header "X-API-Key: $API_KEY_VALUE"
----------------------------------------------------------------
EOF