feat: add standalone (frontend-only) mode via VITE_STANDALONE build flag

- App.tsx: bypass auth gate, swap canvas save/load to localStorage
- Sidebar.tsx: hide scan views and Scan Network button
- useStatusPolling.ts: skip WebSocket connection
- Dockerfile.frontend: accept VITE_STANDALONE build arg
- docker-publish.yml: build ghcr.io/pouzor/homelable-frontend-standalone image
- docker-compose.standalone.yml: frontend-only compose file
- install.sh: add --standalone flag

Usage:
  curl -fsSL .../install.sh | bash -s -- --standalone
  cd homelable && docker compose up -d
This commit is contained in:
Pouzor
2026-03-09 14:00:25 +01:00
parent 1cd93ed250
commit 7ba5e888b4
7 changed files with 91 additions and 24 deletions
+6
View File
@@ -17,8 +17,13 @@ jobs:
include: include:
- image: ghcr.io/pouzor/homelable-backend - image: ghcr.io/pouzor/homelable-backend
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend - image: ghcr.io/pouzor/homelable-frontend
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend-standalone
dockerfile: Dockerfile.frontend
build_args: "VITE_STANDALONE=true"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -56,5 +61,6 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.build_args }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+3
View File
@@ -1,6 +1,9 @@
# Stage 1: build # Stage 1: build
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
WORKDIR /app WORKDIR /app
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
+6
View File
@@ -0,0 +1,6 @@
services:
frontend:
image: ghcr.io/pouzor/homelable-frontend-standalone:latest
restart: unless-stopped
ports:
- "3000:80"
+27 -2
View File
@@ -21,6 +21,9 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore() const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
@@ -37,6 +40,12 @@ export default function App() {
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
try { try {
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges }))
markSaved()
toast.success('Canvas saved')
return
}
const nodesToSave = nodes.map((n) => ({ const nodesToSave = nodes.map((n) => ({
id: n.id, id: n.id,
type: n.data.type, type: n.data.type,
@@ -84,8 +93,22 @@ export default function App() {
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
// Load canvas on auth // Load canvas on auth (or immediately in standalone mode)
useEffect(() => { 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 if (!isAuthenticated) return
canvasApi.load() canvasApi.load()
.then((res) => { .then((res) => {
@@ -253,7 +276,7 @@ export default function App() {
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!isAuthenticated) return <LoginPage /> if (!STANDALONE && !isAuthenticated) return <LoginPage />
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -321,11 +344,13 @@ export default function App() {
title="Edit Link" title="Edit Link"
/> />
{!STANDALONE && (
<ScanConfigModal <ScanConfigModal
open={scanConfigOpen} open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')} onScanNow={() => toast.success('Scan triggered')}
/> />
)}
<Toaster theme="dark" position="bottom-right" /> <Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider> </ReactFlowProvider>
+5 -2
View File
@@ -6,14 +6,17 @@ import { scanApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
const VIEWS = [ const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' }, { id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' }, { id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' }, { id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
] ]
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
interface ScanRun { interface ScanRun {
id: string id: string
@@ -123,7 +126,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border"> <div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} /> <SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} /> {!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem <SidebarItem
icon={Save} icon={Save}
label="Save Canvas" label="Save Canvas"
+3 -1
View File
@@ -12,13 +12,15 @@ interface StatusMessage {
devices_found?: number devices_found?: number
} }
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() { export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { updateNode, notifyScanDeviceFound } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore() const { isAuthenticated, token } = useAuthStore()
useEffect(() => { useEffect(() => {
if (!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.hostname
+25 -3
View File
@@ -4,28 +4,49 @@ set -euo pipefail
REPO="Pouzor/homelable" REPO="Pouzor/homelable"
INSTALL_DIR="${HOMELABLE_DIR:-homelable}" INSTALL_DIR="${HOMELABLE_DIR:-homelable}"
RAW="https://raw.githubusercontent.com/${REPO}/main" 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 # Detect install vs update
if [ -f "${INSTALL_DIR}/docker-compose.yml" ]; then if [ -f "${INSTALL_DIR}/docker-compose.yml" ]; then
echo "Updating Homelable in ./${INSTALL_DIR}/" echo "Updating Homelable in ./${INSTALL_DIR}/"
IS_UPDATE=1 IS_UPDATE=1
else
if [ "${STANDALONE}" -eq 1 ]; then
echo "Installing Homelable (standalone mode) into ./${INSTALL_DIR}/"
else else
echo "Installing Homelable into ./${INSTALL_DIR}/" echo "Installing Homelable into ./${INSTALL_DIR}/"
fi
IS_UPDATE=0 IS_UPDATE=0
fi fi
mkdir -p "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}"
cd "${INSTALL_DIR}" cd "${INSTALL_DIR}"
# Always update docker-compose.yml and refresh .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}/docker-compose.prebuilt.yml" -o docker-compose.yml
curl -fsSL "${RAW}/.env.example" -o .env.example curl -fsSL "${RAW}/.env.example" -o .env.example
fi
if [ "${IS_UPDATE}" -eq 1 ]; then if [ "${IS_UPDATE}" -eq 1 ]; then
echo "" echo ""
echo " docker-compose.yml updated." 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" echo " cd ${INSTALL_DIR} && docker compose pull && docker compose up -d"
else
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 else
if [ ! -f .env ]; then if [ ! -f .env ]; then
cp .env.example .env cp .env.example .env
@@ -35,8 +56,9 @@ else
echo " - Set SECRET_KEY to a random string" echo " - Set SECRET_KEY to a random string"
echo " - Change AUTH_PASSWORD_HASH before exposing on a network" echo " - Change AUTH_PASSWORD_HASH before exposing on a network"
echo "" echo ""
echo " Then run:" echo " Run:"
echo " cd ${INSTALL_DIR} && docker compose up -d" echo " cd ${INSTALL_DIR} && docker compose up -d"
fi
echo "" echo ""
echo " Open http://localhost:3000" echo " Open http://localhost:3000"
fi fi