refactor: replace config.yml with .env for all configuration

All settings (auth credentials, scanner ranges, status_checker interval)
now live in a single .env file via pydantic-settings. config.yml and
config.yml.example are deleted.

- Settings: add auth_username, auth_password_hash, scanner_ranges,
  status_checker_interval; add load_overrides()/save_overrides() for
  persisting runtime changes to data/scan_config.json
- auth.py: read credentials directly from settings
- scan.py: read ranges/interval from settings; write-back via save_overrides()
- scheduler.py: read interval directly from settings
- main.py: call settings.load_overrides() at startup
- docker-compose.yml: remove config.yml volume mount, add new env vars
- conftest.py: set settings fields directly instead of writing a temp config.yml
This commit is contained in:
Pouzor
2026-03-09 11:26:49 +01:00
parent acc5bc6a64
commit a56204a5f2
18 changed files with 117 additions and 212 deletions
-3
View File
@@ -41,9 +41,6 @@ build/
htmlcov/ htmlcov/
.coverage .coverage
# App config — contains credentials, never commit
backend/config.yml
# SQLite # SQLite
*.db *.db
*.db-shm *.db-shm
+24 -54
View File
@@ -1,32 +1,18 @@
# Homelable # Homelable
A self-hosted, open-source tool to visually map, document and monitor your homelab infrastructure. Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
Interactive network canvas where each node is a physical machine, VM, LXC container, switch, or device. Nodes show live status, IPs, hostnames, and running services. Edges represent network links. Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
---
## Features
- **Interactive canvas** — drag, zoom, pan, snap-to-grid (React Flow)
- **11 node types** — ISP, router, switch, server, Proxmox, VM, LXC, NAS, IoT, AP, generic
- **Proxmox nested nodes** — VM/LXC rendered inside a resizable Proxmox group container
- **5 edge types** — ethernet, Wi-Fi, IoT, VLAN (color-coded), virtual
- **Live status** — per-node checks via ping / HTTP / HTTPS / SSH / TCP / Prometheus
- **Network scanner** — nmap-based discovery, approve/hide/ignore new devices
- **Auto-layout** — one-click Dagre hierarchical arrangement
- **Export** — download canvas as PNG
- **Dark theme** — neon accent colors, JetBrains Mono for technical values
- **Self-contained** — SQLite database, single config file, no cloud dependency
--- ---
## Quick Start — Docker ## Quick Start — Docker
```bash ```bash
git clone https://github.com/you/homelable.git git clone https://github.com/Pouzor/homelable.git
cd homelable cd homelable
cp .env.example .env
docker compose up -d docker compose up -d
``` ```
@@ -38,34 +24,12 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
--- ---
## Quick Start — Development
**Backend (Python 3.13):**
```bash
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # edit SECRET_KEY
uvicorn app.main:app --reload --port 8000
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Default login: `admin` / `admin`
---
## Proxmox LXC Install ## Proxmox LXC Install
Run inside a Debian/Ubuntu LXC container: Run inside a Debian/Ubuntu LXC container:
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh) bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
``` ```
This installs the backend as a systemd service and serves the frontend via nginx. This installs the backend as a systemd service and serves the frontend via nginx.
@@ -158,19 +122,25 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
--- ---
## Development
## Development Mode
**Backend (Python 3.13):**
```bash ```bash
# Backend tests cd backend
cd backend && source .venv/bin/activate python3.13 -m venv .venv && source .venv/bin/activate
pytest # 40 tests pip install -r requirements.txt
cp .env.example .env # edit SECRET_KEY
# Backend lint uvicorn app.main:app --reload --port 8000
ruff check .
# Frontend tests
cd frontend && npm test
# Frontend lint + typecheck
npm run lint && npm run typecheck
``` ```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Default login: `admin` / `admin`
---
+11 -1
View File
@@ -1,4 +1,14 @@
SECRET_KEY=<generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"> SECRET_KEY=<generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))">
SQLITE_PATH=./data/homelab.db SQLITE_PATH=./data/homelab.db
CONFIG_PATH=./config.yml
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — set a strong password hash (generate with passlib):
# python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH=
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# Status checker interval in seconds
STATUS_CHECKER_INTERVAL=60
+2 -11
View File
@@ -1,6 +1,5 @@
import hmac import hmac
import yaml
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
@@ -20,20 +19,12 @@ class TokenResponse(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
def _load_credentials() -> tuple[str, str]:
with open(settings.config_path) as f:
cfg = yaml.safe_load(f)
auth = cfg.get("auth", {})
return auth.get("username", "admin"), auth.get("password_hash", "")
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest) -> TokenResponse: async def login(body: LoginRequest) -> TokenResponse:
username, password_hash = _load_credentials()
# Always run both checks to prevent timing-based username enumeration. # Always run both checks to prevent timing-based username enumeration.
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs. # hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
username_ok = hmac.compare_digest(body.username, username) username_ok = hmac.compare_digest(body.username, settings.auth_username)
password_ok = verify_password(body.password, password_hash) password_ok = verify_password(body.password, settings.auth_password_hash)
if not username_ok or not password_ok: if not username_ok or not password_ok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username) token = create_access_token(body.username)
+9 -25
View File
@@ -1,7 +1,6 @@
import logging import logging
from typing import Any from typing import Any
import yaml
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@@ -20,19 +19,11 @@ class ScanConfig(BaseModel):
ranges: list[str] ranges: list[str]
interval_seconds: int interval_seconds: int
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _load_ranges() -> list[str]:
try:
with open(settings.config_path) as f:
cfg: dict[str, Any] = yaml.safe_load(f) or {}
return list(cfg.get("scanner", {}).get("ranges", []))
except Exception:
return []
async def _background_scan(run_id: str, ranges: list[str]) -> None: async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
await run_scan(ranges, db, run_id) await run_scan(ranges, db, run_id)
@@ -44,7 +35,7 @@ async def trigger_scan(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> ScanRun: ) -> ScanRun:
ranges = _load_ranges() ranges = settings.scanner_ranges
run = ScanRun(status="running", ranges=ranges) run = ScanRun(status="running", ranges=ranges)
db.add(run) db.add(run)
await db.commit() await db.commit()
@@ -112,25 +103,18 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig) @router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
try: return ScanConfig(
with open(settings.config_path) as f: ranges=settings.scanner_ranges,
cfg = yaml.safe_load(f) interval_seconds=settings.status_checker_interval,
ranges = cfg.get("scanner", {}).get("ranges", []) )
interval = int(cfg.get("status_checker", {}).get("interval_seconds", 60))
return ScanConfig(ranges=ranges, interval_seconds=interval)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/config", response_model=ScanConfig) @router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
try: try:
with open(settings.config_path) as f: settings.scanner_ranges = payload.ranges
cfg = yaml.safe_load(f) or {} settings.status_checker_interval = payload.interval_seconds
cfg.setdefault("scanner", {})["ranges"] = payload.ranges settings.save_overrides()
cfg.setdefault("status_checker", {})["interval_seconds"] = payload.interval_seconds
with open(settings.config_path, "w") as f:
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True)
return payload return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
+36 -2
View File
@@ -1,3 +1,6 @@
import json
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -6,12 +9,43 @@ class Settings(BaseSettings):
secret_key: str # Required — set SECRET_KEY in .env secret_key: str # Required — set SECRET_KEY in .env
sqlite_path: str = "./data/homelab.db" sqlite_path: str = "./data/homelab.db"
config_path: str = "./config.yml"
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT # JWT
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # 24h access_token_expire_minutes: int = 1440 # 24h
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
auth_username: str = "admin"
auth_password_hash: str = ""
settings = Settings() # type: ignore[call-arg] # pydantic-settings loads secret_key from env # Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
# Status checker
status_checker_interval: int = 60
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
def load_overrides(self) -> None:
"""Load runtime scan config overrides written by the API."""
try:
data = json.loads(self._override_path().read_text())
if "scanner_ranges" in data:
self.scanner_ranges = data["scanner_ranges"]
if "status_checker_interval" in data:
self.status_checker_interval = int(data["status_checker_interval"])
except Exception:
pass
def save_overrides(self) -> None:
"""Persist scan config so it survives container restarts."""
self._override_path().parent.mkdir(parents=True, exist_ok=True)
self._override_path().write_text(json.dumps({
"scanner_ranges": self.scanner_ranges,
"status_checker_interval": self.status_checker_interval,
}))
settings = Settings() # type: ignore[call-arg]
+2 -13
View File
@@ -2,7 +2,6 @@
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
import yaml
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select from sqlalchemy import select
@@ -46,22 +45,12 @@ async def _run_status_checks() -> None:
logger.error("Status check failed for node %s: %s", node.id, exc) logger.error("Status check failed for node %s: %s", node.id, exc)
def _load_interval() -> int:
try:
with open(settings.config_path) as f:
cfg = yaml.safe_load(f)
return int(cfg.get("status_checker", {}).get("interval_seconds", 60))
except Exception:
return 60
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
interval = _load_interval() scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
scheduler.add_job(_run_status_checks, "interval", seconds=interval, id="status_checks")
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def stop_scheduler() -> None: def stop_scheduler() -> None:
+1
View File
@@ -14,6 +14,7 @@ from app.db.database import init_db
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await init_db() await init_db()
settings.load_overrides()
start_scheduler() start_scheduler()
yield yield
stop_scheduler() stop_scheduler()
-9
View File
@@ -1,9 +0,0 @@
auth:
username: admin
password_hash: "" # Generate with: python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
scanner:
interval: null
ranges:
- 192.168.1.0/24
status_checker:
interval_seconds: 60
+4 -14
View File
@@ -4,7 +4,6 @@ import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production") os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest import pytest
import yaml
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@@ -18,20 +17,11 @@ _pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
@pytest.fixture(autouse=True, scope="session") @pytest.fixture(autouse=True, scope="session")
def test_config_file(tmp_path_factory): def test_credentials():
"""Write a minimal config.yml for the test session and point settings at it.""" """Configure test auth credentials directly on settings."""
cfg = {
"auth": {
"username": "admin",
"password_hash": _pwd_ctx.hash("admin"),
},
"scanner": {"ranges": []},
"status_checker": {"interval_seconds": 3600},
}
cfg_path = tmp_path_factory.mktemp("cfg") / "config.yml"
cfg_path.write_text(yaml.dump(cfg))
from app.core.config import settings from app.core.config import settings
settings.config_path = str(cfg_path) settings.auth_username = "admin"
settings.auth_password_hash = _pwd_ctx.hash("admin")
@pytest.fixture @pytest.fixture
+3 -12
View File
@@ -1,16 +1,7 @@
from unittest.mock import patch
import pytest
from httpx import AsyncClient from httpx import AsyncClient
@pytest.fixture async def test_login_success(client: AsyncClient):
def mock_credentials():
with patch("app.api.routes.auth._load_credentials", return_value=("admin", "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS")):
yield
async def test_login_success(client: AsyncClient, mock_credentials):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}) res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 200 assert res.status_code == 200
data = res.json() data = res.json()
@@ -18,12 +9,12 @@ async def test_login_success(client: AsyncClient, mock_credentials):
assert data["token_type"] == "bearer" assert data["token_type"] == "bearer"
async def test_login_wrong_password(client: AsyncClient, mock_credentials): async def test_login_wrong_password(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"}) res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401 assert res.status_code == 401
async def test_login_wrong_username(client: AsyncClient, mock_credentials): async def test_login_wrong_username(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"}) res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"})
assert res.status_code == 401 assert res.status_code == 401
+1 -5
View File
@@ -1,16 +1,12 @@
import uuid import uuid
from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture @pytest.fixture
async def headers(client: AsyncClient): async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)): res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"} return {"Authorization": f"Bearer {res.json()['access_token']}"}
+1 -6
View File
@@ -1,15 +1,10 @@
from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture @pytest.fixture
async def headers(client: AsyncClient): async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)): res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"] token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
+1 -6
View File
@@ -1,15 +1,10 @@
from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
@pytest.fixture @pytest.fixture
async def headers(client: AsyncClient): async def headers(client: AsyncClient):
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)): res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"] token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
+2 -1
View File
@@ -49,8 +49,9 @@ async def test_trigger_scan_requires_auth(client: AsyncClient):
async def test_trigger_scan_creates_run(client: AsyncClient, headers): async def test_trigger_scan_creates_run(client: AsyncClient, headers):
with ( with (
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock), patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]), patch("app.api.routes.scan.settings") as mock_settings,
): ):
mock_settings.scanner_ranges = ["192.168.1.0/24"]
res = await client.post("/api/v1/scan/trigger", headers=headers) res = await client.post("/api/v1/scan/trigger", headers=headers)
assert res.status_code == 200 assert res.status_code == 200
data = res.json() data = res.json()
+14 -46
View File
@@ -1,11 +1,11 @@
"""Tests for background scheduler: _load_interval, _run_status_checks, lifecycle.""" """Tests for background scheduler: _run_status_checks, lifecycle."""
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import _load_interval, _run_status_checks, start_scheduler, stop_scheduler from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
from app.db.database import Base from app.db.database import Base
from app.db.models import Node from app.db.models import Node
@@ -25,35 +25,6 @@ def _make_node(**kwargs) -> Node:
return Node(**{**defaults, **kwargs}) return Node(**{**defaults, **kwargs})
# ---------------------------------------------------------------------------
# _load_interval
# ---------------------------------------------------------------------------
def test_load_interval_reads_from_config(tmp_path):
"""_load_interval returns the value set in config.yml."""
cfg = tmp_path / "config.yml"
cfg.write_text("status_checker:\n interval_seconds: 30\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 30
def test_load_interval_defaults_to_60_when_key_missing(tmp_path):
"""_load_interval returns 60 when status_checker section is absent."""
cfg = tmp_path / "config.yml"
cfg.write_text("auth:\n username: admin\n")
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = str(cfg)
assert _load_interval() == 60
def test_load_interval_defaults_to_60_on_missing_file():
"""_load_interval returns 60 when config file does not exist."""
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.config_path = "/nonexistent/path/config.yml"
assert _load_interval() == 60
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _run_status_checks # _run_status_checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -164,26 +135,23 @@ async def test_run_status_checks_handles_check_error_gracefully(mem_db):
# start_scheduler / stop_scheduler # start_scheduler / stop_scheduler
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_scheduler_uses_settings_interval():
"""Scheduler registers the job with the interval from settings."""
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
def test_start_and_stop_scheduler(): def test_start_and_stop_scheduler():
"""Scheduler can be started and stopped without errors.""" """Scheduler can be started and stopped without errors."""
mock_sched = MagicMock() mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=3600), \ with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler() start_scheduler()
stop_scheduler() stop_scheduler()
mock_sched.add_job.assert_called_once() mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once() mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once() mock_sched.shutdown.assert_called_once()
def test_start_scheduler_uses_configured_interval():
"""Scheduler registers the status_checks job with the correct interval."""
mock_sched = MagicMock()
with patch("app.core.scheduler._load_interval", return_value=120) as mock_interval, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
mock_interval.assert_called_once()
mock_sched.add_job.assert_called_once()
_, kwargs = mock_sched.add_job.call_args
assert kwargs.get("seconds") == 120
mock_sched.start.assert_called_once()
+4 -2
View File
@@ -7,11 +7,13 @@ services:
environment: environment:
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file}
SQLITE_PATH: /app/data/homelab.db SQLITE_PATH: /app/data/homelab.db
CONFIG_PATH: /app/config.yml
CORS_ORIGINS: '["http://localhost:3000"]' CORS_ORIGINS: '["http://localhost:3000"]'
AUTH_USERNAME: ${AUTH_USERNAME:-admin}
AUTH_PASSWORD_HASH: ${AUTH_PASSWORD_HASH:?AUTH_PASSWORD_HASH must be set}
SCANNER_RANGES: ${SCANNER_RANGES:-["192.168.1.0/24"]}
STATUS_CHECKER_INTERVAL: ${STATUS_CHECKER_INTERVAL:-60}
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
- ./backend/config.yml:/app/config.yml
networks: networks:
- homelable - homelable
# Required for ping-based status checks # Required for ping-based status checks
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Homelable — LXC/VM bootstrap installer # Homelable — LXC/VM bootstrap installer
# Compatible with Proxmox VE (Debian/Ubuntu LXC containers) # Compatible with Proxmox VE (Debian/Ubuntu LXC containers)
# Usage: bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh) # Usage: bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
set -euo pipefail set -euo pipefail
@@ -48,7 +48,7 @@ if ! id "$SERVICE_USER" &>/dev/null; then
fi fi
# ── Clone / update repo ─────────────────────────────────────────────────────── # ── Clone / update repo ───────────────────────────────────────────────────────
REPO_URL="https://github.com/you/homelable.git" # ← update before publishing REPO_URL="https://github.com/Pouzor/homelable.git"
if [[ -d "$INSTALL_DIR/.git" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating existing installation..." info "Updating existing installation..."
git -C "$INSTALL_DIR" pull git -C "$INSTALL_DIR" pull