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:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user