diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dc0582f..600ae67 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -17,8 +17,13 @@ jobs: include: - image: ghcr.io/pouzor/homelable-backend dockerfile: Dockerfile.backend + build_args: "" - image: ghcr.io/pouzor/homelable-frontend dockerfile: Dockerfile.frontend + build_args: "" + - image: ghcr.io/pouzor/homelable-frontend-standalone + dockerfile: Dockerfile.frontend + build_args: "VITE_STANDALONE=true" steps: - uses: actions/checkout@v4 @@ -56,5 +61,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ matrix.build_args }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile.frontend b/Dockerfile.frontend index ed5ddd5..d311a93 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,6 +1,9 @@ # Stage 1: build FROM node:20-alpine AS builder +ARG VITE_STANDALONE=false +ENV VITE_STANDALONE=$VITE_STANDALONE + WORKDIR /app COPY frontend/package*.json ./ RUN npm ci diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml new file mode 100644 index 0000000..fbd4da6 --- /dev/null +++ b/docker-compose.standalone.yml @@ -0,0 +1,6 @@ +services: + frontend: + image: ghcr.io/pouzor/homelable-frontend-standalone:latest + restart: unless-stopped + ports: + - "3000:80" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65f4ddd..e6b5be2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,9 @@ import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData } from '@/types' +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' +const STANDALONE_STORAGE_KEY = 'homelable_canvas' + export default function App() { const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore() const canvasRef = useRef(null) @@ -37,6 +40,12 @@ export default function App() { // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { try { + if (STANDALONE) { + localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges })) + markSaved() + toast.success('Canvas saved') + return + } const nodesToSave = nodes.map((n) => ({ id: n.id, type: n.data.type, @@ -84,8 +93,22 @@ export default function App() { const handleSaveRef = useRef(handleSave) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) - // Load canvas on auth + // Load canvas on auth (or immediately in standalone mode) useEffect(() => { + if (STANDALONE) { + try { + const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) + if (saved) { + const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) + loadCanvas(savedNodes, savedEdges) + } else { + loadCanvas(demoNodes, demoEdges) + } + } catch { + loadCanvas(demoNodes, demoEdges) + } + return + } if (!isAuthenticated) return canvasApi.load() .then((res) => { @@ -253,7 +276,7 @@ export default function App() { const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null - if (!isAuthenticated) return + if (!STANDALONE && !isAuthenticated) return return ( @@ -321,11 +344,13 @@ export default function App() { title="Edit Link" /> - setScanConfigOpen(false)} - onScanNow={() => toast.success('Scan triggered')} - /> + {!STANDALONE && ( + setScanConfigOpen(false)} + onScanNow={() => toast.success('Scan triggered')} + /> + )} diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 3bdffbe..ba98c12 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -6,14 +6,17 @@ import { scanApi } from '@/api/client' import { toast } from 'sonner' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' + type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' -const VIEWS = [ +const ALL_VIEWS = [ { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' }, { id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' }, { id: 'history' as SidebarView, icon: Clock, label: 'Scan History' }, ] +const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS interface ScanRun { id: string @@ -123,7 +126,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr {/* Actions */}
- + {!STANDALONE && } (null) const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { isAuthenticated, token } = useAuthStore() useEffect(() => { - if (!isAuthenticated || !token) return + if (STANDALONE || !isAuthenticated || !token) return const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' const host = window.location.hostname diff --git a/install.sh b/install.sh index 43c95ce..7cbf5ae 100755 --- a/install.sh +++ b/install.sh @@ -4,39 +4,61 @@ set -euo pipefail REPO="Pouzor/homelable" INSTALL_DIR="${HOMELABLE_DIR:-homelable}" RAW="https://raw.githubusercontent.com/${REPO}/main" +STANDALONE=0 + +for arg in "$@"; do + case $arg in + --standalone) STANDALONE=1 ;; + esac +done # Detect install vs update if [ -f "${INSTALL_DIR}/docker-compose.yml" ]; then echo "Updating Homelable in ./${INSTALL_DIR}/" IS_UPDATE=1 else - echo "Installing Homelable into ./${INSTALL_DIR}/" + if [ "${STANDALONE}" -eq 1 ]; then + echo "Installing Homelable (standalone mode) into ./${INSTALL_DIR}/" + else + echo "Installing Homelable into ./${INSTALL_DIR}/" + fi IS_UPDATE=0 fi mkdir -p "${INSTALL_DIR}" cd "${INSTALL_DIR}" -# Always update docker-compose.yml and refresh .env.example -curl -fsSL "${RAW}/docker-compose.prebuilt.yml" -o docker-compose.yml -curl -fsSL "${RAW}/.env.example" -o .env.example +if [ "${STANDALONE}" -eq 1 ]; then + curl -fsSL "${RAW}/docker-compose.standalone.yml" -o docker-compose.yml +else + curl -fsSL "${RAW}/docker-compose.prebuilt.yml" -o docker-compose.yml + curl -fsSL "${RAW}/.env.example" -o .env.example +fi if [ "${IS_UPDATE}" -eq 1 ]; then echo "" echo " docker-compose.yml updated." - echo " Check .env.example for any new variables, then restart:" + echo " Restart with:" echo " cd ${INSTALL_DIR} && docker compose pull && docker compose up -d" else - if [ ! -f .env ]; then - cp .env.example .env + if [ "${STANDALONE}" -eq 1 ]; then + echo "" + echo " Standalone mode: no backend, no login, canvas saves to browser localStorage." + echo "" + echo " Run:" + echo " cd ${INSTALL_DIR} && docker compose up -d" + else + if [ ! -f .env ]; then + cp .env.example .env + fi + echo "" + echo " Edit .env if needed (default login: admin / admin):" + echo " - Set SECRET_KEY to a random string" + echo " - Change AUTH_PASSWORD_HASH before exposing on a network" + echo "" + echo " Run:" + echo " cd ${INSTALL_DIR} && docker compose up -d" fi echo "" - echo " Edit .env if needed (default login: admin / admin):" - echo " - Set SECRET_KEY to a random string" - echo " - Change AUTH_PASSWORD_HASH before exposing on a network" - echo "" - echo " Then run:" - echo " cd ${INSTALL_DIR} && docker compose up -d" - echo "" echo " Open http://localhost:3000" fi