Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c7f25a30 | |||
| 8bd1c48976 | |||
| 05c98355a6 | |||
| 323dea6798 | |||
| 19cb4b71f5 | |||
| fd86c0f6ad | |||
| 00d44abfad | |||
| 3bd18ab543 | |||
| 4d8bb246f1 | |||
| 07da498d18 |
+5
-29
@@ -53,37 +53,13 @@ docker compose up -d
|
|||||||
|
|
||||||
## Proxmox LXC Install
|
## Proxmox LXC Install
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
You can now install Homelable with community-scripts (proxmox-VE) :
|
||||||
|
|
||||||
|
`https://community-scripts.org/scripts/homelable`
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
|
||||||
```
|
|
||||||
|
|
||||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
|
||||||
|
|
||||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
|
||||||
> ```bash
|
|
||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### Update (LXC)
|
|
||||||
|
|
||||||
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash /opt/homelable/scripts/update.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly from GitHub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.8.1",
|
version="1.8.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -57,8 +58,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
else:
|
||||||
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ping", "-c", "1", "-W", "1", host,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _tcp_connect, check_node
|
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
|
|||||||
assert result["response_time_ms"] is None
|
assert result["response_time_ms"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping platform args ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_unix_args_on_non_windows():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
assert "-n" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_windows_args_on_win32():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-n" in captured["args"]
|
||||||
|
assert "-w" in captured["args"]
|
||||||
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Generated
+790
-875
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.1",
|
"version": "1.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.7.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
@@ -454,7 +454,11 @@ export default function App() {
|
|||||||
<ScanConfigModal
|
<ScanConfigModal
|
||||||
open={scanConfigOpen}
|
open={scanConfigOpen}
|
||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => toast.success('Scan triggered')}
|
onScanNow={() => {
|
||||||
|
toast.success('Network scan started — check Scan History for results')
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
multiSelectionKeyCode={['Meta', 'Control']}
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[8, 8]}
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
|||||||
@@ -142,9 +142,9 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.snapToGrid).toBe(true)
|
expect(rfProps.snapToGrid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets snapGrid to [16, 16]', () => {
|
it('sets snapGrid to [8, 8]', () => {
|
||||||
render(<CanvasContainer />)
|
render(<CanvasContainer />)
|
||||||
expect(rfProps.snapGrid).toEqual([16, 16])
|
expect(rfProps.snapGrid).toEqual([8, 8])
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Delete key ────────────────────────────────────────────────────────────
|
// ── Delete key ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -24,29 +24,22 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
|||||||
.catch(() => {/* use defaults */})
|
.catch(() => {/* use defaults */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleScanNow = async () => {
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await scanApi.saveConfig({ ranges: cleaned })
|
await scanApi.saveConfig({ ranges: cleaned })
|
||||||
toast.success('Scan config saved')
|
await scanApi.trigger()
|
||||||
|
onScanNow()
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save config')
|
toast.error('Failed to start scan')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanNow = async () => {
|
|
||||||
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
|
|
||||||
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
|
|
||||||
await handleSave()
|
|
||||||
onScanNow()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
scanApi: {
|
scanApi: {
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
saveConfig: vi.fn(),
|
saveConfig: vi.fn(),
|
||||||
|
trigger: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
@@ -20,6 +21,8 @@ describe('ScanConfigModal', () => {
|
|||||||
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||||
vi.mocked(scanApi.saveConfig).mockReset()
|
vi.mocked(scanApi.saveConfig).mockReset()
|
||||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(scanApi.trigger).mockReset()
|
||||||
|
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
|
||||||
vi.mocked(toast.success).mockReset()
|
vi.mocked(toast.success).mockReset()
|
||||||
vi.mocked(toast.error).mockReset()
|
vi.mocked(toast.error).mockReset()
|
||||||
})
|
})
|
||||||
@@ -72,7 +75,7 @@ describe('ScanConfigModal', () => {
|
|||||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
|
||||||
const onScanNow = vi.fn()
|
const onScanNow = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||||
@@ -80,7 +83,9 @@ describe('ScanConfigModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||||
|
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||||
expect(onScanNow).toHaveBeenCalledOnce()
|
expect(onScanNow).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
@@ -53,15 +54,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(() => {
|
||||||
try {
|
|
||||||
await scanApi.trigger()
|
|
||||||
toast.success('Network scan started — check Scan History for results')
|
|
||||||
setActiveView('history')
|
|
||||||
onScan()
|
onScan()
|
||||||
} catch {
|
|
||||||
toast.error('Failed to trigger scan')
|
|
||||||
}
|
|
||||||
}, [onScan])
|
}, [onScan])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +153,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && <VersionBadge />}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -555,6 +551,34 @@ function SettingsPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VersionBadge() {
|
||||||
|
const current = __APP_VERSION__
|
||||||
|
const { latest, hasUpdate } = useLatestRelease(current)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
|
||||||
|
<a
|
||||||
|
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
v{current}
|
||||||
|
</a>
|
||||||
|
{hasUpdate && latest && (
|
||||||
|
<a
|
||||||
|
href={latest.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||||
|
>
|
||||||
|
↑ v{latest.version} available
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||||
|
|||||||
@@ -222,20 +222,12 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => {
|
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||||
const { scanApi } = await import('@/api/client')
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce())
|
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to Scan History view after scan is triggered', async () => {
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
|
||||||
await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { Sidebar } from '../Sidebar'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
|
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
|
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||||
|
},
|
||||||
|
settingsApi: {
|
||||||
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
|
save: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipContent: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
|
||||||
|
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useLatestRelease', () => ({
|
||||||
|
useLatestRelease: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
hideIp: false,
|
||||||
|
toggleHideIp: vi.fn(),
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<Sidebar
|
||||||
|
onAddNode={vi.fn()}
|
||||||
|
onAddGroupRect={vi.fn()}
|
||||||
|
onScan={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onNodeApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('VersionBadge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the current app version', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links current version to its GitHub release page', () => {
|
||||||
|
renderSidebar()
|
||||||
|
const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
|
||||||
|
expect(link).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
|
||||||
|
)
|
||||||
|
expect(link).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update badge when on latest version', () => {
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows update badge when a newer version is available', async () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||||
|
hasUpdate: true,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('update badge links to the latest release URL', async () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||||
|
hasUpdate: true,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
await waitFor(() => {
|
||||||
|
const badge = screen.getByText('↑ v9.9.9 available').closest('a')
|
||||||
|
expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
|
||||||
|
expect(badge).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update badge when hasUpdate is false even if latest exists', () => {
|
||||||
|
vi.mocked(useLatestRelease).mockReturnValue({
|
||||||
|
latest: { version: __APP_VERSION__, url: 'https://github.com' },
|
||||||
|
hasUpdate: false,
|
||||||
|
})
|
||||||
|
renderSidebar()
|
||||||
|
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
|
// Reset module between tests so the module-level cache is cleared
|
||||||
|
async function freshHook() {
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import('../useLatestRelease')
|
||||||
|
return mod.useLatestRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT = '1.8.3'
|
||||||
|
|
||||||
|
function mockFetch(payload: unknown, ok = true) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLatestRelease', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no update when latest version matches current', async () => {
|
||||||
|
mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns update when latest version is newer', async () => {
|
||||||
|
mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.hasUpdate).toBe(true))
|
||||||
|
expect(result.current.latest?.version).toBe('1.9.0')
|
||||||
|
expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leading v from tag_name', async () => {
|
||||||
|
mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||||
|
expect(result.current.latest?.version).toBe('2.0.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns non-ok response', async () => {
|
||||||
|
mockFetch({ message: 'Not Found' }, false)
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
expect(result.current.latest).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns missing tag_name', async () => {
|
||||||
|
mockFetch({ html_url: 'https://github.com/example' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when API returns missing html_url', async () => {
|
||||||
|
mockFetch({ tag_name: 'v2.0.0' })
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show update when fetch throws', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
expect(result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches only once when hook is mounted multiple times concurrently', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const useLatestRelease = await freshHook()
|
||||||
|
// Mount all three before the fetch resolves — cache is set to 'pending' after first mount
|
||||||
|
const a = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
const b = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
const c = renderHook(() => useLatestRelease(CURRENT))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(a.result.current.latest).not.toBeNull()
|
||||||
|
})
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
// All hooks see the same result once cache resolves
|
||||||
|
expect(b.result.current.hasUpdate).toBe(false)
|
||||||
|
expect(c.result.current.hasUpdate).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface ReleaseInfo {
|
||||||
|
version: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: ReleaseInfo | null | 'error' | 'pending' = null
|
||||||
|
|
||||||
|
export function useLatestRelease(currentVersion: string) {
|
||||||
|
const [latest, setLatest] = useState<ReleaseInfo | null>(
|
||||||
|
cache && cache !== 'error' && cache !== 'pending' ? cache : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cache !== null) return
|
||||||
|
cache = 'pending'
|
||||||
|
fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
|
||||||
|
headers: { Accept: 'application/vnd.github+json' },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) { cache = 'error'; return }
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
|
||||||
|
cache = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const version = data.tag_name.replace(/^v/, '')
|
||||||
|
const info: ReleaseInfo = { version, url: data.html_url }
|
||||||
|
cache = info
|
||||||
|
setLatest(info)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cache = 'error'
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasUpdate = latest !== null && latest.version !== currentVersion
|
||||||
|
return { latest, hasUpdate }
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -2,8 +2,12 @@ import path from 'path'
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — Proxmox VE LXC creator
|
|
||||||
# Run this on the Proxmox HOST (not inside a container):
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
step() { echo -e "\n${CYAN}▶ $*${NC}"; }
|
|
||||||
|
|
||||||
# ── Must run on a Proxmox VE host ─────────────────────────────────────────────
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root on the Proxmox host"
|
|
||||||
command -v pct &>/dev/null || error "pct not found — run this on a Proxmox VE host, not inside a container"
|
|
||||||
|
|
||||||
# ── Detect available storages for LXC rootfs ──────────────────────────────────
|
|
||||||
mapfile -t STORAGES < <(pvesm status --content rootdir 2>/dev/null | awk 'NR>1 && $3=="active" {print $1}')
|
|
||||||
[[ ${#STORAGES[@]} -eq 0 ]] && error "No active storage found that supports LXC rootfs (rootdir content type)"
|
|
||||||
|
|
||||||
if [[ ${#STORAGES[@]} -eq 1 ]]; then
|
|
||||||
DEFAULT_STORAGE="${STORAGES[0]}"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "Available storages:"
|
|
||||||
for i in "${!STORAGES[@]}"; do
|
|
||||||
echo " $((i+1))) ${STORAGES[$i]}"
|
|
||||||
done
|
|
||||||
read -rp "Select storage [1]: " STORAGE_IDX
|
|
||||||
STORAGE_IDX="${STORAGE_IDX:-1}"
|
|
||||||
DEFAULT_STORAGE="${STORAGES[$((STORAGE_IDX-1))]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Settings (override via env vars) ──────────────────────────────────────────
|
|
||||||
CT_HOSTNAME="${CT_HOSTNAME:-homelable}"
|
|
||||||
STORAGE="${STORAGE:-$DEFAULT_STORAGE}"
|
|
||||||
DISK_SIZE="${DISK_SIZE:-8}" # GB
|
|
||||||
RAM="${RAM:-1024}" # MB
|
|
||||||
CORES="${CORES:-2}"
|
|
||||||
BRIDGE="${BRIDGE:-vmbr0}"
|
|
||||||
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
|
|
||||||
|
|
||||||
# ── Interactive prompts ────────────────────────────────────────────────────────
|
|
||||||
DEFAULT_CTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 200)"
|
|
||||||
|
|
||||||
if [[ -z "${CTID:-}" ]]; then
|
|
||||||
read -rp "Container ID [${DEFAULT_CTID}]: " CTID_INPUT
|
|
||||||
CTID="${CTID_INPUT:-$DEFAULT_CTID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${ROOT_PASSWORD:-}" ]]; then
|
|
||||||
while true; do
|
|
||||||
read -rsp "Root password for LXC container: " ROOT_PASSWORD
|
|
||||||
echo ""
|
|
||||||
[[ -z "$ROOT_PASSWORD" ]] && warn "Password cannot be empty, try again." && continue
|
|
||||||
read -rsp "Confirm root password: " ROOT_PASSWORD_CONFIRM
|
|
||||||
echo ""
|
|
||||||
[[ "$ROOT_PASSWORD" == "$ROOT_PASSWORD_CONFIRM" ]] && break
|
|
||||||
warn "Passwords do not match, try again."
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
step "Creating Homelable LXC (CTID=$CTID, hostname=$CT_HOSTNAME, storage=$STORAGE)"
|
|
||||||
|
|
||||||
# ── Download Debian 12 template if needed ─────────────────────────────────────
|
|
||||||
TEMPLATE_STORAGE=$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')
|
|
||||||
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep "debian-12" | tail -1 | awk '{print $1}')
|
|
||||||
|
|
||||||
if [[ -z "$TEMPLATE" ]]; then
|
|
||||||
info "Downloading Debian 12 LXC template..."
|
|
||||||
pveam update
|
|
||||||
TEMPLATE_NAME=$(pveam available --section system | grep "debian-12" | tail -1 | awk '{print $2}')
|
|
||||||
[[ -z "$TEMPLATE_NAME" ]] && error "Could not find a Debian 12 template"
|
|
||||||
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE_NAME"
|
|
||||||
TEMPLATE="$TEMPLATE_STORAGE:vztmpl/$TEMPLATE_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Using template: $TEMPLATE"
|
|
||||||
|
|
||||||
# ── Create the container ───────────────────────────────────────────────────────
|
|
||||||
pct create "$CTID" "$TEMPLATE" \
|
|
||||||
--hostname "$CT_HOSTNAME" \
|
|
||||||
--storage "$STORAGE" \
|
|
||||||
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
|
||||||
--memory "$RAM" \
|
|
||||||
--cores "$CORES" \
|
|
||||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
|
|
||||||
--ostype debian \
|
|
||||||
--unprivileged 1 \
|
|
||||||
--features "nesting=1" \
|
|
||||||
--password "$ROOT_PASSWORD" \
|
|
||||||
--start 1
|
|
||||||
|
|
||||||
info "Container $CTID created and started"
|
|
||||||
|
|
||||||
# ── Wait for container to be ready ────────────────────────────────────────────
|
|
||||||
info "Waiting for container to be ready..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if pct exec "$CTID" -- test -x /usr/bin/apt-get &>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
[[ $i -eq 30 ]] && error "Container did not become ready after 30s"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait a bit more for network (DHCP lease)
|
|
||||||
info "Waiting for network (DHCP)..."
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
if pct exec "$CTID" -- sh -c "ip route | grep -q default" &>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
[[ $i -eq 20 ]] && error "Container has no default route after 20s — check bridge $BRIDGE"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
|
|
||||||
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Bootstrap curl then run the installer ─────────────────────────────────────
|
|
||||||
step "Running Homelable installer inside container $CTID..."
|
|
||||||
pct exec "$CTID" -- apt-get install -y -qq curl
|
|
||||||
pct exec "$CTID" -- bash -c "curl -fsSL ${RAW}/scripts/lxc-install.sh | bash"
|
|
||||||
|
|
||||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
|
||||||
IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "<container-ip>")
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}✓ Homelable installed in LXC $CTID${NC}"
|
|
||||||
echo -e " ${GREEN}✓ Open http://${IP}${NC}"
|
|
||||||
echo -e " Homelable login: ${YELLOW}admin / admin${NC}"
|
|
||||||
echo -e " LXC root SSH: ${YELLOW}root / <password you set>${NC}"
|
|
||||||
echo -e " ${YELLOW}⚠ Change the Homelable password after first login${NC}"
|
|
||||||
echo -e " ${YELLOW} - edit /opt/homelable/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
|
||||||
echo ""
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — in-container installer
|
|
||||||
# Runs INSIDE a Debian/Ubuntu LXC container (called automatically by install-proxmox.sh)
|
|
||||||
# Can also be run manually inside any Debian/Ubuntu machine:
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR=/opt/homelable
|
|
||||||
DATA_DIR=/opt/homelable/data
|
|
||||||
SERVICE_USER=homelable
|
|
||||||
REPO_URL="https://github.com/Pouzor/homelable.git"
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
|
||||||
|
|
||||||
# ── Detect OS ─────────────────────────────────────────────────────────────────
|
|
||||||
if [[ -f /etc/os-release ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. /etc/os-release
|
|
||||||
else
|
|
||||||
error "Cannot detect OS"
|
|
||||||
fi
|
|
||||||
info "Detected: $PRETTY_NAME"
|
|
||||||
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
|
|
||||||
|
|
||||||
# ── System deps ───────────────────────────────────────────────────────────────
|
|
||||||
info "Installing system dependencies..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
|
|
||||||
|
|
||||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
|
||||||
if ! command -v node &>/dev/null; then
|
|
||||||
info "Installing Node.js 20..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
||||||
apt-get install -y -qq nodejs
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Service user ──────────────────────────────────────────────────────────────
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
|
||||||
info "Created service user: $SERVICE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Clone / update repo ───────────────────────────────────────────────────────
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
info "Updating existing installation..."
|
|
||||||
git -C "$INSTALL_DIR" pull --quiet
|
|
||||||
else
|
|
||||||
info "Cloning repository..."
|
|
||||||
git clone --quiet "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR"
|
|
||||||
|
|
||||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
|
||||||
info "Setting up Python backend..."
|
|
||||||
cd "$INSTALL_DIR/backend"
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --quiet -r requirements.txt
|
|
||||||
|
|
||||||
# Generate .env if missing
|
|
||||||
if [[ ! -f .env ]]; then
|
|
||||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
|
||||||
# Default hash = bcrypt of "admin" (same as .env.example)
|
|
||||||
cat > .env <<EOF
|
|
||||||
SECRET_KEY=$SECRET
|
|
||||||
SQLITE_PATH=$DATA_DIR/homelab.db
|
|
||||||
CORS_ORIGINS=["http://localhost","http://$(hostname -I | awk '{print $1}')"]
|
|
||||||
|
|
||||||
# Auth — default credentials: admin / admin
|
|
||||||
# Change AUTH_PASSWORD_HASH before exposing on a network.
|
|
||||||
# Generate: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
|
||||||
AUTH_USERNAME=admin
|
|
||||||
AUTH_PASSWORD_HASH='\$2b\$12\$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
|
||||||
|
|
||||||
SCANNER_RANGES=["192.168.1.0/24"]
|
|
||||||
STATUS_CHECKER_INTERVAL=60
|
|
||||||
EOF
|
|
||||||
warn "Created .env with default admin/admin — change AUTH_PASSWORD_HASH before exposing on a network!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR"
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR/backend/.venv"
|
|
||||||
|
|
||||||
# ── systemd: backend ──────────────────────────────────────────────────────────
|
|
||||||
cat > /etc/systemd/system/homelable-backend.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Homelable Backend
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$SERVICE_USER
|
|
||||||
WorkingDirectory=$INSTALL_DIR/backend
|
|
||||||
EnvironmentFile=$INSTALL_DIR/backend/.env
|
|
||||||
ExecStart=$INSTALL_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Frontend ──────────────────────────────────────────────────────────────────
|
|
||||||
info "Building frontend..."
|
|
||||||
cd "$INSTALL_DIR/frontend"
|
|
||||||
npm ci --silent
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# ── nginx ─────────────────────────────────────────────────────────────────────
|
|
||||||
info "Configuring nginx..."
|
|
||||||
# Use the project nginx config, adjusted for local backend
|
|
||||||
sed \
|
|
||||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
|
||||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
|
||||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
|
||||||
|
|
||||||
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
nginx -t
|
|
||||||
systemctl reload nginx || systemctl start nginx
|
|
||||||
|
|
||||||
# ── Enable & start ────────────────────────────────────────────────────────────
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now homelable-backend
|
|
||||||
systemctl enable --now nginx
|
|
||||||
|
|
||||||
info "Done!"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}Homelable is running at http://$(hostname -I | awk '{print $1}')${NC}"
|
|
||||||
echo -e " Default login: admin / admin"
|
|
||||||
echo -e " ${YELLOW}⚠ Change the password: edit $INSTALL_DIR/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
|
||||||
echo ""
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Homelable — update to latest version
|
|
||||||
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
|
||||||
# bash /opt/homelable/scripts/update.sh
|
|
||||||
# Or pull-and-run directly:
|
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR=/opt/homelable
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
||||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
|
||||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
|
||||||
|
|
||||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
|
||||||
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
|
||||||
|
|
||||||
# ── Pull latest code ──────────────────────────────────────────────────────────
|
|
||||||
info "Pulling latest code..."
|
|
||||||
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
|
||||||
git -C "$INSTALL_DIR" pull --quiet
|
|
||||||
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
|
||||||
|
|
||||||
if [[ "$BEFORE" == "$AFTER" ]]; then
|
|
||||||
info "Already up to date."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
info "Changes since last update:"
|
|
||||||
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Stop backend ─────────────────────────────────────────────────────────────
|
|
||||||
info "Stopping backend service..."
|
|
||||||
systemctl stop homelable-backend
|
|
||||||
|
|
||||||
# ── Backend deps ─────────────────────────────────────────────────────────────
|
|
||||||
info "Updating Python dependencies..."
|
|
||||||
cd "$INSTALL_DIR/backend"
|
|
||||||
.venv/bin/pip install --quiet -r requirements.txt
|
|
||||||
|
|
||||||
# ── Frontend build ────────────────────────────────────────────────────────────
|
|
||||||
info "Rebuilding frontend..."
|
|
||||||
cd "$INSTALL_DIR/frontend"
|
|
||||||
npm ci --silent
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# ── nginx config ─────────────────────────────────────────────────────────────
|
|
||||||
info "Updating nginx config..."
|
|
||||||
sed \
|
|
||||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
|
||||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
|
||||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
|
|
||||||
# ── Restart backend ───────────────────────────────────────────────────────────
|
|
||||||
info "Starting backend service..."
|
|
||||||
systemctl start homelable-backend
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
|
||||||
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user