feat: Phase 1 scaffold — frontend canvas + backend skeleton

Frontend:
- Vite + React 18 + TypeScript + Tailwind v4 + Shadcn/ui
- React Flow v12 canvas with all 11 node types and 5 edge types
- Dark theme with project design system (cyan, green, orange, purple accents)
- Collapsible sidebar, toolbar, detail panel
- Zustand store for canvas state
- Demo data with 10 nodes and 10 edges

Backend:
- FastAPI + SQLAlchemy async + SQLite (Python 3.13)
- DB models: Node, Edge, CanvasState, PendingDevice, ScanRun
- REST API routes: auth, nodes, edges, canvas, scan, status (WebSocket)
- JWT auth + bcrypt via config.yml
- Pydantic v2 schemas

Infra:
- GitHub Actions quality + security workflows
- .gitignore, .env.example, verify-tooling.sh, security-check.sh
This commit is contained in:
Pouzor
2026-03-06 23:14:34 +01:00
commit 4310a5cc2d
32 changed files with 870 additions and 0 deletions
View File
+13
View File
@@ -0,0 +1,13 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import decode_token
bearer = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return username
View File
+34
View File
@@ -0,0 +1,34 @@
import yaml
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
from app.core.security import create_access_token, verify_password
router = APIRouter()
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
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)
async def login(body: LoginRequest):
username, password_hash = _load_credentials()
if body.username != username or not verify_password(body.password, password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
router = APIRouter()
@router.get("/", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(nodes=list(nodes), edges=list(edges), viewport=viewport)
@router.post("/save")
async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
# Update node positions from canvas
for node_pos in body.node_positions:
node = await db.get(Node, node_pos.id)
if node:
node.pos_x = node_pos.x
node.pos_y = node_pos.y
# Upsert viewport
state = await db.get(CanvasState, 1)
if state:
state.viewport = body.viewport
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(id=1, viewport=body.viewport))
await db.commit()
return {"saved": True}
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Edge
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
router = APIRouter()
@router.get("/", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(Edge))
return result.scalars().all()
@router.post("/", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = Edge(**body.model_dump())
db.add(edge)
await db.commit()
await db.refresh(edge)
return edge
@router.delete("/{edge_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
await db.delete(edge)
await db.commit()
@router.patch("/{edge_id}", response_model=EdgeResponse)
async def update_edge(edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
edge = await db.get(Edge, edge_id)
if not edge:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(edge, field, value)
await db.commit()
await db.refresh(edge)
return edge
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
router = APIRouter()
@router.get("/", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(Node))
return result.scalars().all()
@router.post("/", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = Node(**body.model_dump())
db.add(node)
await db.commit()
await db.refresh(node)
return node
@router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
return node
@router.patch("/{node_id}", response_model=NodeResponse)
async def update_node(node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(node, field, value)
await db.commit()
await db.refresh(node)
return node
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
await db.delete(node)
await db.commit()
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import PendingDevice, ScanRun
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
router = APIRouter()
@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
run = ScanRun(status="running", ranges=[])
db.add(run)
await db.commit()
await db.refresh(run)
# TODO: launch scanner in background thread
return run
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
return result.scalars().all()
@router.post("/pending/{device_id}/approve")
async def approve_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
device = await db.get(PendingDevice, device_id)
if device:
device.status = "approved"
await db.commit()
return {"approved": True}
@router.post("/pending/{device_id}/hide")
async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
device = await db.get(PendingDevice, device_id)
if device:
device.status = "hidden"
await db.commit()
return {"hidden": True}
+33
View File
@@ -0,0 +1,33 @@
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
# Active WebSocket connections
_connections: list[WebSocket] = []
@router.websocket("/ws/status")
async def ws_status(websocket: WebSocket):
await websocket.accept()
_connections.append(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
_connections.remove(websocket)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None):
payload = json.dumps({
"node_id": node_id,
"status": status,
"checked_at": checked_at,
"response_time_ms": response_time_ms,
})
for conn in list(_connections):
try:
await conn.send_text(payload)
except Exception:
_connections.remove(conn)