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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
frontend:
|
||||
image: ghcr.io/pouzor/homelable-frontend-standalone:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
+27
-2
@@ -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<HTMLDivElement>(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 <LoginPage />
|
||||
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -321,11 +344,13 @@ export default function App() {
|
||||
title="Edit Link"
|
||||
/>
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanConfigModal
|
||||
open={scanConfigOpen}
|
||||
onClose={() => setScanConfigOpen(false)}
|
||||
onScanNow={() => toast.success('Scan triggered')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -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 */}
|
||||
<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={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
|
||||
@@ -12,13 +12,15 @@ interface StatusMessage {
|
||||
devices_found?: number
|
||||
}
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(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
|
||||
|
||||
+27
-5
@@ -4,29 +4,50 @@ 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
|
||||
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 [ "${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
|
||||
@@ -35,8 +56,9 @@ else
|
||||
echo " - Set SECRET_KEY to a random string"
|
||||
echo " - Change AUTH_PASSWORD_HASH before exposing on a network"
|
||||
echo ""
|
||||
echo " Then run:"
|
||||
echo " Run:"
|
||||
echo " cd ${INSTALL_DIR} && docker compose up -d"
|
||||
fi
|
||||
echo ""
|
||||
echo " Open http://localhost:3000"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user