Compare commits

..

36 Commits

Author SHA1 Message Date
Fizzlepoof
482a490d3d meshtastic: map usb meshcore companion into meshmonitor
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-07-03 03:10:07 +00:00
Fizzlepoof
1eb4bb9981 meshtastic: expose embedded mqtt broker
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-07-03 02:41:58 +00:00
Fizzlepoof
43b94813ca Rebalance homelab AI routing around NOMAD-first inference 2026-06-13 23:54:56 +00:00
Fizzlepoof
e19d4a5be3 Update meshtastic docs and remove stale mqtt-proxy service
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-06-13 18:57:37 +00:00
Fizzlepoof
28b2edf2a3 Stabilize Firecrawl startup on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-06-07 04:14:23 +00:00
Fizzlepoof
7a67a2febc fix(schoolhouse): handle Paperless upload failures gracefully
- catch Paperless handoff exceptions during assignment intake
- preserve local submission records when archive handoff fails
- redirect HTML uploads back to the form with readable status details
- show archive-pending messaging instead of a raw 500 page
2026-06-06 02:37:20 +00:00
Fizzlepoof
db8dce8c8b docs: record PD qdrant/seerr drift cleanup incident
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-29 00:59:39 +00:00
Fizzlepoof
1227814277 Add Doris Barbell live workout logger
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 21:32:09 +00:00
Fizzlepoof
ba958f7ef6 Fix Doris Barbell band logging and nav links
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 21:06:46 +00:00
Fizzlepoof
dd4134b1ce Default routine log reps from targets
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 20:55:22 +00:00
Fizzlepoof
64e7ac82cf Simplify Doris Barbell dashboard
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-28 20:49:23 +00:00
Fizzlepoof
00100c5cba Correct Doris Barbell 7-7-7 and punch-out routines
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:33:05 +00:00
Fizzlepoof
a6b268def5 Fix Doris Barbell routine log defaults
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:23:31 +00:00
Fizzlepoof
3960e168bc Add dropdown exercise selection to Doris Barbell logs
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:09:51 +00:00
Fizzlepoof
6ba3fe208e Prefill Doris Barbell routine log timestamps
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-28 18:05:54 +00:00
Fizzlepoof
8a61169ab9 Add structured routine logging sheets to Doris Barbell
Some checks failed
secret-guardrails / gitleaks (push) Has been cancelled
secret-guardrails / artifact-secret-scan (push) Has been cancelled
2026-05-28 17:51:14 +00:00
Fizzlepoof
24c6a29273 docs: record full Pi-hole retirement
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 21:14:15 +00:00
Fizzlepoof
2ab4ff4428 docs: record PD Pi-hole retirement state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 20:22:07 +00:00
Fizzlepoof
dea83b64af docs: clarify technitium sync and legacy nebula state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 19:36:48 +00:00
Fizzlepoof
b98827df40 Update services directory after homelab sweep
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 19:29:05 +00:00
Fizzlepoof
57911b36f5 docs: update Rocinante VLAN recovery notes
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-27 18:52:33 +00:00
Fizzlepoof
77da65e64a docs: clarify current unifi protect chime state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 20:10:45 +00:00
Fizzlepoof
a282fe43bd docs: close out serenity migration cleanup lane
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 16:15:06 +00:00
Fizzlepoof
1dddcbd37d Update Serenity migration plan with live execution state
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 15:48:34 +00:00
Fizzlepoof
186e6b1cc4 docs: capture Serenity majority migration plan
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 15:39:05 +00:00
Fizzlepoof
386911b70d Document LocalSend trusted inbox handoff
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 03:52:08 +00:00
Fizzlepoof
d8bdc36392 Refresh Serenity placement plan after live review
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 03:48:49 +00:00
Fizzlepoof
a17d432b0f Refresh Serenity audit after source app cleanup
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:52:39 +00:00
Fizzlepoof
c7eaf1ab8d Document Serenity source app container cleanup
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:49:29 +00:00
Fizzlepoof
0c33bedbae Add Trusted-LAN LocalSend receiver on NOMAD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:44:18 +00:00
Fizzlepoof
0d7e133283 Document Serenity DB retirement after PD cutovers
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:37:32 +00:00
Fizzlepoof
59a83590c5 Add RomM cutover stack on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 02:12:53 +00:00
Fizzlepoof
09232f7d70 Publish Headscale via Pangolin
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:50:51 +00:00
Fizzlepoof
9826548fd7 Add GameVault cutover stack on PD
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:38:46 +00:00
Fizzlepoof
e9b61ad3cd Fix Headscale pilot policy and healthchecks
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:18:19 +00:00
Fizzlepoof
12452bac64 Fix search stack update recovery notes
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled
2026-05-26 01:11:49 +00:00
74 changed files with 3194 additions and 570 deletions

View File

@@ -7,6 +7,7 @@ Phase 2 (AI stack) is fully deployed and verified on PlausibleDeniability (10.5.
## What's Done
### Phase 2 — AI Stack (COMPLETE)
Historical note: this handoff reflects the original PD-first AI deployment snapshot, not the current Honcho routing policy.
All 6 containers running and verified on PD (10.5.30.6):
- **Ollama:** port 11434 — qwen2.5:14b loaded
- **OpenWebUI:** port 8282 (→8080)

View File

@@ -575,7 +575,7 @@
575|Trigger: Cron node (every Sunday at 10:00 AM)
576|Step 1: Postgres node → SELECT watch history for past 7 days
577|Step 2: Format data as structured text
578|Step 3: HTTP Request → Ollama API (POST to PD's qwen2.5:14b)
578|Step 3: HTTP Request → Ollama API (POST to the local/default automation model host, preferably N.O.M.A.D.-local rather than PD)
579| - Prompt: "Summarize this week's viewing in a fun digest: {data}"
580|Step 4: HTTP Request → Gotify (send digest)
581|```
@@ -1093,9 +1093,9 @@
1388|
1389|LiteLLM sits in front of your three Ollama instances and presents a single OpenAI-compatible API at `http://litellm:4000`. It handles:
1390|
1391|- **Model routing** — requests for `qwen3:32b` go to ROCINANTE, `qwen2.5:14b` goes to PD, `phi4` goes to N.O.M.A.D.
1392|- **Failover** — if ROCINANTE is offline, LiteLLM can fall back to PD automatically
1393|- **Load balancing** — distribute requests across instances running the same model
- **Model routing** — current reliable default is N.O.M.A.D.-local Ollama for background/automation paths, with PD kept available for shared light-tier use and Rocinante treated as optional/manual heavy capacity.
- **Failover** — avoid designs that require Rocinante to be online; degrade to N.O.M.A.D.-local or explicitly operator-invoked PD paths instead of automatic dependence on a personal PC.
- **Load balancing** — distribute requests across instances running the same model when you have multiple dependable backends, but do not assume Rocinante qualifies as one.
1394|- **Usage tracking** — logs token counts, latency, and costs per model/user via its built-in database
1395|
1396|### 7.2 — Scaffold Directories
@@ -1621,17 +1621,23 @@
1916|{
1917| "models": [
1918| {
1919| "title": "PD - qwen2.5:14b",
1919| "title": "N.O.M.A.D. - qwen2.5:3b",
1920| "provider": "ollama",
1921| "model": "qwen2.5:14b",
1922| "apiBase": "http://PD_TAILSCALE_IP:11434"
1921| "model": "qwen2.5:3b",
1922| "apiBase": "http://NOMAD_LOCAL_OR_TAILSCALE_IP:11434"
1923| },
1924| {
1925| "title": "ROCINANTE - qwen3:32b",
1925| "title": "PD - shared light tier (manual / fallback)",
1926| "provider": "ollama",
1927| "model": "qwen3:32b",
1928| "apiBase": "http://ROCINANTE_TAILSCALE_IP:11434"
1929| }
1927| "model": "qwen2.5:14b",
1928| "apiBase": "http://PD_TAILSCALE_IP:11434"
1929| },
1930| {
1931| "title": "ROCINANTE - opportunistic heavy",
1932| "provider": "ollama",
1933| "model": "qwen3:32b",
1934| "apiBase": "http://ROCINANTE_TAILSCALE_IP:11434"
1935| }
1930| ],
1931| "tabAutocompleteModel": {
1932| "title": "N.O.M.A.D. - phi4",

View File

@@ -21,7 +21,7 @@ home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-da
dev/ # gitea, rackpeek
ai/ # ollama, openwebui
headscale/ # pilot self-hosted tailnet control plane + Headplane UI on PD
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
meshtastic/ # meshmonitor, tileserver, meshtastic-map, and MeshCore/MeshMonitor integration notes
automation/ # planned: gotify, n8n
search/ # planned: qdrant
docs/ # all homelab documentation

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Create or update the Pangolin resource/target for Headscale.
Expected env vars:
- PANGOLIN_API_URL e.g. https://api.paccoco.com/v1
- PANGOLIN_API_KEY bearer token for Pangolin integration API
- PANGOLIN_ORG_ID e.g. paccoco
Optional env defaults:
- PANGOLIN_SITE_ID default 4 (Plausible Deniability)
- PANGOLIN_DOMAIN_ID default domain1
Default desired resource:
- host/domain: headscale.paccoco.com
- site: Plausible Deniability (siteId 4)
- target: headscale:8080
- Pangolin auth: disabled (sso=false)
- healthcheck: GET /health
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from urllib import error, parse, request
DEFAULT_API_URL = "https://api.paccoco.com/v1"
DEFAULT_ORG_ID = "paccoco"
DEFAULT_DOMAIN_ID = "domain1"
DEFAULT_SITE_ID = 4
DEFAULT_NAME = "headscale"
DEFAULT_SUBDOMAIN = "headscale"
DEFAULT_TARGET_IP = "headscale"
DEFAULT_TARGET_PORT = 8080
DEFAULT_HC_PATH = "/health"
USER_AGENT = "doris-pangolin-headscale/1.0"
def env(name: str, default: str | None = None) -> str | None:
value = os.environ.get(name)
if value is None or value == "":
return default
return value
def api_request(base_url: str, api_key: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, data=data, headers=headers, method=method.upper())
try:
with request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
return resp.status, json.loads(raw) if raw else None
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
payload = json.loads(raw)
except Exception:
payload = raw
return exc.code, payload
def fail(msg: str, payload: Any = None) -> int:
print(msg, file=sys.stderr)
if payload is not None:
if isinstance(payload, (dict, list)):
print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
else:
print(payload, file=sys.stderr)
return 1
def find_resource(resources: list[dict[str, Any]], *, name: str, full_domain: str) -> dict[str, Any] | None:
for resource in resources:
if resource.get("fullDomain") == full_domain:
return resource
for resource in resources:
if resource.get("name") == name:
return resource
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Upsert Pangolin Headscale resource")
parser.add_argument("--api-url", default=env("PANGOLIN_API_URL", DEFAULT_API_URL))
parser.add_argument("--api-key", default=env("PANGOLIN_API_KEY"))
parser.add_argument("--org-id", default=env("PANGOLIN_ORG_ID", DEFAULT_ORG_ID))
site_id_default = env("PANGOLIN_SITE_ID", str(DEFAULT_SITE_ID)) or str(DEFAULT_SITE_ID)
parser.add_argument("--site-id", type=int, default=int(site_id_default))
parser.add_argument("--domain-id", default=env("PANGOLIN_DOMAIN_ID", DEFAULT_DOMAIN_ID))
parser.add_argument("--name", default=DEFAULT_NAME)
parser.add_argument("--subdomain", default=DEFAULT_SUBDOMAIN)
parser.add_argument("--target-ip", default=DEFAULT_TARGET_IP)
parser.add_argument("--target-port", type=int, default=DEFAULT_TARGET_PORT)
parser.add_argument("--hc-path", default=DEFAULT_HC_PATH)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.api_key:
return fail("Missing PANGOLIN_API_KEY / --api-key")
full_domain = f"{args.subdomain}.paccoco.com"
status, resources_resp = api_request(
args.api_url,
args.api_key,
"GET",
f"/org/{parse.quote(args.org_id)}/resources?pageSize=200",
)
if status != 200:
return fail("Failed to list Pangolin resources", resources_resp)
resources = resources_resp.get("data", {}).get("resources", [])
resource = find_resource(resources, name=args.name, full_domain=full_domain)
create_body = {
"name": args.name,
"subdomain": args.subdomain,
"http": True,
"protocol": "tcp",
"domainId": args.domain_id,
}
if resource is None:
if args.dry_run:
print(json.dumps({"action": "create_resource", "body": create_body}, indent=2))
resource_id = None
else:
status, create_resp = api_request(
args.api_url,
args.api_key,
"PUT",
f"/org/{parse.quote(args.org_id)}/resource",
create_body,
)
if status not in (200, 201):
return fail("Failed to create Pangolin resource", create_resp)
resource_id = create_resp.get("data", {}).get("resourceId") or create_resp.get("data", {}).get("resource", {}).get("resourceId")
if not resource_id:
return fail("Create resource succeeded but resourceId was missing", create_resp)
status, resource_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back created resource", resource_resp)
resource = resource_resp.get("data", {}).get("resource") or resource_resp.get("data") or {}
else:
resource_id = resource["resourceId"]
resource_update = {
"name": args.name,
"niceId": resource.get("niceId") if resource else None,
"subdomain": args.subdomain,
"ssl": True,
"sso": False,
"blockAccess": False,
"emailWhitelistEnabled": False,
"applyRules": False,
"domainId": args.domain_id,
"enabled": True,
"stickySession": False,
"tlsServerName": None,
"setHostHeader": None,
"skipToIdpId": None,
"headers": None,
"maintenanceModeEnabled": False,
"maintenanceModeType": "forced",
"maintenanceTitle": None,
"maintenanceMessage": None,
"maintenanceEstimatedTime": None,
"postAuthPath": None,
}
target_body = {
"siteId": args.site_id,
"method": "http",
"ip": args.target_ip,
"port": args.target_port,
"enabled": True,
"hcEnabled": True,
"hcPath": args.hc_path,
"hcScheme": "http",
"hcMode": "http",
"hcHostname": args.target_ip,
"hcPort": args.target_port,
"hcInterval": 30,
"hcUnhealthyInterval": 30,
"hcTimeout": 10,
"hcHeaders": None,
"hcFollowRedirects": True,
"hcMethod": "GET",
"hcStatus": 200,
"hcTlsServerName": None,
"hcHealthyThreshold": 1,
"hcUnhealthyThreshold": 3,
"path": None,
"pathMatchType": None,
"rewritePath": None,
"rewritePathType": None,
}
existing_targets = resource.get("targets", []) if resource else []
matching_target = None
for target in existing_targets:
if target.get("siteId") == args.site_id:
matching_target = target
break
if args.dry_run:
print(json.dumps({
"resource_id": resource_id,
"full_domain": full_domain,
"resource_update": resource_update,
"target_action": "update" if matching_target else "create",
"target_id": matching_target.get("targetId") if matching_target else None,
"target_body": target_body,
}, indent=2))
return 0
if resource_id is not None:
status, update_resp = api_request(args.api_url, args.api_key, "POST", f"/resource/{resource_id}", resource_update)
if status not in (200, 201):
return fail("Failed to update Pangolin resource", update_resp)
if matching_target:
target_id = matching_target["targetId"]
status, target_resp = api_request(args.api_url, args.api_key, "POST", f"/target/{target_id}", target_body)
if status not in (200, 201):
return fail("Failed to update Pangolin target", target_resp)
else:
status, target_resp = api_request(args.api_url, args.api_key, "PUT", f"/resource/{resource_id}/target", target_body)
if status not in (200, 201):
return fail("Failed to create Pangolin target", target_resp)
status, final_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back final Pangolin resource", final_resp)
payload = final_resp.get("data", {}).get("resource") or final_resp.get("data") or final_resp
print(json.dumps(payload, indent=2))
return 0
return fail("Unexpected state: resource_id is missing")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -38,6 +38,7 @@ Central index for all homelab infrastructure documentation.
| [PD Future-State Architecture](planning/PD_FUTURE_STATE_ARCHITECTURE.md) | Target-state host layout, storage model, cyber VM policy, and Serenity retirement path |
| [Serenity Docker Audit](planning/SERENITY_DOCKER_AUDIT.md) | Live-audited keep/move/retire container baseline for Serenity cleanup |
| [Serenity Cleanup Wave 1](planning/SERENITY_CLEANUP_WAVE_1.md) | First safe cleanup pass: legacy Pi-hole removal, stale container pruning, and DB-app migration ordering |
| [Serenity Majority Migration Plan](planning/SERENITY_MAJORITY_MIGRATION_PLAN.md) | Kanban-ready post-D7 plan for what stays, what retires, and what moves only after PD storage ownership changes |
| [TODO](planning/TODO.md) | Active and backlog tasks |
## Operations
@@ -48,3 +49,4 @@ Central index for all homelab infrastructure documentation.
| [Backup Policy](operations/BACKUP_POLICY.md) | Backup scope, database dumps, and restore cadence |
| [PD Backup Deployment](operations/PD_BACKUP_DEPLOYMENT.md) | Live PD backup runner deployment details |
| [Secrets Management](operations/SECRETS_MANAGEMENT.md) | `.env` handling, encrypted backups, and incident guardrails |
| [PD qdrant / seerr drift cleanup incident](operations/INCIDENT_2026-05-28_PD_QDRANT_SEERR_DRIFT.md) | Backup-first reconciliation after manual qdrant recovery and media/AI compose drift cleanup |

View File

@@ -40,7 +40,8 @@ Current live homelab stack plus the planned direction for the upgraded PD platfo
- **Dockhand** (3230) — Docker management UI
### AI Layer
- **LiteLLM** (4000) — unified LLM proxy; routes to Ollama on PD (light) and Rocinante (heavy)
- **LiteLLM** (4000) — unified LLM proxy; PD remains the shared light tier, while Rocinante is an opportunistic heavy tier when the PC is online
- **Honcho** — memory/deriver stack on N.O.M.A.D. using local Ollama first so PD outages do not cascade into MeshMonitor/database incidents
- **OpenWebUI** (8282) — chat UI with RAG, STT, and web search
- **Qdrant** (6333/6334) — vector database for RAG
- **Whisper** (8786 / 8787) — CPU speech-to-text on N.O.M.A.D. plus CUDA speech-to-text on Rocinante

View File

@@ -2,7 +2,7 @@
All homelab services, their hosts, ports, and current status.
*Last updated: 2026-05-21*
*Last updated: 2026-06-13*
## Shared Databases (PD)
@@ -27,6 +27,8 @@ All homelab services, their hosts, ports, and current status.
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
| Authelia | 9091 | http://pd:9091 | ✅ Active internally on PD as legacy migration-only auth plumbing; public `auth.paccoco.com` now redirects to Authentik |
| Authentik | 9000 | http://pd:9000 / https://authentik.paccoco.com | ✅ Active; public identity front door for OIDC/SAML and forward-auth flows |
| Headscale | 8084 | http://pd:8084 | ✅ Active; control-plane container is running and host port is published |
| Headplane | 3005 | http://pd:3005 | ✅ Active; admin UI container is running and host port is published |
## AI Stack
@@ -36,14 +38,21 @@ All homelab services, their hosts, ports, and current status.
| OpenWebUI | PD | 8282 | ✅ Active |
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
| Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.30.7) | 8786 | ✅ Active |
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.30.112) | 8787 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation |
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.30.112) | 8787 | ⚠️ Re-check from N.O.M.A.D. if Whisper-specific traffic is needed; Ollama-only recovery was validated 2026-05-27 |
| SearXNG | PD | 8888 | ✅ Active |
| Ollama — light tier | PD (10.5.30.6) | 11434 | ✅ Active |
| Ollama — heavy tier | Rocinante (10.5.30.112) | 11434 | ⚠️ Unreachable from N.O.M.A.D. during 2026-05-21 Doris validation |
| Ollama — light tier | PD (10.5.30.6) | 11434 | ✅ Active; keep routine Honcho load off this host to protect MeshMonitor/shared Postgres |
| Ollama — heavy tier | Rocinante (10.5.30.112) | 11434 | ⚠️ Personal PC / opportunistic only; do not depend on it for routine background routing |
| Reranker (TEI) | Serenity (10.5.30.5) | 9787 | ✅ Active |
OpenClaw is no longer treated as an active PD service in the operator inventory. Only helper-artifact references remain under the school-intake planning docs.
## DNS / network control plane
| Service | Host | Port | Status |
|---------|------|------|--------|
| Technitium primary source node | PD (`10.5.30.8` via `technitium-pilot` macvlan stack) | 53 / 5380 | ✅ Active source of truth; PD root cron runs `technitium-pilot/bin/sync_backup_nodes.sh` every 15 minutes, and recent logs show successful sync + verification of Nomad (`10.5.30.9`) and Serenity (`10.5.30.10`) |
| Legacy Pi-hole runtime | PD + N.O.M.A.D. | former legacy resolver path (`10.5.30.53`, Pi-hole/Unbound/Keepalived) | Historical only. PD's old Pi-hole stack (`nebula-sync`, `pihole-primary`, `unbound-pihole-primary`, `keepalived-pihole-primary`) and NOMAD's leftover stack (`pihole-nomad` + `unbound-pihole-nomad` + `keepalived-pihole-nomad`) were both retired on 2026-05-27. The old VIP `10.5.30.53` no longer answers; Technitium (`10.5.30.8/.9/.10`) is the live DNS path. |
## Media (PD)
| Service | Port | Status |
@@ -67,6 +76,7 @@ OpenClaw is no longer treated as an active PD service in the operator inventory.
| DoneTick | 2021 | ✅ Active |
| Dakboard Bridge | 5087 | ✅ Active |
| Qui | 7476 | ✅ Active |
| Dispatcharr | 9191 | ✅ Active |
## Smart Home (PD)
@@ -90,7 +100,13 @@ OpenClaw is no longer treated as an active PD service in the operator inventory.
| Service | Host | Port | Status |
|---------|------|------|--------|
| MeshCore to MQTT relay | N.O.M.A.D. (10.5.30.7) | outbound MQTT only | ✅ Running as Docker container `mctomqtt` (`mctomqtt:latest`); config bind-mounted from `/etc/mctomqtt` |
| MeshCore companion observer | N.O.M.A.D. (10.5.30.7) | outbound MQTT only | ✅ Running as systemd service `meshcore-capture`; install root `/home/fizzlepoof/.meshcore-packet-capture`; direct serial connection to the Heltec companion node |
## Local file intake
| Service | Host | Port | Status |
|---------|------|------|--------|
| LocalSend Trusted Inbox | N.O.M.A.D. (`10.5.1.16` on Trusted VLAN 51) | 53317/tcp+udp | ✅ Repo-tracked Docker stack at `/opt/localsend-nomad`; advertises `Doris Trusted Inbox`; writes directly into `/home/fizzlepoof/private/inbox-secrets`; Minerva auto-sort watches that inbox via `minerva-localsend-autosort.path`; same-host checks from N.O.M.A.D. are unreliable because of macvlan isolation |
## Mapping / Geo (PD)

View File

@@ -0,0 +1,126 @@
# Incident: PD qdrant / seerr compose drift cleanup (2026-05-28)
## Summary
An earlier recovery on PlausibleDeniability left the runtime state healthier than the operator metadata around it.
Public and LAN access were restored, but `qdrant` had been manually recreated from `docker inspect` data because its prior compose-path labeling was stale or absent. That fixed service availability, but it also created the risk that live state, repo state, and future operator assumptions could drift apart.
On 2026-05-28, the stack was squared away by taking fresh backups, re-validating the live compose roots, re-syncing repo-managed compose content into the live directories, pruning a stale backup artifact from the live media compose directory, and re-verifying health.
## What broke
The incident had two layers:
1. **Service health / accessibility breakage**
- `qdrant` and related AI/media surface area needed recovery work earlier in the day.
- `seerr` public/local availability also had to be checked as part of the same cleanup pass.
2. **Post-recovery metadata drift risk**
- `qdrant` no longer had a trustworthy compose lifecycle trail from the original broken state.
- Manual recreation from `docker inspect` restored the container, but that is not the same as a clean repo-driven compose reconciliation.
- A stale file (`docker-compose.yaml.pre-d3b-20260526-003732`) was still sitting in the live media compose directory, which was not dangerous by itself but increased operator ambiguity.
## What was manually recreated
During the earlier recovery, `qdrant` was recreated manually from live container metadata when the prior compose-path labels were stale or missing.
By the end of the cleanup, the running `qdrant` container correctly reported:
- working dir: `/mnt/docker-ssd/docker/compose/ai`
- compose file: `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- service: `qdrant`
`seerr` correctly reported:
- working dir: `/mnt/docker-ssd/docker/compose/media`
- compose file: `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- service: `seerr`
## Backups taken before cleanup
Fresh rollback-friendly backups were created on PD at:
- directory: `/mnt/docker-ssd/docker/backups/post-incident-sync-20260528-192945`
- tarball: `/mnt/docker-ssd/docker/backups/post-incident-sync-20260528-192945.tgz`
Backup contents include:
- live `ai` compose tree
- live `media` compose tree
- `qdrant` appdata
- `docker inspect` output for `qdrant`, `openwebui`, and `seerr`
- rendered `docker compose config` output for both stacks
## Cleanup actions performed
1. verified the live container labels for `qdrant`, `openwebui`, and `seerr`
2. compared live compose files against the repo-managed copies under `truenas-stacks`
3. confirmed the active tracked compose content already matched repo
4. re-synced repo-managed `ai/` and `media/` compose trees into the live PD compose directories
5. pruned the stale live file `docker-compose.yaml.pre-d3b-20260526-003732` from the media compose directory **after** backup
6. re-ran `docker compose config` validation in both live directories
7. re-checked HTTP health for `qdrant`, `openwebui`, and `seerr`
## What was verified at the end
### Live compose / label alignment
The live runtime and repo are back in agreement for the affected services:
- `qdrant` labels point to `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- `seerr` labels point to `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- repo-managed tracked compose content matches the live active files
### Health
The following checks passed after cleanup:
- `http://10.5.30.6:6333/healthz``200`
- `http://10.5.30.6:8282/``200`
- `http://10.5.30.6:5055/``200`
### Compose validation
These both rendered successfully:
- `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`
- `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
## Important non-actions
- The Docker daemon was **not** restarted.
- The stacks were **not** redeployed during this cleanup pass.
- No repo-unrelated working tree changes were touched.
That was intentional: the active tracked compose files already matched repo, and the services were healthy, so a no-redeploy reconciliation was the safer path.
## Why this matters
The main value of this cleanup was not changing application behavior. It was restoring operator confidence that:
- the live compose roots are the authoritative ones
- the running containers point back to those roots
- repo state and live state are aligned again
- future maintenance can start from the compose directories instead of from ad hoc inspect output
## Next time: safer recovery procedure
If a PD stack service must be rebuilt under pressure:
1. **Identify the authoritative compose directory first.**
- Prefer `/mnt/docker-ssd/docker/compose/<stack>/docker-compose.yaml` over ad hoc recreation.
2. **Take a backup before cleanup, even if the service is already back up.**
- Capture compose trees, relevant appdata, and `docker inspect` output.
3. **If manual recreation is unavoidable, treat it as temporary stabilization only.**
- Reconcile labels, compose roots, and repo state immediately afterward.
4. **Compare repo ↔ live compose content before redeploying.**
- If tracked content already matches and health is good, avoid unnecessary churn.
5. **Prune stale backup/override files from live compose dirs only after backup.**
- Leaving old compose fragments nearby makes future incident work harder.
6. **Verify both health and metadata.**
- A container being up is not enough; confirm compose labels, working dir, and health endpoints.
## Follow-up status
No immediate further action was required after the cleanup pass. The remaining lesson is procedural: manual container recreation is acceptable as a short-term rescue, but it should always be followed by a backup-first repo/live reconciliation so drift does not become the new baseline.

View File

@@ -1,9 +1,9 @@
# Pi-hole Deployment Plan
**Status:** Historical Trusted-VLAN design. PD primary is still valid, but after NOMAD moved to Servers (`10.5.30.7`) its participation in the old Trusted-side VIP `10.5.30.53` was disabled to restore correct routing. Do not reuse this document for NOMAD as-is until a same-subnet HA redesign exists.
**Status:** Historical Pi-hole design. It is no longer the active DNS source of truth. Both the PD and NOMAD Pi-hole runtimes were retired on 2026-05-27 after cutover to the Technitium trio, and the old VIP `10.5.30.53` no longer answers. Treat this document as history/reference, not the current intended DNS architecture.
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
> **Warning:** The examples below describe the original Trusted-side plan where NOMAD was `10.5.30.7` and shared VIP `10.5.30.53/24`. They are retained as historical deployment notes, not the current NOMAD runtime truth.
> **Warning:** The examples below describe the original HA Pi-hole plan. The entire Pi-hole path has now been retired. Current live DNS is Technitium on `10.5.30.8`, `10.5.30.9`, and `10.5.30.10`; the old Pi-hole VIP `10.5.30.53` is dead. Do not treat anything below as the desired current-state DNS design.
---

View File

@@ -164,7 +164,6 @@ Media / library:
- Seerr
- RomM
- GameVault
- Wizarr
- Shelfmark
- Notifiarr
@@ -200,6 +199,13 @@ Optional elsewhere:
- heavy inference on Rocinante if PD does not get the 4090
- high-risk security experiments on a future dedicated lab node if the school/lab workload outgrows PD-hosted segmented VMs
### Move off Serenity early; does not need the storage cutover
- retire Wizarr if it is still present; rebuild later only if onboarding need returns
- Notifiarr -> PD only if PD headroom makes the move worth it
- netdata -> keep only where it still adds monitoring value
These are low-risk peel-off candidates because they are not part of the storage-local torrent/media locality lane.
## Cybersecurity VM model on PD
### This is acceptable on PD
@@ -251,7 +257,7 @@ Suggested policy posture:
### Phase 1: document and reduce Serenity sprawl now
- document current host roles and future-state design
- classify Serenity services into keep / move / retire
- move low-risk non-storage-tied apps off Serenity first
- retire or peel off only the smallest low-risk items first (`Wizarr`, maybe `Notifiarr`, and possibly `netdata`) while leaving `Hawser` + `dockersocket` in place as the Dockhand management path
### Phase 2: build upgraded PD
- install TrueNAS Scale on the new PD hardware
@@ -263,6 +269,7 @@ Suggested policy posture:
- migrate any remaining general-purpose apps off Serenity
- relocate databases/appdata onto the intended SSD tier
- validate ingress, monitoring, and DNS roles
- keep `Newt`, `reranker`, and the off-PD Technitium node as explicit special cases until their redesign work is ready
### Phase 4: move the storage-dependent torrent/media automation stack
- after the storage move design is finalized, move qbit + ARR locality to PD
@@ -271,8 +278,8 @@ Suggested policy posture:
### Phase 5: move or redesign the remaining special cases
- move reranker onto PD if AI core lands there
- redesign backup DNS so Serenity is no longer required
- preserve at least one off-PD Technitium node on NOMAD
- redesign backup DNS so Serenity is no longer required, likely by introducing a small dedicated Raspberry Pi 4B resolver lane while preserving an off-PD Technitium failure domain
- preserve at least one off-PD Technitium node outside PD even after Serenity retires
### Phase 6: retire Serenity
- verify pool adoption is complete
@@ -293,5 +300,6 @@ Suggested policy posture:
For the current Serenity Docker review, the guiding rule is:
- remove obvious sprawl from Serenity now
- retire dead weight and peel off only the smallest miscellaneous low-risk apps before touching the storage-locality lane
- do not over-optimize the final placement around Serenity permanence
- remember that once PD directly owns the storage, the qbit/ARR locality argument flips to PD

View File

@@ -56,12 +56,12 @@ Wave 1 explicitly excludes:
## Live facts this plan is based on
From the live Serenity audit:
From the live Serenity audit before D5/D6/D7:
- ARR/torrent locality is still tied to `/mnt/user/data`
- `GameVault` points at local Postgres on `10.5.30.5:5432`
- `RomM` points at local MariaDB on `10.5.30.5:3306`
- live container env inspection still shows those explicit DB_HOST/DB_PORT bindings on 2026-05-25
- `GameVault` pointed at local Postgres on `10.5.30.5:5432`
- `RomM` pointed at local MariaDB on `10.5.30.5:3306`
- live container env inspection showed those explicit DB_HOST/DB_PORT bindings on 2026-05-25 before cutover
- quick live checks did not surface immediate DB dependencies for `Wizarr`, `Shelfmark`, or `Notifiarr`
- `Newt` is still needed
- legacy Pi-hole containers are still running even though Technitium is now the intended DNS path
@@ -76,6 +76,9 @@ Live execution on 2026-05-25 established:
- the recent non-running `Created` containers (`calibre-web`, `SuggestArr`, `Cleanuparr`, `calibre`, `agregarr`) were metadata-verified and then removed after confirming they were still only `Created`, had `restart=no`, and were not live workloads
- `Unraid-Cloudflared-Tunnel` was audited, stopped, and removed after verification showed it was only transport-alive and no current public traffic depended on it
- the Serenity Pi-hole HA stack (`binhex-official-pihole`, `pihole-serenity`, `unbound-pihole-serenity`, `keepalived-pihole-serenity`) was then stopped and removed after live mixed-host DNS checks confirmed the Technitium path remained healthy without it
- `gamevault` and `gamevault-db` validated healthy on PD, then Serenity `postgresql15` was exported, stopped, archived for rollback, and removed during D7
- `romm` and `romm-db` validated healthy on PD, then Serenity `MariaDB-Official` was exported, stopped, archived for rollback, and removed during D7
- the stale stopped Serenity source app containers `romm` and `GameVault` were then removed as post-cutover cleanup, so rollback now depends on retained appdata/backup artifacts rather than source containers lingering in `docker ps -a`
## Wave 1-A: legacy Pi-hole removal
@@ -495,6 +498,21 @@ Definition of done:
- Serenity-side DB appdata is retained for cooldown/rollback, not deleted immediately
- docs and host inventory updated to show Serenity no longer carries those DB pairs
Execution notes (2026-05-25):
- PD validation passed before source retirement:
- `gamevault`, `gamevault-db`, `romm`, and `romm-db` all reported healthy
- HTTP checks returned `200` from PD for `http://127.0.0.1:8785/` and `http://127.0.0.1:8457/`
- PD DB quick checks returned 18 public tables for `gamevault` and 20 tables for `romm`
- Serenity rollback retention created at `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`
- `romm.sql`
- `gamevault.sql`
- `mariadb-official-appdata.tgz`
- `postgresql15-appdata.tgz`
- Serenity source DB appdata paths were intentionally left in place for cooldown:
- `/mnt/user/appdata/mariadb-official`
- `/mnt/cache/appdata/postgresql15`
- After D7, `docker ps -a` on Serenity no longer listed `MariaDB-Official` or `postgresql15`
## Open item that still needs verification
- `reranker` mounts `/mnt/user/appdate/reranker`

View File

@@ -1,18 +1,16 @@
# Serenity Docker Audit
Status: live-audited baseline for cleanup and migration planning.
Status: live-audited baseline for cleanup and migration planning, refreshed after the GameVault/RomM cutover cleanup and the broader keep/move/retire placement review.
Last live verification: 2026-05-25
Last live verification: 2026-05-26
## Access path used
Direct SSH from this session to `root@10.5.30.5` failed with `Permission denied`.
Live audit succeeded by hopping through PD:
Live audit now succeeds directly from NOMAD using the configured host alias:
- `ssh pd`
- then `ssh -i /home/truenas_admin/.ssh/serenity_backup_ed25519 root@10.5.30.5`
- `ssh serenity`
That means the inventory below is grounded in the current live runtime, but through PD's trusted Serenity backup key rather than direct local SSH.
Older discovery in this document used the PD pivot path before direct local SSH was wired up.
## What is currently running on Serenity
@@ -32,13 +30,13 @@ These are the containers whose current placement still makes operational sense b
- `bazarr`
- `autobrr`
- `unpackerr`
- `Notifiarr`
- `shelfmark`
Common pattern observed from live mounts:
- these stacks are bound heavily to `/mnt/user/data`
- torrent state and backup artifacts live under Serenity-owned paths like `/mnt/user/data/torrents`, `/mnt/user/data/BT_backup`, and appdata under `/mnt/user/appdata/*`
- current path locality still argues for keeping them on Serenity until PD directly owns the disks and final media paths
- `shelfmark` is not a generic "random app" here; its live mounts also tie it to Serenity-owned `/mnt/user/data`, audiobook ingest paths, and torrent/library paths
### Keep temporarily, but plan to move or collapse later
These are live today, but they are not good long-term reasons to keep Serenity alive after PD is rebuilt.
@@ -46,6 +44,7 @@ These are live today, but they are not good long-term reasons to keep Serenity a
- `reranker`
- currently on Serenity because CPU-only TEI was moved off PD
- planned long-term home: PD if PD remains the AI control-plane/core host
- live runtime uses `/mnt/user/appdate/reranker`, not `/mnt/user/appdata/reranker`; treat that as a migration hazard that must be handled deliberately
- `technitium-dns-pilot`
- current backup Technitium node on Serenity (`10.5.30.10`)
- long-term: keep at least one off-PD backup resolver, but that does not have to remain on Serenity forever
@@ -53,14 +52,64 @@ These are live today, but they are not good long-term reasons to keep Serenity a
- live on Serenity now
- operator confirmed it is still needed, so it should be preserved during cleanup and only rehomed deliberately later
- `Hawser`
- helper/observability tooling, not a reason to preserve Serenity as a host role
- operator confirmed Dockhand depends on it for remote management of Serenity's remaining Docker stacks
- keep on Serenity with `dockersocket` until that management pattern is intentionally replaced
- `dockersocket`
- Hawser helper sidecar required by the current Dockhand remote-management path
- `netdata`
- useful while Serenity remains live, not a reason to keep Serenity permanently
- `GameVault`
- `romm`
- `Notifiarr`
- low-risk utility app that can move later if PD has spare headroom
- not urgent while PD remains RAM-constrained
- `Wizarr`
- these are normal app workloads, not storage-appliance-only workloads
- long-term candidates for PD once the storage and app-host migration is ready
- operator confirmed it is unused and should be retired rather than migrated
### Placement verdict by service group
#### Keep on Serenity until the storage-locality cutover is designed
- torrent / download lane:
- `qbit`
- `GluetunVPN`
- `qbit_manage`
- `autobrr`
- `unpackerr`
- media automation lane:
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- locality-adjacent helpers:
- `Notifiarr`
- `shelfmark`
Reason:
- all of these either mount Serenity-owned `/mnt/user/data` directly or depend on the downloader/media-path locality that still lives on Serenity today
- moving them before PD owns the storage locally would just trade one cleanup project for a fragile NFS-path rewrite project
#### Optional early cleanup / peel-off work
- retire `Wizarr`
- move `Notifiarr` only if PD headroom clearly allows it
- review whether `netdata` still adds enough local-monitoring value to keep
Reason:
- these are the few remaining low-risk app-level changes that do not depend on the qbit/ARR storage cutover
- PD is constrained right now, so only very small wins should happen before the larger storage redesign
#### Keep as explicit temporary exceptions, then redesign last
- `reranker`
- `technitium-dns-pilot`
- `Newt`
Reason:
- these are the remaining special cases that still justify Serenity for non-media reasons
- each one has a real redesign question attached:
- reranker: whether PD should re-absorb AI helper services later
- Technitium backup node: which off-PD host should own the durable backup resolver role
- Newt: when Serenity-hosted Pangolin resources are deliberately rehomed so the local tunnel is no longer required
### Retire candidates
These should be treated as cleanup targets unless a specific live dependency is rediscovered.
@@ -74,11 +123,14 @@ These should be treated as cleanup targets unless a specific live dependency is
- `keepalived-pihole-serenity`
- these were legacy DNS/HA remnants once Technitium became the intended resolver strategy
- mixed-host DNS verification passed during retirement on 2026-05-25, and the Serenity Pi-hole HA stack was then stopped and removed without immediate DNS regression
- `postgresql15`
- `MariaDB-Official`
- both are live, but they are suspicious because current docs position PD as the shared database home
- do not delete blindly; first identify whether any Serenity-only apps still depend on them
- treat them as consolidation candidates, not permanent Serenity residents
- the old local DB pair (`postgresql15` and `MariaDB-Official`) was retired on 2026-05-25 after PD validation and rollback bundle creation
- the stale stopped source app containers (`romm` and `GameVault`) were removed on 2026-05-26
- retained rollback paths still exist on Serenity:
- `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`
- `/mnt/user/appdata/romm`
- `/mnt/user/appdata/gamevault`
- `/mnt/user/appdata/mariadb-official`
- `/mnt/cache/appdata/postgresql15`
## Not-running / stale containers seen in `docker ps -a`
These did not appear live and should be reviewed for deletion after confirming their data is not needed.
@@ -91,10 +143,10 @@ Created only (retired on 2026-05-25 after metadata verification):
- `agregarr`
Exited:
- `Huntarr` — exited 6 weeks ago
- `omegabrr` — exited 4 months ago
- none remaining from the previously audited stale set; `Huntarr`, `omegabrr`, `romm`, and `GameVault` have now been removed
These are strong clutter candidates.
Current result:
- no stale created/exited containers remain in `docker ps -a`
## Live project roots observed
@@ -117,33 +169,25 @@ Examples from the live container inspection:
- `shelfmark` binds `/mnt/user/data`, `/mnt/user/data/media/books/Audiobooks`, `/mnt/user/data/media/books/ingest`, and `/mnt/user/data/torrents`
- most ARR-family services bind `/mnt/user/data`
- `reranker` binds `/mnt/user/appdate/reranker -> /data`
- `Wizarr` only binds its own appdata/database paths and has no meaningful storage-locality reason to stay on Serenity
- `Hawser` + `dockersocket` are local helper tooling, not media-path owners
Notable typo/risk:
- `reranker` is mounted from `/mnt/user/appdate/reranker` (note `appdate`, not `appdata`)
- verify whether that path is intentional or an unnoticed typo before migration work touches it
- `/mnt/user/appdate/reranker` exists live; `/mnt/user/appdata/reranker` does not currently exist
- treat that as an intentional-on-disk reality for now, but fix the naming deliberately during a future migration rather than by surprise during unrelated cleanup
## Recommended cleanup order
## Recommended next work order
### Phase 1: remove obvious deadwood
1. verify no dependency remains on `Unraid-Cloudflared-Tunnel`
2. inspect whether the legacy Pi-hole stack still serves any traffic or admin purpose
3. remove stale created/exited containers that have no active role:
- `calibre-web`
- `calibre`
- `SuggestArr`
- `Cleanuparr`
- `agregarr`
- `Huntarr`
- `omegabrr`
### Phase 1: completed cleanup baseline
- dead Cloudflared / Pi-hole / stale-container cleanup is done
- the old local DB pair and source GameVault/RomM containers are already retired
- current live work should start from the narrower remaining set, not re-open that cleanup unless new evidence appears
### Phase 2: identify silent database dependencies
Before touching `postgresql15` or `MariaDB-Official`, map which containers point at them.
Questions to answer:
- which apps on Serenity still use local Postgres?
- which apps on Serenity still use local MariaDB?
- are GameVault, RomM, Shelfmark, or Wizarr still backed by these local DBs?
- can any of those apps be moved to PD's shared DB stack cleanly?
### Phase 2: peel off the low-risk non-locality apps
- retire `Wizarr`
- optionally move `Notifiarr` if PD headroom clearly permits it
- decide whether `netdata` still provides unique value once the broader monitoring stack is considered
### Phase 3: preserve the only things that currently justify Serenity
Until PD owns the storage locally, keep the torrent/media-ingest locality group together:
@@ -157,33 +201,36 @@ Until PD owns the storage locally, keep the torrent/media-ingest locality group
### Phase 4: cut over after PD storage migration
Once PD directly owns the relevant media/torrent datasets:
- move qbit + ARR locality to PD
- move reranker to PD if desired
- keep path locality on the box that owns the disks
- move or retire the locality-adjacent helpers that were intentionally left with that lane
### Phase 5: redesign the special cases last
- move reranker to PD if desired, while normalizing the `appdate`/`appdata` path naming intentionally
- keep at least one off-PD Technitium node somewhere, preferably NOMAD if Serenity is retiring
- move or retire the miscellaneous app workloads still left on Serenity
- shut Serenity down as a permanent app host
- re-home or retire Serenity-local `Newt` only after Pangolin dependencies are deliberately redesigned
## Kanban-ready workstreams
### Epic A — Serenity live inventory normalization
- capture `docker ps`, `docker ps -a`, mounts, ports, and database dependencies
- map every live container to one of: keep-now, move-to-PD, retire-now, or remove-as-stale
- verify whether any runtime definitions exist only in Unraid templates and not in repo-managed compose
- capture `docker ps`, mounts, ports, and remaining host-role assumptions
- keep the repo docs aligned with the actual Unraid runtime inventory
- treat ad-hoc Unraid container definitions as a drift risk until they are intentionally replaced
### Epic B — obvious dead container cleanup
- remove dead Cloudflared tunnel
- remove stale created/exited clutter containers
- verify and retire legacy Pi-hole/Keepalived/Unbound stack if no hidden dependency remains
### Epic B — low-risk miscellaneous app peel-off
- retire `Wizarr`
- decide whether `Notifiarr` is worth an early move given PD RAM pressure
- decide whether `netdata` still earns its keep alongside the broader monitoring stack
### Epic C — database dependency audit
- identify which Serenity apps use `postgresql15`
- identify which Serenity apps use `MariaDB-Official`
- decide whether those DBs move to PD shared databases or are retired with the apps
### Epic D — torrent/media-locality preservation until PD cutover
### Epic C — torrent/media-locality preservation until PD cutover
- keep qbit/ARR stack stable on Serenity for now
- document exact media/torrent paths that must exist on PD before migration
- prevent premature moves that would recreate NFS-path weirdness
### Epic D — special-case redesign
- normalize the reranker path oddity during a planned move rather than an incidental cleanup
- decide where the off-PD backup Technitium role should live after Serenity
- re-home Pangolin/Newt dependencies last, not during the media cutover
### Epic E — final Serenity retirement
- move remaining wanted apps to PD or NOMAD
- preserve only the intended backup-DNS failure-domain role off PD
@@ -201,13 +248,14 @@ Resolved on 2026-05-25:
- move qbit + ARR family to PD after storage cutover
- leave no intentional production app role on Serenity
- retire Serenity entirely
6. `Wizarr` is explicit retire-now dead weight; `Notifiarr` is the main optional tiny move candidate; `Hawser`/`dockersocket` remain intentional keepers because Dockhand depends on them.
## Remaining verification questions
- Confirm whether anything beyond `GameVault` still depends on `postgresql15`.
- Confirm whether anything beyond `RomM` still depends on `MariaDB-Official`.
- Is `/mnt/user/appdate/reranker` intentional, or a typo that should be corrected before migration?
- When Pangolin API write auth is available again, rewrite Serenity target health-check hostnames away from stale `10.5.1.5` so the runtime alias can be removed cleanly.
- No additional live dependency beyond the retired `GameVault`/`RomM` pair was surfaced during the quick DB audit; if that changes later, treat it as a rediscovery against rollback artifacts rather than a live-stack blocker.
- Decide when the retained DB/appdata rollback artifacts are old enough to archive more aggressively or finally delete.
- Normalize the `reranker` path naming during the eventual move: preserve the live `/mnt/user/appdate/reranker` data now, but decide whether the destination should standardize back to `appdata`.
- Serenity's Pangolin/Newt health-check drift is fixed now; any future Newt rehome is an architecture task, not a stale-health-check incident response.
## Pangolin / Newt live remediation status
@@ -265,7 +313,7 @@ Rollback / evidence artifacts:
## Database dependency findings
Live inspection of container environments currently points to:
Live inspection before the GameVault/RomM retirement pointed to:
- `GameVault` -> local `postgresql15`
- `DB_HOST=10.5.30.5`

View File

@@ -0,0 +1,368 @@
# Serenity Majority Migration Plan
Status: approved planning baseline for the post-D7 Serenity drain strategy.
Last updated: 2026-05-26
## Execution update — 2026-05-26
Completed live:
- `Wizarr` retired from Serenity runtime
- stale Pangolin frontend for `wizarr.paccoco.com` disabled; public hostname now returns `404` instead of fronting a dead service
- `Hawser` + `dockersocket` verified live and healthy on Serenity
- `Notifiarr` re-evaluated against live PD headroom and intentionally left on Serenity for now
- `netdata` re-evaluated and kept on Serenity for now as host-local monitoring, not as an early migration target
- closeout check: no remaining open Serenity-specific Kanban cards exist from the completed cleanup/planning wave; only the documented planned/blocked future lanes remain
Current blockers verified live:
- PD is still RAM-constrained (`31 GiB total`, `27 GiB used`, `2.3 GiB free` at check time)
- PD still consumes Serenity storage over NFS (`/mnt/unraid/data` and `/mnt/unraid/immich` mounted from `10.5.30.5`)
- backup-DNS replacement is still only a target direction in docs; no Pi 4B resolver lane is live yet
- Serenity-local `Newt` is still part of the current Pangolin path for Serenity resources, so the special-case access lane is not retired yet
Interpretation:
- The no-regret cleanup phase is complete.
- The majority migration phase remains blocked on future infrastructure work, not on missed low-risk cleanup.
## Goal
Move the majority of remaining Docker workload off Serenity without recreating NFS/path-locality pain, while preserving the few roles that still legitimately need to live there until later infrastructure work is complete.
This plan assumes:
- qBittorrent and the torrent/media-locality lane stay on Serenity for now
- PD is resource-constrained for a while, especially on RAM
- `Wizarr` should be retired, not migrated
- `Hawser` + `dockersocket` stay on Serenity because Dockhand uses them for remote management of Serenity's remaining Docker stacks
- `Shelfmark` stays with the media-locality lane for now
- Serenity's backup-DNS replacement should likely land on a small dedicated Raspberry Pi 4B rather than collapsing onto PD
## Executive summary
It is feasible to move the majority of Serenity's Docker workload off the box, but not as a single near-term wave.
Right now the correct strategy is:
1. retire obvious dead weight
2. preserve the torrent/media-locality lane on Serenity
3. keep Hawser/dockersocket, Newt, reranker, and backup-DNS as explicit temporary exceptions
4. only move tiny low-risk apps early if PD headroom makes the move worth it
5. do the real majority migration only after PD directly owns the relevant storage and path locality
## Current placement decision matrix
### Keep on Serenity now
#### Media-locality lane
- `GluetunVPN`
- `qbit`
- `qbit_manage`
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- `autobrr`
- `unpackerr`
- `shelfmark`
Reason:
- these either mount Serenity-owned `/mnt/user/data` directly or depend on workflows whose correctness currently assumes Serenity-local path ownership
- splitting them early would turn this into an NFS-path rewrite project instead of a cleanup project
#### Temporary intentional exceptions
- `Hawser`
- `dockersocket`
- `Newt`
- `technitium-dns-pilot`
- `reranker`
- `netdata`
Reason:
- `Hawser` + `dockersocket` remain part of the current Dockhand remote-management path
- `Newt` still matters because Pangolin tunnels Serenity resources through Serenity-local Newt
- `technitium-dns-pilot` preserves an off-PD DNS failure domain until replacement exists
- `reranker` is not urgent to move while PD remains constrained
- `netdata` is host-local monitoring; evaluate whether to simplify it later, not as a forced migration target now
### Retire
- `Wizarr`
Reason:
- operator confirmed it is unused and disposable
- if onboarding needs return later, it can be rebuilt cleanly instead of migrated
### Optional tiny move candidate
- `Notifiarr`
Reason:
- low storage-locality coupling
- but not worth forcing while PD remains constrained unless there is a concrete benefit
## What should not be added to Serenity
Default rule: add nothing new.
Serenity is in a shrinking transitional role. New durable app responsibilities should go elsewhere unless they are explicitly temporary, host-local, and part of a migration or retirement aid.
## Suggested long-term end state
### PD long-term
After storage cutover and capacity improvement, PD should absorb:
- qBittorrent + Gluetun + qbit_manage
- ARR family
- `autobrr`
- `unpackerr`
- `shelfmark`
- optional `Notifiarr`
- `reranker` if PD remains the AI/control-plane center
### Off-PD DNS resilience
When Serenity retires, preserve at least one non-PD Technitium lane. Likely target:
- dedicated Raspberry Pi 4B backup resolver
### Serenity end state
- no intentional durable production app role
- no unique production dependency path left behind
- host eligible for retirement after cooldown and verification
## Execution-style Kanban board
Use this as the working card map.
### Epic A — Immediate cleanup and intention-locking
#### A1. Retire Wizarr
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- `Wizarr` is removed from live Serenity runtime if still present
- no reverse-proxy or bookmark expectation still points at it as a live service
- docs no longer describe it as a migration target
- rollback expectation is explicitly "rebuild if needed later," not "preserve migrated state"
#### A2. Document Hawser + dockersocket as protected keepers
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- docs explicitly state Dockhand depends on `Hawser` + `dockersocket`
- future cleanup cards do not treat them as accidental leftovers
- any later rehome/removal must be paired with a replacement Dockhand management path
#### A3. Decide whether netdata remains worth keeping
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- decision recorded as one of:
- keep as host-local monitoring until retirement
- simplify/replace with a lighter local signal
- remove after confirming broader monitoring already covers the operator need
- if changed, verification includes equivalent host visibility from the replacement path
### Epic B — Preserve Serenity's temporary intentional roles
#### B1. Keep the media-locality lane stable on Serenity
Status: locked
Depends on: none
Acceptance criteria:
- no partial migration of qbit/ARR lane is attempted before storage design is ready
- docs keep the lane grouped as an intentional temporary unit
- future cards treat locality breakage as a rollback trigger, not a minor warning
#### B2. Keep Hawser + dockersocket in place for Dockhand
Status: locked
Depends on: none
Acceptance criteria:
- `Hawser` and `dockersocket` remain healthy on Serenity
- Dockhand remote management path still works for the remaining Serenity stacks
- no proposal to move them is executed without a replacement management path
#### B3. Keep Serenity-local Newt until Pangolin redesign exists
Status: locked
Depends on: none
Acceptance criteria:
- Pangolin-routed Serenity resources continue working
- no retirement of Serenity-local Newt is attempted early
- redesign work is handled as its own lane, not hidden inside app migration cards
#### B4. Keep backup Technitium role on Serenity until replacement exists
Status: locked
Depends on: none
Acceptance criteria:
- off-PD DNS failure domain remains intact
- no cutover collapses this role onto PD alone
- replacement host is defined and verified before Serenity DNS role is removed
#### B5. Keep reranker on Serenity until PD capacity or architecture changes
Status: locked
Depends on: none
Acceptance criteria:
- reranker remains stable where it is
- no move is attempted just for neatness
- any future move explicitly handles the live `/mnt/user/appdate/reranker` path oddity
### Epic C — Optional tiny near-term moves
#### C1. Re-evaluate Notifiarr against live PD headroom
Status: completed 2026-05-26
Depends on: none
Acceptance criteria:
- current PD RAM/storage headroom is checked at execution time
- clear recommendation recorded: move now, defer, or drop the idea
- if moved, verification proves app health plus any expected integrations still work
### Epic D — Replace Serenity special-case roles
#### D1. Design the post-Serenity backup DNS lane
Status: planned
Depends on: none
Target direction:
- dedicated Raspberry Pi 4B backup resolver
Acceptance criteria:
- target host chosen and documented
- role is explicitly outside PD's failure domain
- expected sync, secrets, and verification model are documented before cutover
#### D2. Build and validate the Pi 4B backup resolver
Status: blocked
Depends on: D1
Acceptance criteria:
- Pi 4B resolver is online and documented
- sync path is defined
- LAN clients can resolve through it as expected
- it is clearly not dependent on PD for local authoritative continuity
#### D3. Redesign Pangolin/Newt dependency
Status: planned
Depends on: none
Acceptance criteria:
- Serenity-hosted access path no longer requires Serenity-local Newt
- replacement routing model is documented
- cutover plan includes rollback and validation steps
#### D4. Re-evaluate reranker final home
Status: planned
Depends on: none
Acceptance criteria:
- host choice made based on live resource reality and AI architecture, not symmetry
- migration plan explicitly preserves existing data path and naming oddity handling
### Epic E — Majority migration after storage ownership changes
#### E1. Define PD storage cutover model
Status: blocked
Depends on: none
Acceptance criteria:
- exact target datasets/paths on PD are documented
- ownership, mount semantics, and performance assumptions are explicit
- migration no longer depends on NFS-style cross-host path fakery
#### E2. Validate media/torrent path behavior on the target model
Status: blocked
Depends on: E1
Acceptance criteria:
- download path behavior validated
- import path behavior validated
- hardlink or equivalent behavior validated
- post-processing behavior validated
- ARR/qbit path mapping is internally consistent
#### E3. Move qbit + Gluetun + qbit_manage
Status: blocked
Depends on: E2
Acceptance criteria:
- downloader lane works on the target without Serenity path dependence
- VPN and management behavior are verified
- rollback window is defined before source teardown
#### E4. Move ARR + helper lane
Status: blocked
Depends on: E3
Includes:
- `prowlarr`
- `sonarr`
- `sonarr-anime`
- `radarr`
- `lidarr`
- `readarr`
- `readarr-epub`
- `bazarr`
- `autobrr`
- `unpackerr`
- `shelfmark`
- optional `Notifiarr`
Acceptance criteria:
- libraries remain visible
- imports and automation continue working
- no stale Serenity-local path assumptions remain in configs
#### E5. Post-cutover soak and verification
Status: blocked
Depends on: E4
Acceptance criteria:
- successful download/import verification exists
- operator-facing health paths are normal
- rollback confidence window passes without hidden path regressions
### Epic F — Final Serenity retirement
#### F1. Remove remaining special roles from Serenity
Status: blocked
Depends on: D2, D3, D4, E5
Acceptance criteria:
- no production path still depends on Serenity DNS role, Serenity-local Newt, or Serenity reranker placement
- any retained backups are documented and off the critical path
#### F2. Retire Serenity
Status: blocked
Depends on: F1
Acceptance criteria:
- no intentional production app remains
- no unique resolver or tunnel role remains
- docs describe Serenity as retired rather than transitional
## Suggested execution order
If only a few cards should move soon, use this order:
1. A1 — Retire Wizarr
2. A2 — Lock Hawser/dockersocket as intentional keepers
3. A3 — Decide netdata end-state
4. C1 — Re-evaluate Notifiarr only if there is real benefit
5. D1/D2 — Create the replacement backup-DNS lane on Pi 4B
6. D3/D4 — redesign special cases
7. E1-E5 — execute the true majority migration only after PD storage ownership changes
8. F1/F2 — retire Serenity
## Recommendation
Do not force a near-term "move the majority now" project.
The correct near-term board is:
- retire dead weight
- keep the locality lane intact
- keep Dockhand's Hawser path intact
- build the future DNS replacement lane
- wait for the storage and capacity conditions that make the real majority move sane

View File

@@ -5,14 +5,17 @@
- [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15
## Infrastructure
- [x] Deploy Pi-hole HA on PD + N.O.M.A.D. with VIP `10.5.30.53` — completed 2026-05-15; historical Trusted-VLAN design, later superseded after NOMAD moved to Servers (`10.5.30.7`) and its Trusted-side Keepalived role was disabled pending a same-subnet HA redesign; see docs/planning/DEPLOY_PIHOLE.md
- PD primary live at `/mnt/docker-ssd/docker/compose/pihole`
- NOMAD backup live at `/opt/pihole-nomad`
- [x] Deploy Pi-hole HA on PD + N.O.M.A.D. with VIP `10.5.30.53` — completed 2026-05-15; historical design later superseded by the Technitium trio. Both the PD runtime at `/mnt/docker-ssd/docker/compose/pihole` and the NOMAD runtime at `/opt/pihole-nomad` were retired on 2026-05-27; see docs/planning/DEPLOY_PIHOLE.md
- PD primary retired 2026-05-27 after local resolver cutover to Technitium (`10.5.30.8/.9/.10` + `9.9.9.9`)
- NOMAD legacy survivor retired 2026-05-27; old VIP `10.5.30.53` no longer answers
- RPi4 remains optional future BACKUP2 work if you want a third node later
- [x] Deploy Kima-Hub — completed 2026-05-06; docs/planning/DEPLOY_KIMA_HUB.md now serves as deployment/repair reference
- [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity
- [x] Remove dead Unraid-Cloudflared-Tunnel container from Serenity — completed 2026-05-25
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
- [ ] Design post-Serenity backup DNS lane (target: Pi 4B backup resolver outside PD's failure domain)
- [ ] Redesign Pangolin/Newt so Serenity resources no longer require Serenity-local Newt
- [ ] Define PD storage cutover model for qBittorrent/ARR locality migration off Serenity
- [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15
- [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15
- [ ] Install fresh editor on PD

View File

@@ -47,8 +47,10 @@ Tech stack: FastAPI, Jinja2, JSON state store for V1 bootstrap, Docker Compose o
- Homepage now renders current metrics, recent workouts, exercise bests, and placeholder template/routine scaffolding.
- Homepage now renders John's imported five-day split as the default routine board.
- Homepage now includes mobile-friendly HTML forms for quick weight entry, body measurements, exercise templates, routine drafts, and quick workout logging.
- Routine log sheets now support dropdown exercise selection with set-by-set rep and weight entry for seeded program days.
- Live workout pages now let John start a session and save one exercise at a time mid-workout before finishing the routine into history.
- Homepage now includes John-focused recent history snapshots so the app is useful before charts exist.
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, progression summaries, homepage render, and form-post redirects.
- Tests currently cover health, seeded users, BMI calculation, imperial measurement logging, exercise template creation, routine creation, history ordering, structured workout logging, live workout session flows, progression summaries, homepage render, routine log flows, and form-post redirects.
## Next build slices

View File

@@ -1,9 +1,14 @@
# Meshtastic Stack
## Overview
Self-hosted Meshtastic monitoring and management stack running on PlausibleDeniability.
Self-hosted MeshMonitor stack running on PlausibleDeniability, plus the current operator note for the separate NOMAD-side MeshCore companion observer.
Related NOMAD-side bridge note: a separate MeshCore-to-MQTT relay is running on N.O.M.A.D. as Docker container `mctomqtt` (`mctomqtt:latest`), with config bind-mounted from `/etc/mctomqtt`. The package also includes a systemd unit under `/opt/mctomqtt`, but that is not the active runtime on this host.
Related NOMAD-side bridge note: the legacy `mctomqtt` Docker relay was retired on 2026-06-13. N.O.M.A.D. now uses the LetsMesh companion observer flow via `meshcore-capture.service`, running from `/home/fizzlepoof/.meshcore-packet-capture` and connecting directly to the Heltec over `/dev/serial/by-id/...`.
Important split:
- **PD** hosts the MeshMonitor web/UI stack.
- **NOMAD** owns the USB-attached Heltec companion observer via `meshcore-capture.service`.
- Do not assume MeshMonitor and the companion observer are the same runtime path.
## Stack Location
`/mnt/docker-ssd/docker/compose/meshtastic/`
@@ -12,29 +17,43 @@ Related NOMAD-side bridge note: a separate MeshCore-to-MQTT relay is running on
| Container | Image | Port | Purpose |
|-----------|-------|------|---------|
| meshmonitor | ghcr.io/yeraze/meshmonitor:4.2.0 | 8081→3001 | Main UI + backend |
| meshmonitor (virtual node) | — | 4404 | TCP virtual node server |
| meshmonitor-tileserver | maptiler/tileserver-gl-light | 8082→8080 | Offline map tiles |
| meshmonitor-mqtt-proxy | ghcr.io/ln4cy/mqtt-proxy:master | — | MQTT→TCP bridge |
| meshmonitor | ghcr.io/yeraze/meshmonitor:latest | 8081→3001, 4404→4404, 1883→1883 | Main UI + backend; live compose publishes the virtual-node port and embedded MQTT broker directly from this container, and now maps `/dev/ttyACM0` into the container for native MeshCore USB sources |
| meshmonitor (virtual node) | — | 4404 | TCP virtual node server exposed directly by the meshmonitor container |
| meshmonitor (embedded MQTT broker) | — | 1883 | Embedded Aedes-based MQTT broker exposed directly by the meshmonitor container |
| meshmonitor-tileserver | maptiler/tileserver-gl-light:latest | 8082→8080 | Offline map tiles |
| meshmonitor-upgrader | docker:latest | — | Auto-upgrade watchdog |
Live runtime verified on 2026-07-03:
- `docker compose ps` on PD showed `meshmonitor`, `meshmonitor-tileserver`, and `meshmonitor-upgrader` running.
- Repo compose and live runtime now agree that there is no separate `meshmonitor-mqtt-proxy` sidecar in the active stack.
- The embedded MQTT broker source `Meshcore Peachtree Observer` is configured in MeshMonitor and logs `MQTT broker listening on 0.0.0.0:1883`.
- A native MeshCore USB source `PT Meshcore` is now auto-connected in MeshMonitor via `/dev/ttyACM0` after the compose file was updated to map the device into the container.
- NOMAD `meshcore-capture.service` was later returned to its three upstream brokers after the embedded-broker test path proved unnecessary for real MeshCore ingestion.
## Architecture
```
Meshtastic Node (10.5.1.172)
MQTT
meshmonitor-mqtt-proxy
↓ TCP (port 4404)
meshmonitor virtual node server
meshmonitor backend
Trusted-LAN mesh node (10.5.1.120)
direct node connection configured by MESHTASTIC_NODE_IP
meshmonitor backend on PD
PostgreSQL (shared-postgres, db: meshmonitor)
Separate path:
NOMAD Heltec companion observer (`meshcore-capture.service`)
↓ upstream MQTT brokers (LetsMesh US / EU + NashMe)
PD native MeshCore USB source (`PT Meshcore`)
↓ `/dev/ttyACM0` mapped into the meshmonitor container
↓ meshcore.js native companion backend
```
Note: the embedded MeshMonitor broker now accepts the NOMAD connection on `10.5.30.6:1883`, but current MeshMonitor logs still show protobuf decode warnings for MeshCore packet payloads. Treat this as confirmed broker fanout connectivity, not yet confirmed first-class MeshCore ingestion.
## Node Connection
- Node IP: `10.5.1.172`
- Connection type: MQTT via mqtt-proxy (not direct TCP)
- mqtt-proxy connects to meshmonitor's virtual node server on port 4404
- PD MeshMonitor compose is currently configured with `MESHTASTIC_NODE_IP=10.5.1.120`
- MeshMonitor itself publishes port `4404` for the virtual-node path and `1883` for the embedded MQTT broker; there is no separate `meshmonitor-mqtt-proxy` service in the active stack
- For native MeshCore USB sources on PD, the live compose now maps `/dev/ttyACM0` directly into the `meshmonitor` container
- NOMAD's companion observer is a different ingest path and should not be documented as a MeshMonitor sidecar
## Storage
- Appdata: `/mnt/tank/docker/appdata/meshmonitor/data``/data`
@@ -60,7 +79,7 @@ mkdir -p /mnt/docker-ssd/docker/compose/meshtastic/scripts
## Known Warnings
- Node `!dd972536` has a low-entropy public key — this is a device configuration issue, not a meshmonitor bug
- Frequent connect/disconnect cycles in logs are normal when the MQTT proxy reconnects
- MeshMonitor logs can still show noisy reconnect/admin-session churn during node or radio instability; do not confuse that with the retired NOMAD `mctomqtt` path
## Tiles
Map tiles stored in `./tiles/`. Currently includes `zurich_switzerland.mbtiles` (34MB).
@@ -68,5 +87,4 @@ Tiles directory is gitignored. To add new tiles, download `.mbtiles` files and p
## TODO
- [ ] Document all running meshtastic-map container config
- [ ] Document meshtastic-map-mosquitto and meshtastic-mqtt containers
- [ ] Add healthcheck to meshmonitor container

View File

@@ -213,43 +213,40 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
- The live `.env` is also backed up into the encrypted PD secrets repo as `env/technitium-nomad.env`
- Same-host checks from NOMAD to `10.5.30.9` are unreliable because the resolver sits behind macvlan networking; verify from another LAN peer instead
### MeshCore to MQTT relay
- Install date: 2026-05-18
- Install root: `/opt/mctomqtt`
- Config root: `/etc/mctomqtt`
- Service account: `mctomqtt:mctomqtt`
- Installer metadata: `.version_info` reports `installer_version: 1.3.0.0-preview`, repo `Cisien/meshcoretomqtt`, branch `main`, commit `10f7c0e`
- Detected install type marker: `/opt/mctomqtt/.install_type` contains `docker`
- Runtime model:
- Active runtime is a standalone Docker container named `mctomqtt`
- Image: `mctomqtt:latest`
- Command: `python3 /opt/mctomqtt/mctomqtt.py`
- User: `mctomqtt`
- Restart policy: `unless-stopped`
- Network mode: `bridge`
- Config bind mount: `/etc/mctomqtt:/etc/mctomqtt:ro`
- Device passthrough: the Heltec serial device is mapped directly into the container at the same `/dev/serial/by-id/... ` path
- Systemd note:
- The packaged repo also includes `/opt/mctomqtt/mctomqtt.service`, but it is not the active runtime on NOMAD
- No installed unit was present in `/etc/systemd/system` or `/lib/systemd/system`
- The bundled unit expects `/opt/mctomqtt/venv/bin/python3`, but no `venv/` directory exists under `/opt/mctomqtt`
- Treat the systemd unit as an unused package artifact for this host unless the deployment model changes later
- Config layering:
- Base reference: `/etc/mctomqtt/config.toml`
- Preset overlay: `/etc/mctomqtt/config.d/10-meshmapper.toml`
- Local overrides: `/etc/mctomqtt/config.d/99-user.toml`
- Current local overrides observed:
- IATA region code set to `BNA`
- Serial device pinned to `/dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__441BF670B684-if00`
- Enabled broker targets include MeshMapper, LetsMesh US, and NashMe
- Credentials/secrets are stored inline in `99-user.toml`; do not mirror them into git-backed docs
- Container health observed during inspection:
- Logs show the relay loading all three config layers, opening the Heltec serial device, and connecting successfully to MeshMapper, NashMe, and LetsMesh US after restart
- The current container had been up about 5 minutes during inspection with `MQTT: 3/3`, zero failures, and live RX air-time reported
- An earlier start logged one DNS resolution failure for a broker label shown as `custom-2`, but the current running container recovered and all three named brokers connected cleanly
- Container logs / inspection:
- Use `docker logs mctomqtt` for runtime output
- Use `docker inspect mctomqtt` for bind mounts, device mappings, and restart policy
### LocalSend Trusted inbox
- Live stack path: `/opt/localsend-nomad`
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad`
- Trusted bind IP: `10.5.1.16`
- Advertised device name: `Doris Trusted Inbox`
- Role: headless LocalSend receiver for Trusted-LAN handoff into `/home/fizzlepoof/private/inbox-secrets`
- Runtime model: standalone Docker Compose stack on a Trusted-LAN macvlan (`enp5s0.51`, VLAN 51) while the host stays on Servers (`10.5.30.7`)
- LocalSend listener ports: `53317/tcp`, `53317/udp`
- Same-host checks from NOMAD to `10.5.1.16` are unreliable because the receiver sits behind macvlan networking; verify from another Trusted-LAN peer instead
- Inbox watcher for Minerva staging: `minerva-localsend-autosort.path`
- Sorter service: `minerva-localsend-autosort.service`
- Sorter script: `/home/fizzlepoof/.local/bin/minerva-localsend-autosort.py`
### MeshCore companion observer
- Current runtime: `meshcore-capture.service` (systemd)
- Install root: `/home/fizzlepoof/.meshcore-packet-capture`
- Service unit: `/etc/systemd/system/meshcore-capture.service`
- Runtime command: `/home/fizzlepoof/.meshcore-packet-capture/venv/bin/python3 /home/fizzlepoof/.meshcore-packet-capture/packet_capture.py`
- Connection mode: direct serial on the Heltec via `/dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__441BF670B684-if00`
- Observer role:
- NOMAD now uses the LetsMesh companion observer flow instead of the legacy `mctomqtt` relay
- The companion process connects to the Heltec locally, signs JWTs on-device, and publishes packet capture data upstream to MQTT brokers
- Service health observed on 2026-07-03:
- `systemctl status meshcore-capture` shows the service active/running
- Logs confirm successful serial connection to device name `Peachtree Mesh Monitor`
- Logs confirm MQTT connectivity to LetsMesh US, LetsMesh EU, and NashMe
- After removing the temporary PD embedded-broker test target, startup logs now show `Connected to 3 MQTT broker(s)`
- Packet capture mode is enabled and waiting for packets
- Initial-start caveat:
- One installer-start attempt failed with `No such file or directory` for the `/dev/serial/by-id/...` path
- A subsequent manual `systemctl start meshcore-capture` succeeded once the device path was present again
- Legacy relay retirement:
- The old standalone Docker container `mctomqtt` was retired on 2026-06-13
- Backup of the retired relay files/config was saved under `/home/fizzlepoof/private/backups/mctomqtt-retire/20260613-174536`
- Documentation linkage:
- Related mesh stack notes live in [MESHTASTIC.md](../reference/MESHTASTIC.md)

View File

@@ -47,9 +47,10 @@ Recommended models for 11GB VRAM:
| `phi4` | ~8GB | Analytical tasks |
Current light-tier notes:
- PD Ollama is the active light-tier endpoint for LiteLLM on `http://10.5.30.6:11434`
- Local Honcho currently points at PD Ollama over LAN (`http://10.5.30.6:11434/v1`) instead of using a separate local model host
- Current Honcho routing split keeps embeddings on `nomic-embed-text:latest`, cheap/default paths on `qwen2.5:7b`, and deeper high/max paths on `qwen2.5:14b`
- PD Ollama remains the shared light-tier endpoint for the broader AI stack on `http://10.5.30.6:11434`, but it should be protected from routine Honcho background load.
- Local Honcho now uses N.O.M.A.D.'s own Ollama on `http://127.0.0.1:11434/v1` so PD pressure does not take down MeshMonitor or shared Postgres.
- Current Honcho routing keeps embeddings on `nomic-embed-text:v1.5`, cheap/default paths on `qwen2.5:3b`, and high/max paths on `llama3.1:latest`.
- Rocinante is John's PC and should be treated as an opportunistic/manual heavy tier, not a dependable always-on backend.
## Remote Access
- Pangolin VPS + Newt for external services

View File

@@ -24,14 +24,15 @@ Ollama runs on port `11434` and hosts the large-model ("heavy") tier.
| `qwen2.5:32b` | Large-context tasks |
| `gemma3:27b` | Multimodal / vision |
> **LiteLLM tier:** Rocinante is the **"heavy"** tier in LiteLLM `config.yaml`.
> Heavy-tier requests from OpenWebUI and n8n route to `http://10.5.30.112:11434`.
> **LiteLLM tier:** Rocinante may serve as an **opportunistic heavy/manual tier** when the PC is online.
> Do not depend on `http://10.5.30.112:11434` for routine background routing; verify live reachability first and prefer N.O.M.A.D.-local paths for guaranteed availability.
## Current operational note
- During Doris validation from N.O.M.A.D. on 2026-05-21, `10.5.30.112` did not answer ping and did not accept connections on `11434` or `8787`.
- Treat Rocinante-hosted Ollama heavy-tier traffic and CUDA Whisper as currently unreachable from N.O.M.A.D. until host/network reachability is restored.
- Re-validate from N.O.M.A.D. with `curl http://10.5.30.112:11434/api/tags` and `curl http://10.5.30.112:8787/health` after recovery.
- Rocinante is John's personal PC and is not a dependable always-on homelab tier.
- Treat Ollama/Whisper on Rocinante as opportunistic capacity when the machine is online, not as a required background dependency.
- If N.O.M.A.D. or PD need guaranteed inference, prefer local N.O.M.A.D. routing first and keep PD protected from routine background load.
- Re-check live reachability from N.O.M.A.D. before any Rocinante-dependent maintenance or cutover.
## Notes

View File

@@ -45,12 +45,13 @@ Text Embeddings Inference reranker for OpenWebUI RAG pipeline.
| **Model** | `BAAI/bge-reranker-v2-m3` |
| **Image** | `ghcr.io/huggingface/text-embeddings-inference:cpu-latest` |
| **Port** | `9787` |
| **Volume** | `/mnt/user/appdata/reranker` |
| **Volume** | `/mnt/user/appdate/reranker` |
| **LiteLLM model name** | `reranker` |
| **API base** | `http://10.5.30.5:9787` |
> Moved from PlausibleDeniability (port 8787) to Serenity (port 9787) on 2026-05-09.
> CPU-only inference is acceptable for RAG reranking workloads.
> Live runtime currently uses `/mnt/user/appdate/reranker`; preserve that path as-is until a deliberate migration normalizes it.
### ARR Stack
@@ -71,10 +72,14 @@ Serenity hosts the secondary backup Technitium node on `10.5.30.10`.
- No GPU; reranker uses CPU-only TEI image
- Tailscale active — reachable at `100.94.87.79` from any Tailscale node
- Local Serenity DB containers for the old GameVault and RomM stacks (`postgresql15` and `MariaDB-Official`) were retired on 2026-05-25 after both apps validated healthy on PD.
- The stale stopped source app containers for those old stacks were cleaned out afterward; rollback now lives in the retained appdata plus `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531` rather than in lingering `docker ps -a` entries.
- Cooldown/rollback artifacts for that retirement were retained on Serenity under `/mnt/user/backups/doris/serenity-d7-db-retire-20260525-213531`, and the original DB appdata paths were left in place rather than deleted immediately.
## Planned future role
- Transitional only; do not design new long-term dependencies around Serenity.
- Near-term it continues to host storage locality, reranker, the backup Technitium node, and the Serenity-local Newt needed for Pangolin access to Serenity resources.
- Near-term cleanup already retired `Wizarr`, keeps `Hawser` + `dockersocket` because Dockhand depends on them for remote management of Serenity's remaining Docker stacks, and intentionally leaves `Notifiarr` and `netdata` on Serenity for now because PD headroom and host-local monitoring value do not justify an early move.
- Once PD directly owns the storage, qBittorrent/ARR locality should move to PD and Serenity should be drained and retired.
- Before any Pangolin cleanup, re-home Serenity resources away from stale literal-IP targets; live Newt logs show repeated health checks to old `10.5.1.5` addresses.
- The stale Pangolin/Newt health-check drift to `10.5.1.5` was fixed on 2026-05-25; future Newt work is now a deliberate rehome/retirement design task rather than an active incident.

View File

@@ -42,6 +42,15 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- DB: user=`openwebui`, db=`openwebui` on shared-postgres
- If restart-looping with auth errors: container wasn't on `ix-databases_shared-databases` at creation time — must `down && up`
### qdrant
- If the container has to be manually recreated from `docker inspect` because compose labels are stale or missing, treat that as emergency stabilization only, not final cleanup
- After emergency recovery, immediately reconcile the runtime back to `/mnt/docker-ssd/docker/compose/ai/docker-compose.yaml`, back up the live state, and verify the container labels again before calling the incident closed
- The container being `Up` is not enough; also confirm `com.docker.compose.project.working_dir`, `com.docker.compose.project.config_files`, and `/healthz`
### seerr
- After adjacent media-stack incident work, verify both LAN/public availability and that the container still points at `/mnt/docker-ssd/docker/compose/media/docker-compose.yaml`
- Stale `*.pre-*` compose backups do not belong in the live compose directory long-term; back them up elsewhere, then prune them to reduce operator ambiguity
### plex
- Port `5353/udp` conflicts with system avahi/mDNS — remove from port mappings
@@ -59,6 +68,12 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
- `docker restart` or `docker compose restart` will NOT fix missing network attachments
- Must use `docker compose --env-file .env down <service> && docker compose --env-file .env up -d <service>`
### UniFi Protect WiFi chimes after doorbell/network changes
- A UniFi Protect WiFi chime can look healthy in UniFi Network while still showing offline in Protect if a per-client Camera virtual-network override puts it on a lane that does not preserve the required Protect path.
- In John's current environment, the known-good fallback for the two Wi-Fi chimes is Management/default `UniFi Wireless`, not the Camera-lane override that was tried on 2026-05-22.
- After a doorbell is re-adopted, also verify each chime's `ringSettings.cameraId` still points at the current doorbell object; a stale camera binding can break ringing even when the network path is healthy.
- Treat "device online in UniFi Network" and "device healthy in Protect / rings for the current doorbell" as separate checks.
---
## Serenity

View File

@@ -1,7 +1,7 @@
TZ=America/Chicago
# Private DNS names for the pilot. Add matching records in Technitium before device enrollment.
HEADSCALE_SERVER_URL=http://headscale.home.paccoco.com:8084
# Public control-plane hostname for off-LAN clients.
HEADSCALE_SERVER_URL=https://headscale.paccoco.com
HEADPLANE_BASE_URL=http://headplane.home.paccoco.com:3005
# Must be replaced with an actual 32-character secret before deployment.

View File

@@ -21,14 +21,26 @@ Self-hosted Headscale + Headplane pilot stack for replacing the Tailscale free-t
- `/mnt/docker-ssd/docker/appdata/headplane`
- No OIDC on day one
- No subnet-router or exit-node rollout on day one
- No public Pangolin exposure on day one
- Headscale can be published later as a dedicated public control-plane hostname without exposing Headplane the same way
## URLs for the pilot
- Headscale control plane: `http://headscale.home.paccoco.com:8084`
- Headplane UI: `http://headplane.home.paccoco.com:3005/admin`
- Headscale control plane for clients: `https://headscale.paccoco.com`
- Headplane admin UI (restricted/LAN-only): `http://headplane.home.paccoco.com:3005/admin`
These are intentionally LAN/private-DNS endpoints for the pilot. If you later want Traefik/Pangolin exposure, use `examples/traefik-routes.yml` as a starting point instead of changing the pilot shape first.
This is the recommended **Option C** shape:
- publish **Headscale** on a stable public HTTPS hostname so phones and laptops can enroll/use it off-LAN
- keep **Headplane** off the public internet by default; treat it as an admin surface reached on LAN/private DNS (or a separately restricted admin path later)
The internal Traefik example in `examples/traefik-routes.yml` is still useful for private-DNS/LAN convenience, but it is not the public control-plane path for off-LAN device enrollment.
### Current live pilot note
Before the public hostname is wired, the live PD pilot uses direct-IP URLs:
- Headscale: `http://10.5.30.6:8084`
- Headplane: `http://10.5.30.6:3005/admin`
Once `https://headscale.paccoco.com` is created and verified through Pangolin, off-LAN clients should use that public Headscale URL. Headplane can stay on the restricted LAN/private-DNS path above.
## Initial users
@@ -70,9 +82,15 @@ Before first deploy, replace the placeholder values in:
Required changes:
- replace `CHANGE_ME_TO_EXACTLY_32_CHARS` with a real 32-character cookie secret
- confirm the hostnames/IP-backed DNS records exist in Technitium
- confirm the restricted Headplane LAN/private-DNS record exists if you want the nicer admin hostname
- for off-LAN clients, create/verify the public Pangolin hostname for Headscale
- if you want different ports or hostnames, update both the config files and `.env`
Important live-ops note:
- the repo-tracked `config/headplane/config.yaml` intentionally keeps a placeholder `cookie_secret`
- before restarting the live Headplane container, render or restore the real 32-character secret from the live secret-bearing `.env`
- blindly syncing the tracked placeholder over the live config will make Headplane fail startup with `server.cookie_secret must be exactly length 32`
## Deploy on PD
1. Sync this directory into the live compose tree:
@@ -124,6 +142,26 @@ docker logs headplane --tail=100
11. Register one tagged service node.
12. Enroll one Manndra device.
## Public Headscale via Pangolin
For the off-LAN/mobile path, publish only Headscale itself.
Desired public hostname:
- `https://headscale.paccoco.com`
Desired Pangolin target shape:
- resource name: `headscale`
- site: `Plausible Deniability` (siteId `4`)
- target: `headscale:8080`
- healthcheck path: `/health`
- Pangolin auth/SSO: **disabled** for this route
Why:
- Headscale clients need a plain reachable control-plane endpoint; adding browser SSO in front of it is the wrong shape
- Headplane is an admin UI and should stay restricted/admin-only instead of being published like a normal household app
Use `automation/bin/pangolin_upsert_headscale.py` to create or reconcile the public Pangolin resource.
## Policy reload
After editing `config/headscale/policy.hujson`:

View File

@@ -9,18 +9,20 @@ server:
headscale:
url: "http://headscale:8080"
public_url: "http://headscale.home.paccoco.com:8084"
public_url: "https://headscale.paccoco.com"
config_path: "/etc/headscale/config.yaml"
config_strict: true
integration:
agent:
enabled: false
pre_authkey: ""
docker:
enabled: true
container_label: "me.tale.headplane.target=headscale"
socket: "unix:///var/run/docker.sock"
kubernetes:
enabled: false
pod_name: ""
proc:
enabled: false

View File

@@ -1,4 +1,4 @@
server_url: http://headscale.home.paccoco.com:8084
server_url: https://headscale.paccoco.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 0.0.0.0:50443

View File

@@ -20,16 +20,14 @@
"dst": [
"tag:infra:*",
"tag:apps:*",
"tag:admin:*",
"autogroup:self:*"
"tag:admin:*"
]
},
{
"action": "accept",
"src": ["group:vip"],
"dst": [
"tag:apps:*",
"autogroup:self:*"
"tag:apps:*"
]
}
],

View File

@@ -25,7 +25,7 @@ services:
- /mnt/docker-ssd/docker/appdata/headscale:/var/lib/headscale
- /mnt/docker-ssd/docker/appdata/headscale/run:/var/run/headscale
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"]
test: ["CMD", "headscale", "version"]
interval: 30s
timeout: 10s
retries: 5

View File

@@ -53,6 +53,8 @@ The current scaffold already supports:
- exercise template creation/listing
- routine creation/listing on top of John's imported base split
- structured workout logging with nested exercises and sets
- dedicated routine log sheets with dropdown exercise selection plus set-by-set reps and weight entry
- live workout pages that let John start a session and save one exercise at a time mid-workout
- history endpoints for weight, measurements, and workouts
- exercise progression summaries with max weight and estimated 1RM per exercise
- mobile-friendly homepage with HTML forms for:

View File

@@ -61,7 +61,7 @@ class RoutineCreate(BaseModel):
class WorkoutSetCreate(BaseModel):
reps: int = Field(ge=1)
weight_lbs: float = Field(ge=0)
weight_lbs: float | None = Field(default=None, ge=0)
rpe: float | None = Field(default=None, ge=0)
rest_seconds: int | None = Field(default=None, ge=0)
notes: str | None = None

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
@@ -121,9 +123,136 @@ def quick_log_workout(
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
@router.get('/routines/{routine_id}/log')
def routine_log_sheet(request: Request, routine_id: str):
routine = _get_routine_or_404(request, routine_id)
return templates.TemplateResponse(
request=request,
name='routine_log.html',
context={
'title': f"Log {routine['name']}",
'routine': routine,
'routine_sheet': _build_routine_log_sheet(request, routine),
'exercise_options': _exercise_options_for_routine(request, routine),
'routine_action_label': _routine_action_label(routine['name']),
'default_performed_at': _default_performed_at_value(),
},
)
@router.get('/routines/{routine_id}/live')
def live_workout_logger(request: Request, routine_id: str):
routine = _get_routine_or_404(request, routine_id)
return templates.TemplateResponse(
request=request,
name='live_workout.html',
context=_live_workout_context(request, routine),
)
@router.post('/routines/{routine_id}/live/start', status_code=303)
def start_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/add-exercise', status_code=303)
async def add_live_workout_exercise(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
exercise_name: str = Form(...),
set_count: int = Form(...),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
request.app.state.store.start_active_workout_session(
{
'routine_id': routine['id'],
'routine_name': routine['name'],
'user_id': user_id,
'performed_at': performed_at,
}
)
exercise = await _parse_live_workout_exercise_form(request, routine, exercise_name, set_count)
request.app.state.store.add_active_workout_exercise(routine_id, user_id, exercise)
return RedirectResponse(url=f"/routines/{routine_id}/live", status_code=status.HTTP_303_SEE_OTHER)
@router.post('/routines/{routine_id}/live/finish', status_code=303)
def finish_live_workout_session(
request: Request,
routine_id: str,
user_id: str = Form(...),
performed_at: str = Form(...),
notes: str = Form(''),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
active_session = request.app.state.store.get_active_workout_session(routine_id, user_id)
if active_session is None:
raise HTTPException(status_code=400, detail='No live workout session in progress.')
if active_session['performed_at'] != performed_at:
raise HTTPException(status_code=400, detail='Performed-at timestamp does not match the active session.')
if not active_session.get('exercises'):
raise HTTPException(status_code=400, detail='Log at least one exercise before finishing the session.')
request.app.state.store.finish_active_workout_session(routine_id, user_id, notes.strip() or None)
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
@router.post('/workouts/routine-log', status_code=303)
async def log_seeded_routine_workout(
request: Request,
user_id: str = Form(...),
routine_id: str = Form(...),
performed_at: str = Form(...),
routine_name: str = Form(...),
exercise_count: int = Form(...),
notes: str = Form(''),
):
routine = _get_routine_or_404(request, routine_id)
if routine['user_id'] != user_id:
raise HTTPException(status_code=400, detail='Routine does not belong to that user.')
payload = {
'user_id': user_id,
'performed_at': performed_at,
'routine_name': routine_name.strip(),
'notes': notes.strip() or None,
'exercises': await _parse_structured_routine_form(request, routine, exercise_count),
}
request.app.state.store.add_workout(payload)
return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER)
def _dashboard_context(request: Request) -> dict:
store = request.app.state.store
summary = store.dashboard_summary()
routines_with_live_state = []
for routine in summary['routines']:
active_session = store.get_active_workout_session(routine['id'], routine['user_id'])
routines_with_live_state.append({**routine, 'live_session': active_session})
summary['routines'] = routines_with_live_state
return {
'title': 'Doris Barbell',
'summary': summary,
@@ -134,6 +263,49 @@ def _dashboard_context(request: Request) -> dict:
}
def _live_workout_context(request: Request, routine: dict) -> dict:
active_session = request.app.state.store.get_active_workout_session(routine['id'], routine['user_id'])
routine_sheet = _build_routine_log_sheet(request, routine)
remaining_exercises = _remaining_live_exercises(routine_sheet, active_session)
logged_exercises = []
if active_session is not None:
for exercise in active_session.get('exercises', []):
logged_exercises.append(
{
'exercise_name': exercise['exercise_name'],
'set_count': len(exercise.get('sets', [])),
'notes': exercise.get('notes'),
}
)
return {
'title': f"Live log · {routine['name']}",
'routine': routine,
'routine_sheet': routine_sheet,
'active_session': active_session,
'logged_exercises': logged_exercises,
'remaining_exercises': remaining_exercises,
'default_performed_at': active_session['performed_at'] if active_session else _default_performed_at_value(),
'routine_action_label': _routine_action_label(routine['name']),
}
def _remaining_live_exercises(routine_sheet: list[dict], active_session: dict | None) -> list[dict]:
completed_counts: dict[str, int] = {}
if active_session is not None:
for exercise in active_session.get('exercises', []):
normalized_name = exercise['exercise_name'].strip().lower()
completed_counts[normalized_name] = completed_counts.get(normalized_name, 0) + 1
remaining = []
for exercise in routine_sheet:
normalized_name = exercise['exercise_name'].strip().lower()
completed_count = completed_counts.get(normalized_name, 0)
if completed_count > 0:
completed_counts[normalized_name] = completed_count - 1
continue
remaining.append(exercise)
return remaining
def _optional_float(value: str) -> float | None:
cleaned = value.strip()
if not cleaned:
@@ -208,3 +380,203 @@ def _parse_workout_exercises(raw_lines: str) -> list[dict]:
if not exercises:
raise HTTPException(status_code=400, detail='At least one workout exercise is required.')
return exercises
def _get_routine_or_404(request: Request, routine_id: str) -> dict:
routine = request.app.state.store.get_routine(routine_id)
if routine is None:
raise HTTPException(status_code=404, detail='Unknown routine')
return routine
def _build_routine_log_sheet(request: Request, routine: dict) -> list[dict]:
equipment_by_name = _exercise_equipment_lookup(request)
sheet = []
for exercise_index, exercise in enumerate(routine.get('exercises', [])):
exercise_name = exercise['exercise_name']
target_sets = max(int(exercise.get('target_sets', 1) or 1), 1)
default_reps = _default_reps_value(exercise.get('target_reps'))
is_band = equipment_by_name.get(exercise_name.strip().lower()) == 'band'
sheet.append(
{
'exercise_index': exercise_index,
'exercise_name': exercise_name,
'target_sets': target_sets,
'target_reps': exercise.get('target_reps'),
'default_reps': default_reps,
'notes': exercise.get('notes'),
'is_band': is_band,
'sets': [
{
'exercise_index': exercise_index,
'index': set_index,
'default_reps': default_reps,
'is_band': is_band,
}
for set_index in range(target_sets)
],
}
)
return sheet
def _exercise_options_for_routine(request: Request, routine: dict) -> list[str]:
routine_names = [exercise['exercise_name'] for exercise in routine.get('exercises', []) if exercise.get('exercise_name')]
template_names = [
template['name']
for template in request.app.state.store.list_exercise_templates()
if template.get('name')
]
ordered = []
seen = set()
for name in [*routine_names, *template_names]:
normalized = name.strip().lower()
if normalized in seen:
continue
ordered.append(name)
seen.add(normalized)
return ordered
def _exercise_equipment_lookup(request: Request) -> dict[str, str]:
lookup: dict[str, str] = {}
for template in request.app.state.store.list_exercise_templates():
name = str(template.get('name', '')).strip().lower()
equipment = str(template.get('equipment', '')).strip().lower()
if not name or not equipment:
continue
lookup[name] = equipment
return lookup
def _default_reps_value(target_reps: str | None) -> str:
if target_reps is None:
return ''
cleaned = str(target_reps).strip()
return cleaned if cleaned.isdigit() else ''
def _default_performed_at_value() -> str:
return datetime.now().astimezone().strftime('%Y-%m-%dT%H:%M')
def _routine_action_label(routine_name: str) -> str:
if ': ' in routine_name:
label = routine_name.split(': ', 1)[1].strip().lower()
else:
label = routine_name.strip().lower()
day_aliases = {
'shoulders': 'shoulder day',
'arms': 'arm day',
'legs': 'leg day',
}
return day_aliases.get(label, label)
async def _parse_live_workout_exercise_form(
request: Request,
routine: dict,
exercise_name: str,
set_count: int,
) -> dict:
form = await request.form()
sheet = _build_routine_log_sheet(request, routine)
matching_exercise = next(
(exercise for exercise in sheet if exercise['exercise_name'].strip().lower() == exercise_name.strip().lower()),
None,
)
if matching_exercise is None:
raise HTTPException(status_code=400, detail=f'Unknown routine exercise: {exercise_name}')
default_reps_raw = matching_exercise.get('default_reps', '')
is_band_exercise = bool(matching_exercise.get('is_band'))
sets = []
for set_index in range(set_count):
reps_raw = str(form.get(f'set_{set_index}_reps', '')).strip()
weight_raw = str(form.get(f'set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw:
if is_band_exercise and default_reps_raw.isdigit():
reps_raw = default_reps_raw
else:
continue
if not reps_raw and default_reps_raw.isdigit():
reps_raw = default_reps_raw
if not reps_raw:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
)
if not weight_raw and not is_band_exercise:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
)
try:
reps = int(reps_raw)
weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
) from exc
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
if not sets:
raise HTTPException(status_code=400, detail=f'Log at least one set for {exercise_name}.')
exercise_notes = str(form.get('exercise_notes', '')).strip() or None
return {'exercise_name': matching_exercise['exercise_name'], 'notes': exercise_notes, 'sets': sets}
async def _parse_structured_routine_form(request: Request, routine: dict, exercise_count: int) -> list[dict]:
form = await request.form()
routine_exercises = routine.get('exercises', [])
equipment_by_name = _exercise_equipment_lookup(request)
exercises = []
for exercise_index in range(exercise_count):
exercise_name = str(form.get(f'exercise_{exercise_index}_name', '')).strip()
if not exercise_name:
continue
is_band_exercise = equipment_by_name.get(exercise_name.lower()) == 'band'
try:
set_count = int(str(form.get(f'exercise_{exercise_index}_set_count', '0')).strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail='Invalid set count in routine log form.') from exc
target_reps = None
if exercise_index < len(routine_exercises):
target_reps = routine_exercises[exercise_index].get('target_reps')
default_reps_raw = _default_reps_value(target_reps)
sets = []
for set_index in range(set_count):
reps_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_reps', '')).strip()
weight_raw = str(form.get(f'exercise_{exercise_index}_set_{set_index}_weight_lbs', '')).strip()
if not reps_raw and not weight_raw:
if is_band_exercise and default_reps_raw.isdigit():
reps_raw = default_reps_raw
else:
continue
if not reps_raw and default_reps_raw.isdigit():
reps_raw = default_reps_raw
if not reps_raw:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs reps.',
)
if not weight_raw and not is_band_exercise:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} needs both reps and weight.',
)
try:
reps = int(reps_raw)
weight_lbs = None if not weight_raw else float(weight_raw)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail=f'Exercise {exercise_name} set {set_index + 1} has invalid reps or weight.',
) from exc
sets.append({'reps': reps, 'weight_lbs': weight_lbs})
if not sets:
continue
exercise_notes = str(form.get(f'exercise_{exercise_index}_notes', '')).strip() or None
exercises.append({'exercise_name': exercise_name, 'notes': exercise_notes, 'sets': sets})
if not exercises:
raise HTTPException(status_code=400, detail='Log at least one completed exercise set.')
return exercises

View File

@@ -52,7 +52,7 @@ SEEDED_EXERCISE_TEMPLATES = [
{'name': 'Upright Row', 'primary_muscle': 'shoulders', 'equipment': 'barbell'},
{'name': 'Shoulder Shrugs', 'primary_muscle': 'traps', 'equipment': 'dumbbells'},
{'name': '7-7-7 Shoulder Press', 'primary_muscle': 'shoulders', 'equipment': 'machine'},
{'name': 'Resistance Band Punch-Out Holds', 'primary_muscle': 'abs', 'equipment': 'band'},
{'name': 'Resistance Band Punch-Out Training', 'primary_muscle': 'shoulders', 'equipment': 'band'},
{'name': 'Squats', 'primary_muscle': 'legs', 'equipment': 'barbell'},
{'name': 'Leg Extensions', 'primary_muscle': 'quads', 'equipment': 'machine'},
{'name': 'Step Ups', 'primary_muscle': 'legs', 'equipment': 'bodyweight'},
@@ -96,14 +96,14 @@ SEEDED_ROUTINES = [
{'exercise_name': 'Overhead Tricep Extensions', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Incline Barbell Curls', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Isolation Bicep Curls', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Barbell Curl', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Barbell Curl', 'target_sets': 1, 'target_reps': '7-7-7'},
{'exercise_name': 'Suitcase Carry', 'target_sets': 3, 'target_reps': '10'},
],
},
{
'user_id': 'john',
'name': 'Thursday: Shoulders',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out ab holds.",
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out training.",
'exercises': [
{'exercise_name': 'Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Arnold Press', 'target_sets': 3, 'target_reps': '10'},
@@ -111,8 +111,8 @@ SEEDED_ROUTINES = [
{'exercise_name': 'Front Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Upright Row', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Shoulder Shrugs', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Resistance Band Punch-Out Holds', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 1, 'target_reps': '7-7-7'},
{'exercise_name': 'Resistance Band Punch-Out Training', 'target_sets': 3, 'target_reps': '10'},
],
},
{
@@ -155,6 +155,11 @@ DEFAULT_STATE = {
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
'active_workout_sessions': [],
}
LEGACY_TEMPLATE_ALIASES = {
'resistance band punch-out holds': 'Resistance Band Punch-Out Training',
}
@@ -172,7 +177,11 @@ class JSONStore:
self._write(seeded_state)
def _read(self) -> dict[str, Any]:
return json.loads(self.state_path.read_text())
payload = json.loads(self.state_path.read_text())
seeded_payload = _ensure_seeded_library(payload)
if seeded_payload != payload:
self._write(seeded_payload)
return seeded_payload
def _write(self, payload: dict[str, Any]) -> None:
self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
@@ -267,6 +276,91 @@ class JSONStore:
state = self._read()
return sorted(state['routines'], key=lambda item: item['name'].lower())
def get_routine(self, routine_id: str) -> dict[str, Any] | None:
state = self._read()
for routine in state['routines']:
if routine.get('id') == routine_id:
return routine
return None
def get_active_workout_session(self, routine_id: str, user_id: str) -> dict[str, Any] | None:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
return session
return None
def start_active_workout_session(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
state['active_workout_sessions'] = [
session
for session in state['active_workout_sessions']
if not (
session['routine_id'] == payload['routine_id'] and session['user_id'] == payload['user_id']
)
]
saved = {
'id': uuid4().hex,
'routine_id': payload['routine_id'],
'routine_name': payload['routine_name'],
'user_id': payload['user_id'],
'performed_at': payload['performed_at'],
'notes': payload.get('notes'),
'exercises': [],
}
state['active_workout_sessions'].append(saved)
self._write(state)
return saved
def add_active_workout_exercise(self, routine_id: str, user_id: str, exercise: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] == routine_id and session['user_id'] == user_id:
session['exercises'].append(
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
)
self._write(state)
return session
raise KeyError((routine_id, user_id))
def finish_active_workout_session(self, routine_id: str, user_id: str, notes: str | None = None) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, user_id)
for session in state['active_workout_sessions']:
if session['routine_id'] != routine_id or session['user_id'] != user_id:
continue
workout_payload = {
'user_id': user_id,
'performed_at': session['performed_at'],
'routine_name': session['routine_name'],
'notes': notes,
'exercises': [
{
'exercise_name': exercise['exercise_name'],
'notes': exercise.get('notes'),
'sets': [dict(item) for item in exercise['sets']],
}
for exercise in session['exercises']
],
}
saved_workout = self.add_workout(workout_payload)
refreshed_state = self._read()
refreshed_state['active_workout_sessions'] = [
active_session
for active_session in refreshed_state['active_workout_sessions']
if active_session.get('id') != session['id']
]
self._write(refreshed_state)
return saved_workout
raise KeyError((routine_id, user_id))
def add_workout(self, payload: dict[str, Any]) -> dict[str, Any]:
state = self._read()
self._user_or_raise(state, payload['user_id'])
@@ -278,7 +372,9 @@ class JSONStore:
normalized_sets = []
for item in exercise['sets']:
set_count += 1
total_volume_lbs += item['reps'] * item['weight_lbs']
weight_lbs = item.get('weight_lbs')
if weight_lbs is not None:
total_volume_lbs += item['reps'] * weight_lbs
normalized_sets.append(dict(item))
exercises.append({
'exercise_name': exercise['exercise_name'],
@@ -329,10 +425,13 @@ class JSONStore:
stats['last_performed_at'] = workout['performed_at']
for item in exercise['sets']:
stats['set_count'] += 1
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], item['weight_lbs'])
weight_lbs = item.get('weight_lbs')
if weight_lbs is None:
continue
stats['max_weight_lbs'] = max(stats['max_weight_lbs'], weight_lbs)
stats['best_set_estimated_1rm'] = max(
stats['best_set_estimated_1rm'],
_estimated_1rm(item['weight_lbs'], item['reps']),
_estimated_1rm(weight_lbs, item['reps']),
)
results = []
for item in progression.values():
@@ -414,6 +513,18 @@ def _ensure_seeded_library(state: dict[str, Any]) -> dict[str, Any]:
payload.setdefault('weight_entries', [])
payload.setdefault('measurement_entries', [])
payload.setdefault('workout_sessions', [])
payload.setdefault('active_workout_sessions', [])
for template in payload['exercise_templates']:
legacy_name = template.get('name', '').strip().lower()
replacement_name = LEGACY_TEMPLATE_ALIASES.get(legacy_name)
if replacement_name is None:
continue
seeded_template = next(item for item in SEEDED_EXERCISE_TEMPLATES if item['name'] == replacement_name)
template['name'] = seeded_template['name']
template['primary_muscle'] = seeded_template.get('primary_muscle')
template['equipment'] = seeded_template.get('equipment')
template['notes'] = seeded_template.get('notes')
existing_template_names = {
item.get('name', '').strip().lower()

View File

@@ -413,6 +413,79 @@ button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgb
padding: 14px;
}
.compact-casefile-header {
padding-bottom: 0.35rem;
}
.compact-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 300px);
gap: 16px;
}
.compact-hot-fuzz-art {
min-width: 0;
}
.compact-hero-mark {
max-width: 300px;
margin-left: auto;
}
.compact-page-shell {
padding-bottom: 1.5rem;
}
.compact-panel {
padding: 1rem;
}
.compact-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
margin-bottom: 0.9rem;
}
.stat-slab {
display: grid;
gap: 0.3rem;
}
.stat-label,
.section-label {
color: var(--hf-amber);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.metric-value-sm {
font-size: 1.5rem;
}
.compact-summary-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.65rem;
}
.compact-summary-list li {
margin: 0;
padding: 0.7rem 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.compact-summary-list li + li {
margin-top: 0;
}
.compact-history-block h3 {
margin-bottom: 0.65rem;
}
@media (max-width: 700px) {
.page-header,
.page-shell {
@@ -431,6 +504,18 @@ button:focus, input:focus, select:focus, textarea:focus { outline: 2px solid rgb
flex-basis: 100%;
min-width: 0;
}
.compact-title-row {
grid-template-columns: 1fr;
}
.compact-hero-mark {
margin-left: 0;
}
.compact-stat-grid {
grid-template-columns: 1fr;
}
}
.doris-family-shell .case-nav {

View File

@@ -4,11 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Barbell' }}</title>
<link rel="stylesheet" href="/static/app.css?v=6">
<link rel="stylesheet" href="/static/app.css?v=7">
</head>
<body class="doris-hot-fuzz doris-family-shell app-barbell">
<div class="incident-tape" aria-hidden="true"></div>
<header class="page-header casefile-header app-casefile app-casefile-barbell">
<header class="page-header casefile-header app-casefile app-casefile-barbell compact-casefile-header">
<div class="film-grain" aria-hidden="true"></div>
<div class="cinematic-glow" aria-hidden="true"></div>
<div class="brand-row">
@@ -17,28 +17,26 @@
<strong>Doris Constabulary</strong>
</div>
<nav class="nav-links doris-family-nav" aria-label="Doris family navigation">
<a class="nav-link {{ 'active' if request.url.path == '/' else '' }}" href="http://10.5.30.6:8093/">Barbell</a>
<a class="nav-link {{ 'active' if request.url.path == '/' else '' }}" href="https://gym.paccoco.com/">Barbell</a>
<a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a>
<a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a>
<a class="nav-link" href="https://schoolhouse.paccoco.com/">Schoolhouse</a>
</nav>
</div>
<div class="casefile-title-row">
<div>
<div class="casefile-title-row compact-title-row">
<div class="hero-copy">
<p class="eyebrow">Station training desk</p>
<div class="casefile-stamp barbell-evidence-tag">Training Dossier</div>
<h1>Doris Barbell</h1>
<p class="lede">Reps, weight, BMI, and body metrics now look like a proper performance dossier instead of generic gym CRUD.</p>
<p class="lede">Log lifts, body weight, and routines.</p>
<div class="brand-badges" aria-label="Theme badges">
<span class="pill badge-hotfuzz">For the Greater Good</span>
<span class="pill">Evidence locker: reps</span>
<span class="pill">Case type: Training Dossier</span>
</div>
</div>
<div class="art-stack">
<div class="hot-fuzz-art" aria-hidden="true">
<svg viewBox="0 0 320 220" class="poster-illustration" role="presentation">
<div class="hot-fuzz-art compact-hot-fuzz-art" aria-hidden="true">
<svg viewBox="0 0 320 140" class="poster-illustration compact-hero-mark" role="presentation">
<defs>
<linearGradient id="barbell-sunset" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f2c14e"></stop>
@@ -46,60 +44,29 @@
<stop offset="100%" stop-color="#910f3f"></stop>
</linearGradient>
</defs>
<rect x="8" y="8" width="304" height="204" rx="24" class="poster-frame"></rect>
<circle cx="228" cy="72" r="58" class="poster-halo"></circle>
<path d="M18 170 L112 100 L134 124 L40 198 Z" class="poster-tape tape-left"></path>
<path d="M202 82 L302 32 L314 60 L214 112 Z" class="poster-tape tape-right"></path>
<path d="M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z" class="constable-silhouette lead"></path>
<path d="M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z" class="constable-silhouette partner"></path>
<circle cx="84" cy="60" r="28" class="swan-stamp"></circle>
<path d="M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z" class="swan-mark"></path>
<rect x="120" y="88" width="88" height="38" rx="8" class="evidence-card"></rect>
<path d="M130 98 h58 M130 108 h42 M130 118 h32" class="evidence-lines"></path>
<circle cx="88" cy="88" r="8" class="marker-dot"></circle>
<circle cx="100" cy="104" r="6" class="marker-dot marker-dot-alt"></circle>
<path d="M96 94 L122 110 L152 96" class="map-thread"></path>
<path d="M210 88 C226 80 242 80 254 88" class="beacon-arc"></path>
<rect x="122" y="110" width="86" height="8" rx="4" class="barbell-bar"></rect>
<circle cx="120" cy="114" r="16" class="plate-outer"></circle>
<circle cx="120" cy="114" r="8" class="plate-inner"></circle>
<circle cx="210" cy="114" r="16" class="plate-outer"></circle>
<circle cx="210" cy="114" r="8" class="plate-inner"></circle>
<rect x="216" y="126" width="38" height="16" rx="6" class="radio-body"></rect>
<rect x="232" y="110" width="8" height="16" rx="4" class="radio-antenna"></rect>
<text x="160" y="204" class="poster-callout">TRAINING DOSSIER</text>
<rect x="8" y="8" width="304" height="124" rx="24" class="poster-frame"></rect>
<circle cx="250" cy="45" r="34" class="poster-halo"></circle>
<path d="M34 104 L86 58 L102 74 L50 118 Z" class="poster-tape tape-left"></path>
<path d="M218 44 L292 18 L300 38 L226 66 Z" class="poster-tape tape-right"></path>
<path d="M112 106 C112 82 118 64 132 52 C140 44 147 39 154 36 C161 39 168 44 176 52 C190 64 196 82 196 106 Z" class="constable-silhouette lead"></path>
<path d="M168 108 C170 86 178 70 192 60 C201 53 210 48 218 45 C226 48 235 53 242 60 C255 70 262 86 260 108 Z" class="constable-silhouette partner"></path>
<circle cx="76" cy="44" r="20" class="swan-stamp"></circle>
<path d="M71 47 C74 40 82 35 90 39 C84 39 80 43 81 48 C82 53 90 53 92 58 C85 60 77 58 73 53 L68 58 L64 55 L70 49 Z" class="swan-mark"></path>
<rect x="114" y="62" width="92" height="10" rx="5" class="barbell-bar"></rect>
<circle cx="112" cy="67" r="16" class="plate-outer"></circle>
<circle cx="112" cy="67" r="8" class="plate-inner"></circle>
<circle cx="208" cy="67" r="16" class="plate-outer"></circle>
<circle cx="208" cy="67" r="8" class="plate-inner"></circle>
<path d="M88 76 L120 68 L154 82" class="map-thread"></path>
<text x="160" y="118" class="poster-callout">TRAINING DOSSIER</text>
</svg>
</div>
<div class="evidence-contact-grid">
<section class="evidence-note-card case-contact-sheet" aria-label="Training caseboard details">
<div class="case-contact-top">
<span class="case-contact-kicker">Station drill board</span>
<span class="case-contact-badge">Cadet fitness</span>
</div>
<svg viewBox="0 0 320 160" class="contact-sheet-illustration" role="presentation">
<rect x="12" y="14" width="116" height="132" rx="16" class="contact-frame"></rect>
<rect x="24" y="30" width="92" height="54" rx="12" class="contact-photo"></rect>
<path d="M30 96 h80 M30 108 h66 M30 120 h48" class="contact-lines"></path>
<rect x="146" y="24" width="78" height="44" rx="10" class="contact-photo alt"></rect>
<rect x="160" y="84" width="112" height="52" rx="12" class="contact-photo alt"></rect>
<circle cx="250" cy="40" r="16" class="contact-marker"></circle>
<path d="M132 94 C152 82 176 80 194 88 C208 94 228 98 250 96" class="contact-thread"></path>
<text x="70" y="60" class="contact-label">RPE</text>
<text x="186" y="52" class="contact-label">PACE</text>
<text x="218" y="114" class="contact-label">PLATE</text>
</svg>
<ul class="case-contact-list">
<li>Clipboard-style slips, radio cues, and drill-board labels make the page read like a police training dossier instead of generic fitness software.</li>
<li>The photo still remains for texture, but the authored graphics now carry the theme.</li>
</ul>
</section>
<figure class="photo-evidence-card hero-photo-card supporting-photo-card">
<img src="/static/images/hotfuzz-training-dossier.jpg" alt="Sepia-toned training photo with dumbbells, weight plates, and a handwritten workout clipboard." loading="eager">
<img src="/static/images/hotfuzz-training-dossier.jpg" alt="Gym training photo with cable machine station, bench, and weight stacks." loading="eager">
<figcaption>Training still · source logged in docs</figcaption>
</figure>
</div>
</div>
</div>
<nav class="case-nav" aria-label="Doris Barbell sections">
<a class="case-nav-link active" href="/#body-metrics">Body metrics</a>
<a class="case-nav-link" href="/#weight-log">Weight log</a>
@@ -107,7 +74,7 @@
<a class="case-nav-link" href="/#recent-workouts">Workout reports</a>
</nav>
</header>
<main class="page-shell">
<main class="page-shell compact-page-shell">
<section class="family-portal evidence-board casefile-directory">
<div class="section-heading">
<div>
@@ -116,32 +83,32 @@
</div>
<span class="pill">Casefile directory</span>
</div>
<p class="muted case-legend">One shell, separate runtimes. Training reports live here, but the rest of the Doris family is one jump away.</p>
<p class="muted case-legend">One shell, separate runtimes. Jump between training, household ops, and coursework without losing the same caseboard language.</p>
<div class="family-directory-grid">
<a class="family-app-card current-desk" href="http://10.5.30.6:8093/">
<a class="family-app-card current-desk" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Current desk</span>
<strong>Doris Barbell</strong>
<small>Body metrics, routines, workouts, and progression dossiers.</small>
<small>Workout logs, body metrics, and progression dossiers.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/">
<span class="family-app-kicker">Operator portal</span>
<strong>Doris Dashboard</strong>
<small>Canonical front door for the whole Doris family.</small>
<small>Canonical front door for alerts, briefings, and launch control.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8787/services.html">
<span class="family-app-kicker">Switchboard</span>
<strong>Services Directory</strong>
<small>Dense launch wall for the wider homelab stack.</small>
<small>Dense portal card wall for the broader stack.</small>
</a>
<a class="family-app-card" href="http://10.5.30.7:8092/">
<span class="family-app-kicker">Pantry desk</span>
<strong>Doris Kitchen</strong>
<small>Recipe leads, repair queue, and planning board.</small>
<small>Recipe triage, search warrants, and pantry planning.</small>
</a>
<a class="family-app-card" href="https://schoolhouse.paccoco.com/">
<span class="family-app-kicker">Records desk</span>
<strong>Doris Schoolhouse</strong>
<small>Assignments, recordings, and school intake packets.</small>
<small>Assignments, recordings, and archive intake.</small>
</a>
</div>
</section>

View File

@@ -1,51 +1,70 @@
{% extends 'base.html' %}
{% block content %}
<section id="body-metrics" class="grid cols-2 evidence-board dossier-grid">
<article class="card evidence-card">
<section id="body-metrics" class="grid cols-2 dossier-grid">
<article class="card evidence-card compact-panel">
<div class="case-legend">
<span class="section-label">Training floor board</span>
<span class="pill">Training Dossier</span>
<span class="pill">John</span>
</div>
<h2>Current body metrics</h2>
<div class="grid dossier-grid">
{% for user in summary.users %}
<section class="evidence-slab">
<h3>{{ user.display_name }} {% if user.is_stub %}<span class="badge">stub</span>{% endif %}</h3>
{% for user in summary.users if user.id == 'john' %}
<div class="compact-stat-grid">
<section class="evidence-slab stat-slab">
<span class="stat-label">Weight</span>
<p class="metric-value">{{ user.current_weight_lbs if user.current_weight_lbs is not none else '—' }}{% if user.current_weight_lbs is not none %} lb{% endif %}</p>
<p class="muted">BMI: {{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else 'Not set yet' }}</p>
<p class="muted">System: {{ user.measurement_system }}</p>
</section>
<section class="evidence-slab stat-slab">
<span class="stat-label">BMI</span>
<p class="metric-value metric-value-sm">{{ '%.1f'|format(user.current_bmi) if user.current_bmi is not none else '—' }}</p>
</section>
</div>
{% if user.latest_measurements %}
<p class="muted">
Waist {{ user.latest_measurements.waist_inches or '—' }} in ·
Hips {{ user.latest_measurements.hip_inches or '—' }} in ·
Chest {{ user.latest_measurements.chest_inches or '—' }} in ·
Neck {{ user.latest_measurements.neck_inches or '—' }} in
</p>
<ul class="list compact-summary-list">
<li>Waist {{ user.latest_measurements.waist_inches or '—' }} in</li>
<li>Chest {{ user.latest_measurements.chest_inches or '—' }} in</li>
<li>Hips {{ user.latest_measurements.hip_inches or '—' }} in</li>
<li>Neck {{ user.latest_measurements.neck_inches or '—' }} in</li>
</ul>
{% else %}
<p class="muted">No body measurements logged yet.</p>
{% endif %}
</section>
{% endfor %}
</div>
</article>
<article class="card evidence-card">
<article id="routine-board" class="card evidence-board compact-panel">
<div class="case-legend">
<span class="section-label">Case priorities</span>
<span class="pill">Operator notes</span>
<span class="section-label">Current split</span>
<span class="pill">Routine board</span>
</div>
<h2>V1 focus</h2>
<ul class="list case-list">
<li>Structured workout logging with routines and exercises.</li>
<li>Body-weight, BMI, waist/hip/chest/neck history in imperial units.</li>
<li>Recent workouts, volume trends, and simple PR tracking.</li>
<h2>Current routine board</h2>
<ul class="list compact-summary-list">
<li>Routines: {{ summary.counts.routines }}</li>
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Workout sessions: {{ summary.counts.workout_sessions }}</li>
</ul>
{% if summary.routines %}
<div class="history-block compact-history-block">
<ul class="list">
{% for routine in summary.routines %}
<li>
<strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises</p>
<p>
<a class="nav-link" href="/routines/{{ routine.id }}/log">Open log sheet</a>
·
<a class="nav-link" href="/routines/{{ routine.id }}/live">{% if routine.live_session %}Resume {{ routine.name }} live log{% else %}Resume live log{% endif %}</a>
</p>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article>
</section>
<section id="weight-log" class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Weight log</span></div>
<h2>Log weight</h2>
<form class="stack-form intake-docket" method="post" action="/log/weight">
@@ -74,7 +93,7 @@
</form>
</article>
<article class="card evidence-board">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Intake docket</span><span class="pill">Measurements</span></div>
<h2>Log body measurements</h2>
<form class="stack-form intake-docket" method="post" action="/log/measurements">
@@ -104,108 +123,8 @@
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Exercise library</span><span class="pill">Template intake</span></div>
<h2>Add exercise template</h2>
<form class="stack-form intake-docket" method="post" action="/templates">
<label>Name
<input type="text" name="name" placeholder="Bench Press" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Primary muscle
<input type="text" name="primary_muscle" placeholder="Chest">
</label>
<label>Equipment
<input type="text" name="equipment" placeholder="Barbell">
</label>
</div>
<label>Notes
<textarea name="notes" rows="3" placeholder="Technique cue, setup reminder, variation notes."></textarea>
</label>
<button type="submit">Add template</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Routine intake</span><span class="pill">Draft split</span></div>
<h2>Build routine draft</h2>
<form class="stack-form intake-docket" method="post" action="/routines">
<input type="hidden" name="user_id" value="john">
<label>Routine name
<input type="text" name="name" placeholder="Push Day Draft" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|4|6-8&#10;Incline Dumbbell Press|3|8-10&#10;Triceps Pushdown|3|12-15" required></textarea>
</label>
<p class="hint">Format: exercise | target sets | target reps | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Temporary until the real split gets entered."></textarea>
</label>
<button type="submit">Save routine draft</button>
</form>
</article>
</section>
<section id="recent-workouts" class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Workout report</span><span class="pill">Quick log</span></div>
<h2>Quick log workout</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/quick-log">
<input type="hidden" name="user_id" value="john">
<label>Workout name
<input type="text" name="routine_name" placeholder="Push Day" required>
</label>
<label>Performed at
<input type="datetime-local" name="performed_at" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="6" placeholder="Bench Press|8@135, 8@145&#10;Incline Dumbbell Press|10@50, 8@55|Last set close to failure" required></textarea>
</label>
<p class="hint">Format: exercise | reps@weight, reps@weight | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Energy, pain, wins, misses."></textarea>
</label>
<button type="submit">Save workout</button>
</form>
</article>
<article id="routine-board" class="card evidence-board">
<div class="case-legend"><span class="section-label">Current split</span><span class="pill">Routine board</span></div>
<h2>Current routine board</h2>
<p class="muted">John's current five-day split is now loaded as the default routine library with a default prescription of 3 sets of 10 reps. Weights are still TBD.</p>
<ul class="list case-list">
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
{% if summary.routines %}
<div class="history-block">
<h3>Loaded routine days</h3>
<ul class="list">
{% for routine in summary.routines %}
<li>
<strong>{{ routine.name }}</strong>
<p class="muted">{{ routine.exercises|length }} exercises{% if routine.notes %} · {{ routine.notes }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if summary.exercise_templates %}
<div class="history-block">
<h3>Template snapshot</h3>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Recent logs</span><span class="pill">Session reports</span></div>
<h2>Recent workouts</h2>
{% if john_workout_history %}
@@ -222,50 +141,7 @@
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">History snapshot</span><span class="pill">John file</span></div>
<h2>John history snapshot</h2>
<div class="history-block">
<h3>Recent weights</h3>
{% if john_weight_history %}
<ul class="list">
{% for entry in john_weight_history %}
<li>{{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No weight entries yet.</p>
{% endif %}
</div>
<div class="history-block">
<h3>Recent measurements</h3>
{% if john_measurement_history %}
<ul class="list">
{% for entry in john_measurement_history %}
<li>{{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No measurement entries yet.</p>
{% endif %}
</div>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Current counts</span><span class="pill">Inventory</span></div>
<h2>Current counts</h2>
<ul class="list case-list">
<li>Workout sessions: {{ summary.counts.workout_sessions }}</li>
<li>Weight entries: {{ summary.counts.weight_entries }}</li>
<li>Measurement entries: {{ summary.counts.measurement_entries }}</li>
<li>Exercise templates: {{ summary.counts.exercise_templates }}</li>
<li>Routines: {{ summary.counts.routines }}</li>
</ul>
</article>
<article class="card evidence-board">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Exercise bests</span><span class="pill">PR board</span></div>
<h2>Exercise bests</h2>
{% if john_progression %}
@@ -284,14 +160,108 @@
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">Next likely build</span><span class="pill">Open casework</span></div>
<h2>Next likely build</h2>
<ul class="list case-list">
<li>Replace placeholder routines with Johns real day-by-day program tomorrow.</li>
<li>Routine-specific workout entry flow driven directly from the saved split.</li>
<li>Exercise progression and PR views.</li>
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Library tools</span><span class="pill">Templates</span></div>
<h2>Add exercise template</h2>
<form class="stack-form intake-docket" method="post" action="/templates">
<label>Name
<input type="text" name="name" placeholder="Bench Press" required>
</label>
<div class="grid cols-2 compact-grid">
<label>Primary muscle
<input type="text" name="primary_muscle" placeholder="Chest">
</label>
<label>Equipment
<input type="text" name="equipment" placeholder="Barbell">
</label>
</div>
<label>Notes
<textarea name="notes" rows="2" placeholder="Technique cue or setup reminder."></textarea>
</label>
<button type="submit">Add template</button>
</form>
{% if summary.exercise_templates %}
<div class="history-block compact-history-block">
<h3>Template snapshot</h3>
<ul class="list">
{% for template in summary.exercise_templates %}
<li>{{ template.name }}{% if template.equipment %} · {{ template.equipment }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</article>
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Workout report</span><span class="pill">Quick log</span></div>
<h2>Quick log workout</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/quick-log">
<input type="hidden" name="user_id" value="john">
<label>Workout name
<input type="text" name="routine_name" placeholder="Push Day" required>
</label>
<label>Performed at
<input type="datetime-local" name="performed_at" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="5" placeholder="Bench Press|8@135, 8@145&#10;Incline Dumbbell Press|10@50, 8@55|Last set close to failure" required></textarea>
</label>
<p class="hint">Format: exercise | reps@weight, reps@weight | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Energy, pain, wins, misses."></textarea>
</label>
<button type="submit">Save workout</button>
</form>
</article>
</section>
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">Routine tools</span><span class="pill">Draft split</span></div>
<h2>Build routine draft</h2>
<form class="stack-form intake-docket" method="post" action="/routines">
<input type="hidden" name="user_id" value="john">
<label>Routine name
<input type="text" name="name" placeholder="Push Day Draft" required>
</label>
<label>Exercise lines
<textarea name="exercise_lines" rows="5" placeholder="Bench Press|4|6-8&#10;Incline Dumbbell Press|3|8-10&#10;Triceps Pushdown|3|12-15" required></textarea>
</label>
<p class="hint">Format: exercise | target sets | target reps | optional notes</p>
<label>Notes
<textarea name="notes" rows="2" placeholder="Optional notes."></textarea>
</label>
<button type="submit">Save routine draft</button>
</form>
</article>
<article class="card evidence-board compact-panel">
<div class="case-legend"><span class="section-label">History snapshot</span><span class="pill">John file</span></div>
<h2>Recent body history</h2>
<div class="history-block compact-history-block">
<h3>Recent weights</h3>
{% if john_weight_history %}
<ul class="list">
{% for entry in john_weight_history %}
<li>{{ entry.recorded_at }} · {{ entry.weight_lbs }} lb{% if entry.bmi is not none %} · BMI {{ '%.1f'|format(entry.bmi) }}{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No weight entries yet.</p>
{% endif %}
</div>
<div class="history-block compact-history-block">
<h3>Recent measurements</h3>
{% if john_measurement_history %}
<ul class="list">
{% for entry in john_measurement_history %}
<li>{{ entry.recorded_at }} · Waist {{ entry.waist_inches or '—' }} · Chest {{ entry.chest_inches or '—' }} · Hips {{ entry.hip_inches or '—' }} · Neck {{ entry.neck_inches or '—' }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No measurement entries yet.</p>
{% endif %}
</div>
</article>
</section>
{% endblock %}

View File

@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Live workout logger</span>
<span class="pill">One-at-a-time entry</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Log one exercise at a time while you work through the session. Save each movement as you finish it, then close out the workout when you're done.</p>
{% if routine.notes %}
<p class="muted">{{ routine.notes }}</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Routine field sheet</span>
<span class="pill">{{ routine_sheet|length }} exercises</span>
</div>
<h2>Loaded exercises</h2>
<ul class="list case-list">
{% for exercise in routine_sheet %}
<li>
<strong>{{ exercise.exercise_name }}</strong>
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
{% if not active_session %}
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">Ready</span>
</div>
<h2>Start live session</h2>
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/start">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<button type="submit">Start {{ routine.name }} session</button>
</form>
</article>
</section>
{% else %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Session control</span>
<span class="pill">In progress</span>
</div>
<h2>Session in progress</h2>
<p class="muted">Started at {{ active_session.performed_at }} · Logged exercises so far: {{ logged_exercises|length }}</p>
{% if logged_exercises %}
<ul class="list case-list">
{% for exercise in logged_exercises %}
<li>
<strong>{{ exercise.exercise_name }} · {{ exercise.set_count }} sets</strong>
{% if exercise.notes %}<p class="muted">{{ exercise.notes }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No exercises saved yet.</p>
{% endif %}
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/finish">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<label>Session notes
<textarea name="notes" rows="2" placeholder="Energy, pain, machine swaps, or anything worth remembering."></textarea>
</label>
<button type="submit">Finish {{ routine_action_label }} workout</button>
</form>
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Remaining queue</span>
<span class="pill">{{ remaining_exercises|length }} left</span>
</div>
<h2>Ready to log next</h2>
{% if remaining_exercises %}
<p class="muted">Each card saves one exercise at a time, so you can log mid-workout without rewriting the whole session.</p>
{% for exercise in remaining_exercises %}
<section class="evidence-slab">
<form class="stack-form intake-docket" method="post" action="/routines/{{ routine.id }}/live/add-exercise">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="performed_at" value="{{ active_session.performed_at }}">
<input type="hidden" name="exercise_name" value="{{ exercise.exercise_name }}">
<input type="hidden" name="set_count" value="{{ exercise.target_sets }}">
<div class="case-legend">
<span class="section-label">Exercise</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<h3>{{ exercise.exercise_name }}</h3>
<p class="muted">{% if exercise.target_reps %}Target reps {{ exercise.target_reps }}{% else %}Enter reps manually{% endif %}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid">
{% for set in exercise.sets %}
<div class="evidence-slab compact-slab">
<strong>Set {{ set.index + 1 }}</strong>
<label>Reps
<input type="number" name="set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
<button type="submit">Save {{ exercise.exercise_name }}</button>
</form>
</section>
{% endfor %}
{% else %}
<p class="muted">Everything in this routine is logged. Finish the workout to move it into history.</p>
{% endif %}
</article>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block content %}
<section class="grid cols-2 dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Routine field sheet</span>
<span class="pill">Live training log</span>
</div>
<h2>{{ routine.name }}</h2>
<p class="muted">Fill in today's completed reps and weight for each set. Leave any unfinished exercise blank and it will be skipped.</p>
{% if routine.notes %}
<p class="muted">{{ routine.notes }}</p>
{% endif %}
</article>
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Current prescription</span>
<span class="pill">Shoulder day</span>
</div>
<h2>Loaded exercises</h2>
<ul class="list case-list">
{% for exercise in routine_sheet %}
<li>
<strong>{{ exercise.exercise_name }}</strong>
<p class="muted">{{ exercise.target_sets }} sets{% if exercise.target_reps %} · target reps {{ exercise.target_reps }}{% endif %}{% if exercise.notes %} · {{ exercise.notes }}{% endif %}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
<section class="grid dossier-grid">
<article class="card evidence-board">
<div class="case-legend">
<span class="section-label">Workout report</span>
<span class="pill">Structured entry</span>
</div>
<h2>Log this session</h2>
<form class="stack-form intake-docket" method="post" action="/workouts/routine-log">
<input type="hidden" name="user_id" value="{{ routine.user_id }}">
<input type="hidden" name="routine_id" value="{{ routine.id }}">
<input type="hidden" name="routine_name" value="{{ routine.name }}">
<input type="hidden" name="exercise_count" value="{{ routine_sheet|length }}">
<label>Performed at
<input type="datetime-local" name="performed_at" value="{{ default_performed_at }}" required>
</label>
<label>Session notes
<textarea name="notes" rows="2" placeholder="Pain, wins, machine availability, energy, anything worth remembering."></textarea>
</label>
{% for exercise in routine_sheet %}
<section class="evidence-slab">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_set_count" value="{{ exercise.target_sets }}">
<input type="hidden" name="exercise_{{ exercise.exercise_index }}_default_reps" value="{{ exercise.default_reps }}">
<div class="case-legend">
<span class="section-label">Exercise {{ loop.index }}</span>
<span class="pill">{{ exercise.target_sets }} sets</span>
</div>
<label>Exercise
<select name="exercise_{{ exercise.exercise_index }}_name">
{% for option_name in exercise_options %}
<option value="{{ option_name }}" {% if option_name == exercise.exercise_name %}selected{% endif %}>{{ option_name }}</option>
{% endfor %}
</select>
</label>
<p class="muted">Target reps: {{ exercise.target_reps or 'enter manually' }}</p>
{% if exercise.is_band %}
<p class="muted">Resistance-band exercise: leave weight blank to log sets and default reps only.</p>
{% endif %}
<div class="grid cols-2 compact-grid">
{% for set in exercise.sets %}
<div class="evidence-slab compact-slab">
<strong>Set {{ set.index + 1 }}</strong>
<label>Reps
<input type="number" name="exercise_{{ set.exercise_index }}_set_{{ set.index }}_reps" min="1" step="1" placeholder="{{ set.default_reps }}">
</label>
<label>Weight (lb)
<input type="number" name="exercise_{{ set.exercise_index }}_set_{{ set.index }}_weight_lbs" min="0" step="0.1">
</label>
</div>
{% endfor %}
</div>
<label>Exercise notes
<textarea name="exercise_{{ exercise.exercise_index }}_notes" rows="2" placeholder="Technique note, pain flag, or machine change."></textarea>
</label>
</section>
{% endfor %}
<button type="submit">Save {{ routine_action_label }} workout</button>
</form>
</article>
</section>
{% endblock %}

View File

@@ -1,4 +1,5 @@
import json
import re
from pathlib import Path
from fastapi.testclient import TestClient
@@ -54,6 +55,18 @@ def test_dashboard_summary_seeds_john_and_manndra(tmp_path: Path) -> None:
}
assert '3 sets of 10 reps' in payload['routines'][0]['notes']
assert 'Weights are still TBD' in payload['routines'][0]['notes']
shoulders = next(routine for routine in payload['routines'] if routine['name'] == 'Thursday: Shoulders')
assert shoulders['exercises'][6] == {
'exercise_name': '7-7-7 Shoulder Press',
'target_sets': 1,
'target_reps': '7-7-7',
}
assert shoulders['exercises'][7] == {
'exercise_name': 'Resistance Band Punch-Out Training',
'target_sets': 3,
'target_reps': '10',
}
assert 'resistance band punch-out training' in shoulders['notes']
template_names = [template['name'] for template in payload['exercise_templates']]
assert template_names == [
'Resistance Band Crunches',
@@ -366,12 +379,277 @@ def test_homepage_renders_core_sections_and_forms(tmp_path: Path) -> None:
assert response.status_code == 200
body = response.text
assert 'Doris Barbell' in body
assert 'Log lifts, body weight, and routines.' in body
assert 'Recent workouts' in body
assert 'Current body metrics' in body
assert 'Log weight' in body
assert 'Add exercise template' in body
assert 'Current routine board' in body
assert 'Monday: Chest' in body
assert 'Doris family directory' in body
assert 'Singular front door' in body
assert 'V1 focus' not in body
assert 'Next likely build' not in body
def test_seeded_routine_can_render_prefilled_log_sheet(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.get(f"/routines/{shoulders['id']}/log")
assert response.status_code == 200
body = response.text
assert 'Thursday: Shoulders' in body
assert 'Shoulder Press' in body
assert 'Save shoulder day workout' in body
assert 'name="exercise_0_name"' in body
assert '<option value="Shoulder Press" selected>' in body
assert 'value="Upright Row"' in body
assert 'name="exercise_0_set_0_reps"' in body
assert 'name="exercise_0_set_0_weight_lbs"' in body
assert 'placeholder="10"' in body
assert 'name="exercise_0_set_0_reps" min="1" step="1" placeholder="10"' in body
assert re.search(r'name="performed_at" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"', body)
def test_seeded_routine_can_render_live_workout_page(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.get(f"/routines/{shoulders['id']}/live")
assert response.status_code == 200
body = response.text
assert 'Live workout logger' in body
assert 'Log one exercise at a time' in body
assert 'Start Thursday: Shoulders session' in body
assert 'Resume live log' in client.get('/').text
def test_live_workout_page_backfills_older_state_without_active_session_key(tmp_path: Path) -> None:
client = make_client(tmp_path)
legacy_state = json.loads((tmp_path / 'state.json').read_text())
legacy_state.pop('active_workout_sessions', None)
(tmp_path / 'state.json').write_text(json.dumps(legacy_state))
response = client.get('/routines/seed-routine-04/live')
assert response.status_code == 200
assert 'Start Thursday: Shoulders session' in response.text
def test_live_workout_session_can_log_exercises_one_at_a_time_and_finish(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
start_response = client.post(
f"/routines/{shoulders['id']}/live/start",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
},
follow_redirects=True,
)
assert start_response.status_code == 200
start_body = start_response.text
assert 'Session in progress' in start_body
assert 'Logged exercises so far: 0' in start_body
assert 'name="exercise_name"' in start_body
assert 'name="set_0_reps"' in start_body
assert 'name="set_0_weight_lbs"' in start_body
add_response = client.post(
f"/routines/{shoulders['id']}/live/add-exercise",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'exercise_name': 'Shoulder Press',
'set_count': '3',
'set_0_reps': '10',
'set_0_weight_lbs': '70',
'set_1_reps': '10',
'set_1_weight_lbs': '80',
'set_2_reps': '8',
'set_2_weight_lbs': '90',
},
follow_redirects=True,
)
assert add_response.status_code == 200
add_body = add_response.text
assert 'Logged exercises so far: 1' in add_body
assert 'Shoulder Press · 3 sets' in add_body
assert 'Upright Row' in add_body
finish_response = client.post(
f"/routines/{shoulders['id']}/live/finish",
data={
'user_id': 'john',
'performed_at': '2026-05-28T12:15:00',
'notes': 'Logged between sets.',
},
follow_redirects=False,
)
assert finish_response.status_code == 303
assert finish_response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercise_count'] == 1
assert payload[0]['set_count'] == 3
assert payload[0]['notes'] == 'Logged between sets.'
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
dashboard = client.get('/').text
assert 'Resume Thursday: Shoulders live log' not in dashboard
def test_seeded_routine_log_submission_creates_structured_workout(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:00:00',
'routine_name': shoulders['name'],
'notes': 'Shoulder day at the gym.',
'exercise_count': '2',
'exercise_0_name': 'Shoulder Press',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '10',
'exercise_0_set_0_weight_lbs': '70',
'exercise_0_set_1_reps': '10',
'exercise_0_set_1_weight_lbs': '80',
'exercise_0_set_2_reps': '8',
'exercise_0_set_2_weight_lbs': '90',
'exercise_1_name': 'Upright Row',
'exercise_1_set_count': '3',
'exercise_1_set_0_reps': '10',
'exercise_1_set_0_weight_lbs': '35',
'exercise_1_set_1_reps': '10',
'exercise_1_set_1_weight_lbs': '35',
'exercise_1_set_2_reps': '8',
'exercise_1_set_2_weight_lbs': '40',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercise_count'] == 2
assert payload[0]['set_count'] == 6
assert payload[0]['total_volume_lbs'] == 3240
assert payload[0]['exercises'][0]['exercise_name'] == 'Shoulder Press'
assert payload[0]['exercises'][0]['sets'][2] == {'reps': 8, 'weight_lbs': 90.0}
assert payload[0]['exercises'][1]['exercise_name'] == 'Upright Row'
def test_seeded_routine_log_uses_numeric_target_reps_when_reps_left_blank(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:00:00',
'routine_name': shoulders['name'],
'notes': 'Use default target reps when left blank.',
'exercise_count': '1',
'exercise_0_name': 'Shoulder Press',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '',
'exercise_0_set_0_weight_lbs': '45',
'exercise_0_set_1_reps': '',
'exercise_0_set_1_weight_lbs': '45',
'exercise_0_set_2_reps': '',
'exercise_0_set_2_weight_lbs': '45',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['exercises'][0]['sets'] == [
{'reps': 10, 'weight_lbs': 45.0},
{'reps': 10, 'weight_lbs': 45.0},
{'reps': 10, 'weight_lbs': 45.0},
]
def test_seeded_routine_log_allows_band_exercises_without_weight(tmp_path: Path) -> None:
client = make_client(tmp_path)
summary = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in summary['routines'] if routine['name'] == 'Thursday: Shoulders')
response = client.post(
'/workouts/routine-log',
data={
'user_id': 'john',
'routine_id': shoulders['id'],
'performed_at': '2026-05-28T12:05:00',
'routine_name': shoulders['name'],
'notes': 'Band work without tracked weight.',
'exercise_count': '1',
'exercise_0_name': 'Resistance Band Punch-Out Training',
'exercise_0_set_count': '3',
'exercise_0_set_0_reps': '',
'exercise_0_set_0_weight_lbs': '',
'exercise_0_set_1_reps': '',
'exercise_0_set_1_weight_lbs': '',
'exercise_0_set_2_reps': '',
'exercise_0_set_2_weight_lbs': '',
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers['location'] == '/'
workout_history = client.get('/api/history/john/workouts')
assert workout_history.status_code == 200
payload = workout_history.json()
assert payload[0]['routine_name'] == 'Thursday: Shoulders'
assert payload[0]['set_count'] == 3
assert payload[0]['total_volume_lbs'] == 0
assert payload[0]['exercises'][0]['exercise_name'] == 'Resistance Band Punch-Out Training'
assert payload[0]['exercises'][0]['sets'] == [
{'reps': 10, 'weight_lbs': None},
{'reps': 10, 'weight_lbs': None},
{'reps': 10, 'weight_lbs': None},
]
def test_existing_state_gets_seeded_routine_backfill(tmp_path: Path) -> None:
@@ -472,6 +750,82 @@ def test_existing_seeded_routines_upgrade_prescriptions_to_three_by_ten(tmp_path
assert 'Weights are still TBD' in monday['notes']
def test_existing_seeded_shoulders_routine_gets_777_and_punch_out_corrections(tmp_path: Path) -> None:
state_path = tmp_path / 'state.json'
state_path.write_text(
json.dumps(
{
'users': {
'john': {
'id': 'john',
'display_name': 'John',
'is_stub': False,
'measurement_system': 'imperial',
'height_inches': 68.5,
'goal_weight_lbs': None,
'notes': 'Legacy state',
},
'manndra': {
'id': 'manndra',
'display_name': 'Manndra',
'is_stub': True,
'measurement_system': 'imperial',
'height_inches': None,
'goal_weight_lbs': None,
'notes': 'Legacy stub',
},
},
'exercise_templates': [
{
'id': 'seed-template-25',
'name': 'Resistance Band Punch-Out Holds',
'primary_muscle': 'abs',
'equipment': 'band',
'notes': None,
}
],
'routines': [
{
'id': 'seed-routine-04',
'user_id': 'john',
'name': 'Thursday: Shoulders',
'notes': "Imported from John's current routine. Default prescription is 3 sets of 10 reps. Weights are still TBD. Includes resistance band punch-out ab holds.",
'exercises': [
{'exercise_name': 'Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Arnold Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Side Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Front Lateral Raise', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Upright Row', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Shoulder Shrugs', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': '7-7-7 Shoulder Press', 'target_sets': 3, 'target_reps': '10'},
{'exercise_name': 'Resistance Band Punch-Out Holds', 'target_sets': 3, 'target_reps': '10'},
],
}
],
'weight_entries': [],
'measurement_entries': [],
'workout_sessions': [],
}
)
)
client = make_client(tmp_path)
payload = client.get('/api/dashboard/summary').json()
shoulders = next(routine for routine in payload['routines'] if routine['name'] == 'Thursday: Shoulders')
assert shoulders['exercises'][6] == {
'exercise_name': '7-7-7 Shoulder Press',
'target_sets': 1,
'target_reps': '7-7-7',
}
assert shoulders['exercises'][7] == {
'exercise_name': 'Resistance Band Punch-Out Training',
'target_sets': 3,
'target_reps': '10',
}
assert 'resistance band punch-out training' in shoulders['notes']
def test_weight_form_submission_redirects_and_updates_dashboard(tmp_path: Path) -> None:
client = make_client(tmp_path)

View File

@@ -112,9 +112,6 @@ SERVICE_DIRECTORY = [
'items': [
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'},
{'name': 'Kima Hub', 'url': 'http://10.5.30.6:3333', 'health_url': 'http://10.5.30.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'},
{'name': 'Pi-hole VIP', 'url': 'http://10.5.30.53/ui/', 'health_url': 'http://10.5.30.53/admin/', 'purpose': 'Floating DNS/admin surface for the HA Pi-hole pair your clients should target.', 'exposure': 'LAN', 'state': 'ok', 'host': 'VIP', 'port': '53 / 80'},
{'name': 'Pi-hole PD', 'url': 'http://10.5.30.6/ui/', 'health_url': 'http://10.5.30.6/admin/', 'purpose': 'Primary PD Pi-hole node behind the floating DNS VIP.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '53 / 80'},
{'name': 'Pi-hole NOMAD', 'url': 'https://10.5.30.7/login', 'purpose': 'NOMAD backup Pi-hole replica for the HA DNS pair.', 'exposure': 'LAN', 'state': 'attention', 'host': 'NOMAD', 'port': '53 / 80/443', 'probe': False, 'badge': 'Manual', 'detail': 'documented active; local admin currently presents a self-signed TLS flow from NOMAD so Doris leaves this manual'},
{'name': 'Authelia', 'url': 'http://10.5.30.6:9091', 'health_url': 'http://10.5.30.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'},
],
},
@@ -2022,7 +2019,7 @@ def top_nav(active:str)->str:
('Services','services.html','services'),
('Kitchen','http://10.5.30.7:8092','kitchen'),
('Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse'),
('Barbell','http://10.5.30.6:8093','barbell'),
('Barbell','https://gym.paccoco.com','barbell'),
('Donetick','https://donetick.paccoco.com','donetick'),
('Paperless','https://paperless.paccoco.com','paperless'),
('n8n','https://n8n.paccoco.com','n8n'),
@@ -2054,7 +2051,7 @@ def render_family_directory(active:str)->str:
('Services Directory','services.html','services','Switchboard','Dense launch wall for the wider stack once you know where you need to go.'),
('Doris Kitchen','http://10.5.30.7:8092','kitchen','Pantry desk','Recipe leads, import repair, and meal-planning evidence review.'),
('Doris Schoolhouse','https://schoolhouse.paccoco.com','schoolhouse','Records desk','Assignments, recordings, D2L sync, and archive intake.'),
('Doris Barbell','http://10.5.30.6:8093','barbell','Training desk','Body metrics, routines, workouts, and progression dossiers.'),
('Doris Barbell','https://gym.paccoco.com','barbell','Training desk','Body metrics, routines, workouts, and progression dossiers.'),
]
cards=[]
active_key='dashboard' if active=='home' else active

View File

@@ -2,6 +2,11 @@
> For Doris: this worksheet exists so tomorrow does not devolve into vibes and guessing. Every device on Legacy CIA gets one label: migrate, quarantine, or kill.
Historical status note:
- this is a 2026-05-22 migration worksheet, not the current remaining-work checklist
- do not treat its "tomorrow" language as active backlog without checking later result docs
- current truth for Protect accessories lives in `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: classify every remaining device on the legacy CIA Via VLAN/SSID so we can make deliberate decisions during and after the cutover.
Rules:
@@ -47,8 +52,8 @@ Use `kill` when all or most are true:
- MyQ -> IoT VLAN 40
- Samsung FamilyHub -> IoT VLAN 40
- LG dryer -> IoT VLAN 40
- doorbell -> Cameras VLAN 60
- Protect chimes -> Cameras VLAN 60
- doorbell -> Cameras VLAN 60 only if immediate validation is available
- Protect chimes -> Cameras VLAN 60 only after explicit Protect-path validation; current known-good live state keeps them on Management/default `UniFi Wireless`
### Migrate later / careful handling
- Google Home Mini -> IoT VLAN 40 after discovery testing
@@ -114,7 +119,7 @@ If answers are bad, it does not earn migration tomorrow.
### Protect chimes / doorbell
- These are not Legacy CIA end-state residents.
- Move to Cameras/Security as early cleanup.
- Treat Cameras/Security as the target design, not an automatic next action. The Wi-Fi chimes currently have a documented Management/default-SSID exception because the Camera override broke Protect health.
### old bulbs / old plugs
- If still useful but irrecoverable, quarantine.

View File

@@ -2,6 +2,11 @@
> For Doris: this is the one-page live operator sheet for tomorrow. Use this instead of bouncing between five docs while tired.
Historical status note:
- this file is a 2026-05-22 live cutover sheet, not the current remaining-work source of truth
- do not treat its open checkboxes or "tomorrow" language as active backlog
- current truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: execute the UniFi network redesign with minimal improvisation, explicit stop/go gates, per-port actions, per-device dispositions, and fast rollback discipline.
Architecture:
@@ -43,7 +48,7 @@ Current client counts:
- Trusted: 14
Current hazards:
- two unnamed `espressif` devices are on Management via `UniFi Wireless` on the U7 Pro
- the two historical `espressif` Management hazards are now known Protect WiFi chimes, not unidentified junk
- almost all WiFi clients are on the U7 Pro
- there are no custom firewall rules/groups yet, so tomorrows segmentation policy is basically greenfield
@@ -110,7 +115,7 @@ Stop/go gate:
- [ ] Trusted admin path still fine
### Minute 20-35: management cleanup first
- [ ] identify/address the 2 Management `espressif` devices
- [ ] confirm the two former Management `espressif` devices are still understood as the documented Protect-chime exception
- [ ] ensure infra devices remain on Management intent
- [ ] remove obvious non-infra junk from Management
@@ -120,7 +125,7 @@ Stop/go gate:
### Minute 35-50: security cleanup
- [ ] validate Camera lane
- [ ] move Protect chimes if ready
- [ ] move Protect chimes only if Protect health is explicitly validated during the move
- [ ] confirm doorbell remains healthy
### Minute 50-85: core server move wave
@@ -160,22 +165,22 @@ Stop/go gate:
## 5. Management Offender Sheet
These two devices are currently on Management and need identification or removal from that lane:
These two devices were the historical Management offenders that needed identification. They are no longer mystery devices, and they are not a rediscovery task:
1. `espressif`
- IP: `10.5.0.123`
- AP: `U7 Pro`
- SSID: `UniFi Wireless`
- Default call: identify first; if it is smart-junk, move/quarantine off Management
- Current call: known Protect WiFi chime; keep on the documented Management/default-SSID exception unless doing an explicit Protect-validation move
2. `espressif`
- IP: `10.5.0.189`
- AP: `U7 Pro`
- SSID: `UniFi Wireless`
- Default call: identify first; if it is smart-junk, move/quarantine off Management
- Current call: known Protect WiFi chime; keep on the documented Management/default-SSID exception unless doing an explicit Protect-validation move
Operator note:
- these are almost certainly exactly the sort of device that should not live on your infrastructure lane
- target architecture still says these belong with security estate, but the current known-good live state keeps them on Management/default `UniFi Wireless` because the Camera override failed in Protect
## 6. Exact Core Port Move Sheet
@@ -272,10 +277,14 @@ Rule:
Already true:
- `front-doorbell` is already on Camera at `10.5.20.217`
Tomorrows security work is therefore mainly:
- move/clean up chimes
- tighten policy
Current known-good chime exception:
- Protect WiFi Chime `upstairs landing` is intentionally on Management/default `UniFi Wireless` at `10.5.0.123`
- Protect WiFi Chime `Living Room` is intentionally on Management/default `UniFi Wireless` at `10.5.0.189`
If revisiting security work, the goal is therefore:
- preserve the doorbells healthy state
- tighten policy carefully
- only revisit chime placement during an explicit Protect-validation test
## 9. Firewall Build Priorities

View File

@@ -2,6 +2,11 @@
> For Doris: this is the live change-window script. Use it like a pilot checklist, not a brainstorming prompt.
Historical status note:
- this file is a 2026-05-22 cutover script, not the current remaining-work checklist
- do not treat unchecked boxes or "tomorrow" phrasing here as active backlog
- current truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: execute the redesign tomorrow with deliberate pauses, validation after every block of changes, and clean rollback points.
Assumptions:
@@ -83,7 +88,7 @@ Stop/go check:
- [ ] Confirm UDM, switches, and APs are mapped to Management intent.
- [ ] Remove obvious non-infrastructure devices from Management.
- [ ] Identify any stragglers remaining in Management and list them.
- [ ] If Security lane is ready, prepare to move chimes immediately.
- [ ] If revisiting Protect accessory placement, first confirm the documented chime Management exception and only move them during explicit Protect validation.
Validation:
- [ ] UniFi still sees gateway, switches, APs.
@@ -97,9 +102,9 @@ Rollback trigger:
- [ ] Activate/validate Cameras VLAN 60.
- [ ] Activate Security SSID if needed.
- [ ] Move Protect chimes.
- [ ] Move doorbell if ready.
- [ ] Confirm no security devices remain polluting Management.
- [ ] Move Protect chimes only if Protect health is validated during the move.
- [ ] Move doorbell only if immediate post-move validation is available.
- [ ] Confirm any remaining security-device Management exception is documented explicitly.
Validation:
- [ ] device rejoins expected lane
@@ -107,7 +112,7 @@ Validation:
- [ ] no broad emergency rules added yet
Rollback trigger:
- [ ] If doorbell/chimes become unrecoverable fast, revert that device only.
- [ ] If doorbell/chimes become unrecoverable fast, revert that device only; for the Wi-Fi chimes, clearing the Camera override and returning to Management/default `UniFi Wireless` is the known-good rollback.
## Minute 50-75: move core servers to Servers VLAN 30

View File

@@ -2,6 +2,10 @@
Use this during the live window when your brain gets dumb.
Historical status note:
- this card belongs to the 2026-05-22 cutover-planning wave
- keep it as operator evidence, but do not treat it as the current backlog without checking later migration result docs
Goal:
- make one clean change
- validate it

View File

@@ -2,10 +2,14 @@
Current intent: only safe, low-drama follow-up work remains. The easy-value client cleanup already completed should not be re-run.
Current truth note:
- use this file plus `unifi-protect-doorbell-chime-current-state-2026-05-26.md` as the source of truth for what is actually still pending
- treat earlier cutover-prep docs with "tomorrow" language as historical planning artifacts unless they were explicitly updated later
## Already completed
- Servers network/profile exists in UniFi
- Protect WiFi Chime `upstairs landing` moved from Management -> Camera
- Protect WiFi Chime `Living Room` moved from Management -> Camera
- Historical attempt moved both Protect WiFi chimes from Management -> Camera, but that change was later reversed after Protect health failed on the Camera-lane override
- Current known-good state keeps both Protect WiFi chimes on Management / default `UniFi Wireless`; see `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
- `LG_Smart_Laundry2_open` moved from Trusted -> IoT
- `MyQ-29B` moved from Old IoT -> IoT
- `LG_Smart_Dryer2_open` moved from Old IoT -> IoT
@@ -28,6 +32,7 @@ Current intent: only safe, low-drama follow-up work remains. The easy-value clie
- keep unknown Legacy CIA devices explicitly quarantine-first
- Trusted-lane Intellirocks cleanup is now resolved separately; see `intellirocks-triage-result-2026-05-23.md`
- Legacy CIA leftovers are now dispositioned separately; see `unifi-legacy-cia-closeout-2026-05-23.md`
- Protect chime lane cleanup is intentionally deferred until Protect connectivity is explicitly re-validated on a non-Management lane
3. Non-disruptive firewall planning cleanup
- compare staged firewall objects/policies against desired rule order
@@ -96,6 +101,7 @@ Important distinction:
- final validation sweep
- confirm no temporary panic exceptions remain
- document any intentionally deferred weird devices
- keep the Protect doorbell/chime exception documented until a future validated lane migration replaces it
## Suggested execution order from here
1. Wait for laptop session

View File

@@ -2,6 +2,11 @@
> For Doris: use this during the live UniFi change window. Goal is a controlled cutover with explicit validation and rollback at each boundary.
Historical status note:
- this runbook came from the 2026-05-22 cutover-planning wave
- preserve it as the detailed plan, but do not treat every "tomorrow" step as still pending without checking later result docs
- current remaining-work truth lives in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Goal: deploy the target VLAN/SSID/firewall design with minimum drama, keep legacy CIA Via as a quarantine lane for devices that cannot yet be migrated, and finish the window with an understandable, supportable network.
Architecture summary:
@@ -258,7 +263,7 @@ Exit criteria:
### Phase C: Management cleanup first
1. Ensure UDM, switches, and AP management surfaces are on VLAN 10 intent.
2. Remove obvious non-infrastructure devices from Management.
3. Move Protect chimes out of Management as top cleanup item if Security lane is ready.
3. Treat Protect chimes as a documented temporary Management exception unless you are explicitly re-validating their Protect health on a non-Management lane.
4. Re-validate UniFi visibility of gateway, switches, APs.
Rollback if:
@@ -272,14 +277,14 @@ Exit criteria:
### Phase D: Build/activate Cameras lane
1. Activate/validate `NET-CAMERAS`.
2. Activate `Security` SSID if used.
3. Move doorbell and Protect chimes to Cameras/Security.
3. Move doorbell and Protect chimes to Cameras/Security only if Protect health is explicitly validated during the move.
4. Validate app/admin behavior.
Rollback if:
- security devices fall offline and cannot be recovered quickly
Exit criteria:
- doorbell/chimes are no longer polluting Management
- either doorbell/chimes are healthy on Cameras/Security, or the documented Management exception is preserved intentionally
- Camera lane exists for future growth
### Phase E: Move core servers to Servers VLAN 30
@@ -353,8 +358,8 @@ Exit criteria:
## 9. Explicit Device Triage
Move tomorrow if practical:
- Protect chimes
- doorbell
- Protect chimes only during an explicit Protect-validation test
- doorbell only if healthy-state validation is immediate and reversible
- PD
- Serenity
- Nomad
@@ -403,6 +408,7 @@ Minimum successful tomorrow outcome:
- IoT lane exists and at least easy-value devices are moved
- Legacy CIA is converted into explicit quarantine/sunset lane
- no broad insecure exceptions were added out of fatigue
- any documented temporary Protect chime exception is written down instead of being rediscovered later
Nice-to-have, not mandatory tomorrow:
- U6 LR restored and tuned

View File

@@ -49,6 +49,11 @@ Architecture summary:
- Contains: doorbell, Protect chimes, future cameras, NVR-adjacent accessories, security-specific Wi-Fi endpoints
- Policy: no broad access to LAN; only specific flows to Protect/NVR services and operator/admin clients
Current live exception:
- this is still the target design, not the currently proven-safe lane for every Protect accessory
- a 2026-05-22 client-override attempt put the Wi-Fi chimes on the Camera lane, but they later had to be returned to Management/default `UniFi Wireless` because Protect health failed there
- future camera/security-lane work must preserve this documented exception until Protect connectivity is explicitly re-validated
## SSID Plan
Preferred steady-state SSIDs:
@@ -107,7 +112,7 @@ IoT / Smart Home:
Cameras / Security:
- front-doorbell
- Protect chimes
- Protect chimes (target design; current live exception keeps the Wi-Fi chimes on Management/default `UniFi Wireless` until Protect validation exists on the security lane)
- future wired/PoE cameras
- future Wi-Fi security accessories
@@ -176,7 +181,7 @@ Phase 2: servers
- update DNS/static mappings/firewall rules
Phase 3: cameras/security
- move doorbell and chimes to VLAN 60
- move doorbell and chimes to VLAN 60 only after explicit Protect-path validation proves that the Wi-Fi chimes stay healthy there
- prepare room for future camera growth
Phase 4: IoT migration

View File

@@ -2,6 +2,10 @@
> For Doris: this is the concrete disposition call for every current live Old IoT client seen during read-only recon. This is the list to use tomorrow unless live validation proves otherwise.
Historical status note:
- this is a 2026-05-22 planning artifact
- several "tomorrow" items here were completed later; do not use this file as the current backlog without checking `network-migration-remaining-checklist-2026-05-22.md`
Source basis:
- live UniFi read-only recon on 2026-05-22
- all devices listed were active on SSID `CIA Via` through the `U7 Pro`

View File

@@ -1,5 +1,7 @@
# UniFi Client Cleanup Shortlist
Status note: this file started as a live shortlist on 2026-05-22. Most of the appliance/thermostat cleanup in here is completed. For the current Protect doorbell/chime truth, do not rely on the original chime recommendation below without also reading `unifi-protect-doorbell-chime-current-state-2026-05-26.md`.
Live basis:
- Captured from PD with `python3 automation/bin/unifi_network.py --raw`
- Capture time: 2026-05-22 21:45:20 UTC
@@ -40,8 +42,8 @@ These are the clearest remaining misclassifications, and the smart-appliance tar
- Why: vendor-family clustering strongly suggests it belongs with the other low-trust embedded devices, not with human/operator endpoints.
- Caution: identify before moving if possible; if unknown, do not promote it by leaving it in Trusted.
## Management offenders
These are now identified and should not remain on Management.
## Management offenders (historical identification, but not current next-step guidance)
These were correctly identified on 2026-05-22, but the original recommendation to re-home them immediately to `Camera` / security is no longer the standing advice.
- `58:d6:1f:54:e5:6d` (`10.5.0.123`, hostname `espressif`)
- Identified as: UniFi Protect WiFi Chime
@@ -56,7 +58,9 @@ These are now identified and should not remain on Management.
Notes:
- Both are Wi-Fi clients on `UniFi Wireless`.
- UniFi fingerprint metadata reports `product_line=unifi-protect` and `product_model=Protect WiFi Chime`.
- This means they are not mystery ESP junk anymore; they are known Protect accessories that should be treated like camera/security estate, not management-plane clients.
- This means they are not mystery ESP junk anymore; they are known Protect accessories.
- Current known-good live state keeps them on Management/default `UniFi Wireless` because the Camera-lane override caused them to show offline in Protect.
- Do not re-home these two chimes again unless Protect connectivity is explicitly validated during the move.
## Old IoT move-now set
These still look like the cleanest deliberate moves from `CIA Via` into `IoT`.
@@ -88,7 +92,7 @@ Leave these in legacy quarantine unless/until they are identified better.
If doing a low-drama cleanup pass, use this order:
1. Move `LG_Smart_Laundry2_open` out of `Trusted` into `IoT`
2. Re-home the two identified Protect chimes out of `Management` into `Camera` / security
2. Leave the two identified Protect chimes on their current known-good Management/default-SSID path unless you are doing an explicit Protect-validation test
3. Migrate the approved Old IoT move-now set into `IoT`, one app-validated batch at a time
4. Leave Google/cast-class gear for later unless everything else is stable
5. Keep unknown leftovers quarantined; do not “clean them up” by granting `Trusted`
@@ -98,5 +102,5 @@ The clearest remaining lane problems are now:
- one approved LG appliance still sitting in `Trusted`
- one likely Google cast/display-class client in `Trusted`
- one likely Intellirocks smart-home client in `Trusted`
- two now-identified Protect chimes still sitting in `Management`
- the Protect chimes are a documented temporary exception, not a rediscovery task
- the approved appliance/thermostat/MyQ devices still lingering in `Old IoT`

View File

@@ -1,13 +1,15 @@
# UniFi Client Rehome Results
Historical execution note: this file records what was applied on 2026-05-22, not the final lasting truth for every device. For the current known-good Protect doorbell/chime state, see `unifi-protect-doorbell-chime-current-state-2026-05-26.md`.
Executed from PD against the live UniFi controller using per-client virtual network overrides plus targeted `kick-sta` reconnects.
## Requested changes completed
## Requested changes applied on 2026-05-22
### Trusted -> IoT
- `LG_Smart_Laundry2_open` (`60:ab:14:5f:b4:ba`) -> `IoT` -> `10.5.10.141`
### Management -> Camera
### Management -> Camera (historical attempt; later reverted for the chimes)
- Protect WiFi Chime `upstairs landing` (`58:d6:1f:54:e5:6d`) -> `Camera` -> `10.5.20.191`
- Protect WiFi Chime `Living Room` (`58:d6:1f:54:e5:af`) -> `Camera` -> `10.5.20.8`
@@ -18,11 +20,23 @@ Executed from PD against the live UniFi controller using per-client virtual netw
- `Main-Floor` ecobee (`44:61:32:1d:ee:8f`) -> `IoT` -> `10.5.10.160`
- `Upstairs` ecobee (`44:61:32:3f:0b:4c`) -> `IoT` -> `10.5.10.64`
## Validation result
All 8 targeted clients were re-read live from `stat/sta` and matched their intended target lanes.
## Validation result at the time
All 8 targeted clients were re-read live from `stat/sta` and matched their intended target lanes during the 2026-05-22 apply window.
Important caveat:
- lane placement success in UniFi Network did not prove Protect health for the chimes
- a later follow-up showed the two chimes could appear associated in UniFi Network while still showing offline in Protect
## Follow-up result that supersedes the chime portion
Later known-good state:
- Protect WiFi Chime `upstairs landing` was returned to `Management` / default `UniFi Wireless` -> `10.5.0.123`
- Protect WiFi Chime `Living Room` was returned to `Management` / default `UniFi Wireless` -> `10.5.0.189`
- the Camera-lane virtual-network override for the chimes should now be treated as a failed experiment in this environment, not as the standing recommendation
## Notes
- Rehome mechanism used: `PUT /rest/user/<id>` with `network_id`, `virtual_network_override_enabled=true`, and `virtual_network_override_id=<target_network_id>`
- Reconnect mechanism used: `POST /cmd/stamgr` with `cmd=kick-sta`
- `LG_Smart_Laundry2_open` had a temporary probe note during discovery; it was cleared during the live apply
- Some clients still show their original SSID names in UniFi telemetry even after landing on the target virtual network override; the authoritative validation point for this pass was the live `network` / `network_id` state
- The Protect lesson learned here is specific and important: a client can look correctly placed in UniFi Network while still being unhealthy in Protect.

View File

@@ -1,5 +1,10 @@
# UniFi Live Preflight Snapshot
Historical snapshot note:
- this file is a dated preflight capture from 2026-05-22
- preserve it as evidence, but do not treat it as the current source of truth for remaining work or final device placement
- later results and reversals are documented in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Date: 2026-05-22
Mode: read-only live pull from PD using the Doris UniFi operator account
Controller: `https://10.5.0.1`
@@ -82,7 +87,7 @@ Interpretation:
- `58:d6:1f:54:e5:6d` -> `10.5.0.123`
- `58:d6:1f:54:e5:af` -> `10.5.0.189`
These are still the two management-lane ESP-class offenders to identify or evict.
Historical interpretation only: these were later identified as the two Protect WiFi chimes, so this section is evidence of the pre-identification state, not an open rediscovery task.
## Fresh anomalies vs the earlier planning assumptions

View File

@@ -0,0 +1,60 @@
# UniFi Protect Doorbell / Chime Current State — 2026-05-26
Purpose: capture the current known-good state for the adopted doorbell and the two Protect WiFi chimes so future cleanup work does not accidentally re-run a reverted change.
## Status summary
Current known-good state:
- `front-doorbell` remains the Protect doorbell endpoint
- Protect WiFi Chime `upstairs landing` is intentionally on `Management` / default `UniFi Wireless`
- Protect WiFi Chime `Living Room` is intentionally on `Management` / default `UniFi Wireless`
- per-client Camera virtual-network overrides for the chimes should remain disabled unless a later validation proves a safe Protect path on the Camera/Security lane
Current chime IPs from the last known-good state:
- `upstairs landing` -> `10.5.0.123`
- `Living Room` -> `10.5.0.189`
## What changed from the earlier migration notes
Historical 2026-05-22 action:
- both chimes were moved from `Management` to `Camera` using per-client virtual network overrides
- UniFi Network showed them associated on the target lane
Why that is not the source of truth anymore:
- in this environment, the Camera-lane override made the chimes show offline in Protect even though UniFi Network still showed them connected
- the working fix was to remove the Camera override and let the chimes return to the known-good Management/default-SSID path
Operational conclusion:
- treat the 2026-05-22 chime move as a historical experiment, not a standing recommendation
- do not re-home the chimes to `Camera` / `Security` as routine cleanup without fresh Protect validation
## Doorbell adoption note
After a doorbell is re-adopted:
- verify the chimes are still logically bound to the current adopted doorbell object
- if chime ringing breaks even though the devices are online, check whether each chime's `ringSettings.cameraId` still points at the old doorbell object instead of the current adopted one
This is separate from the network-lane issue:
- network-lane failure symptom: chime looks connected in UniFi Network but offline in Protect
- adoption-binding failure symptom: doorbell is adopted and devices are online, but the chimes do not ring for the current doorbell object
## Future-state intent versus current safe state
Long-term design intent still favors a dedicated Camera/Security lane and SSID for Protect accessories.
Current safe live state is narrower:
- doorbell can stay where it is if healthy
- chimes stay on Management/default `UniFi Wireless` until Protect connectivity is explicitly re-validated on the Camera/Security lane
- future migration is blocked on proving the required Protect path, not on re-discovering the device identities
## Files that were updated to reflect this
Key current-truth docs:
- `network-migration-remaining-checklist-2026-05-22.md`
- `unifi-client-cleanup-shortlist-2026-05-22.md`
- `unifi-client-rehome-results-2026-05-22.md`
- `unifi-ssid-cleanup-proposal-2026-05-23.md`
- `network-redesign-plan.md`
- `network-redesign-implementation-runbook.md`
Historical planning/snapshot docs were also marked so they are not mistaken for current next-step instructions.

View File

@@ -1,5 +1,10 @@
# UniFi Read-Only Recon Notes
Historical snapshot note:
- this file records the 2026-05-22 read-only discovery pass
- preserve it as evidence, but do not use it as the current next-step checklist without checking later outcome docs
- later results and reversals are documented in `network-migration-remaining-checklist-2026-05-22.md` and `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
Date: 2026-05-22
Mode: read-only only
Auth status: verified working with upgraded Doris admin login
@@ -79,9 +84,9 @@ Likely quarantine until identified better:
### 6. Camera lane live state
- `front-doorbell` is already on Camera network at `10.5.20.217`
Interpretation:
- the doorbell is already where it belongs logically
- tomorrows security cleanup work is mainly about chimes and future policy tightening
Interpretation at the time:
- the doorbell was already in the expected camera lane during this snapshot
- later follow-up changed the chime guidance, so do not treat this paragraph as the final remaining-work instruction set
### 7. Trusted lane still carrying infrastructure-class hosts
Trusted currently includes:
@@ -131,8 +136,8 @@ Interpretation:
- Rocinante needs a quick extra look because the client is live on port 2 but that port did not show an explicit profile name in the returned record
## Recommended next actions for tomorrow
1. identify the two Management `espressif` devices before or during the window
1. historical note: the two Management `espressif` devices were later identified as Protect WiFi chimes; do not treat this as an open rediscovery task
2. move/changeprofile for server ports on the USW Pro HD 24 one at a time
3. treat Google devices as late-batch or defer
4. treat unknown Old IoT clients as quarantine by default, not migration by default
5. keep Camera cleanup focused on chimes and policy, because the doorbell is already in the correct lane
5. keep Camera cleanup focused on validated policy and documented exceptions; the doorbell was already in the correct lane during this snapshot, but later chime guidance changed

View File

@@ -2,6 +2,10 @@
Purpose: turn the remaining SSID cleanup into an exact low-risk keep/retire proposal before any live Wi-Fi changes.
Status note:
- this remains useful as a keep/retire proposal, but the Protect chime portion must now be read together with `unifi-protect-doorbell-chime-current-state-2026-05-26.md`
- current known-good chime behavior is on Management/default `UniFi Wireless`, not on the Camera/Security lane
## Basis
Artifact-based planning only. No live SSID edits were made in this pass.
@@ -87,8 +91,11 @@ Long-term, the network should converge on:
- doorbell and Protect-chime estate justify a distinct security SSID
- keeping security separate from generic IoT matches the redesign intent and future camera growth plan
- Preconditions to call it final:
- doorbell/chimes remain healthy
- doorbell/chimes remain healthy after explicit Protect validation on that lane
- no evidence that merging them into general IoT would be safer or simpler
- Current caution:
- treat the Security SSID as future-state design intent, not proof that the Wi-Fi chimes are safe there today
- the current known-good live path for the chimes is still Management/default `UniFi Wireless`
### 5) Guest SSID
- Proposed role: `create or enable separately when ready`
@@ -116,6 +123,7 @@ This is the safest order based on current evidence:
- do not rename and repurpose a live SSID in one step if disabling/creating separately would be clearer
- do not infer that Google/cast is solved globally from one successful pilot reassociation
- do not collapse Security into generic IoT unless there is a deliberate design change
- do not assume that because `UNEF's Playhouse` exists, the Protect chimes are already proven safe on it
## Practical one-page operator version
If you want the shortest operator call:

View File

@@ -2,6 +2,10 @@
> For Doris: this is the mechanical switch-port sheet for tomorrow. Do not freestyle. Change one thing, validate, then continue.
Historical status note:
- this is a 2026-05-22 cutover sheet, preserved as evidence of the original plan
- do not treat its "tomorrow" wording as active backlog without checking later migration result docs
Source basis:
- live UniFi read-only recon on 2026-05-22
- /home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard/docs/unifi-readonly-recon-2026-05-22.md

View File

@@ -21,7 +21,7 @@
<a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a>
<a class="nav-link" href="https://schoolhouse.paccoco.com/">Schoolhouse</a>
<a class="nav-link" href="http://10.5.30.6:8093/">Barbell</a>
<a class="nav-link" href="https://gym.paccoco.com/">Barbell</a>
</nav>
</div>
<div class="casefile-title-row">
@@ -137,7 +137,7 @@
<strong>Doris Schoolhouse</strong>
<small>Assignments, recordings, and archive intake.</small>
</a>
<a class="family-app-card" href="http://10.5.30.6:8093/">
<a class="family-app-card" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Training desk</span>
<strong>Doris Barbell</strong>
<small>Weights, routines, workouts, and progression snapshots.</small>

View File

@@ -1,6 +1,7 @@
import hashlib
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse
@@ -40,6 +41,11 @@ def _confirm_submission_to_paperless(current: dict) -> dict:
if value:
fields[optional_key] = value
artifact_kind = handoff.get("artifact_kind") or "document"
response: dict[str, Any]
body_obj: dict[str, Any]
ok = False
error_detail = None
try:
if artifact_kind == "source_bundle":
paperless_response = paperless.upload_source_bundle_receipt(current)
enrichment = paperless.enrich_direct_submission(current, paperless_response)
@@ -67,6 +73,17 @@ def _confirm_submission_to_paperless(current: dict) -> dict:
body = response.get("body", {}) if isinstance(response, dict) else {}
body_obj = body[0] if isinstance(body, list) and body and isinstance(body[0], dict) else (body if isinstance(body, dict) else {})
ok = bool(response.get("ok", False)) if isinstance(response, dict) else False
if not ok:
error_detail = str(response.get("error") or response.get("body") or "Paperless confirmation failed.")
except Exception as exc:
error_detail = f"{type(exc).__name__}: {exc}"
response = {
"ok": False,
"status_code": 500,
"mode": "direct-paperless" if artifact_kind == "source_bundle" else "webhook",
"error": error_detail,
}
body_obj = {}
updates = {
"status": "paperless-queued" if ok else "paperless-error",
"confirmed_at": store.now(),
@@ -76,6 +93,7 @@ def _confirm_submission_to_paperless(current: dict) -> dict:
"paperless_task_id": body_obj.get("paperless_task_id"),
}
updated = repo.update_submission(str(current.get("id")), updates) or current
updated["paperless_error_detail"] = error_detail
grade_note = None
if ok and updated.get("assignment_id"):
grade_note = paperless.append_grade_note_if_available(str(updated.get("assignment_id")))
@@ -87,6 +105,7 @@ def _confirm_submission_to_paperless(current: dict) -> dict:
"webhook": [response],
"grade_note": grade_note,
"file_count": handoff.get("file_count") or 1,
"error_detail": error_detail,
}
@@ -220,7 +239,10 @@ async def intake_assignment(
"backend": repo.backend,
}
if "text/html" in accept:
redirect_url = "/assignments/upload?submitted=" + str(created["id"]) + "&file_count=" + str(bundle["file_count"]) + "&artifact_kind=" + str(created.get("artifact_kind", "document")) + "&paperless_status=" + str(created.get("status", "uploaded-local"))
status_value = str(created.get("status", "uploaded-local"))
redirect_url = "/assignments/upload?submitted=" + str(created["id"]) + "&file_count=" + str(bundle["file_count"]) + "&artifact_kind=" + str(created.get("artifact_kind", "document")) + "&paperless_status=" + status_value
if auto_confirmed and auto_confirmed.get("error_detail"):
redirect_url += "&paperless_error=" + quote_plus(str(auto_confirmed["error_detail"]))
return RedirectResponse(url=redirect_url, status_code=303)
return response_payload

View File

@@ -1,12 +1,19 @@
{% extends 'base.html' %}
{% block content %}
{% if request.query_params.get('submitted') %}
<section class="card form-card notice-card success-card evidence-board">
<div class="case-legend"><span class="section-label">Submission packet</span><span class="pill">Filed</span></div>
<h2>Submission Created</h2>
{% set paperless_status = request.query_params.get('paperless_status', 'uploaded-local') %}
<section class="card form-card notice-card {{ 'success-card' if paperless_status == 'paperless-queued' else '' }} evidence-board">
<div class="case-legend"><span class="section-label">Submission packet</span><span class="pill">{{ 'Filed' if paperless_status == 'paperless-queued' else 'Saved locally' }}</span></div>
<h2>{{ 'Submission Created' if paperless_status == 'paperless-queued' else 'Submission Saved, Archive Pending' }}</h2>
<p>Submission {{ request.query_params.get('submitted') }} was created successfully.</p>
<p class="muted">Artifact: {{ request.query_params.get('artifact_kind', 'document') }} · Files: {{ request.query_params.get('file_count', '1') }}</p>
<p class="muted">Paperless status: {{ request.query_params.get('paperless_status', 'uploaded-local') }}</p>
<p class="muted">Paperless status: {{ paperless_status }}</p>
{% if paperless_status != 'paperless-queued' %}
<p class="muted">Schoolhouse kept the submission locally, but the archive handoff failed this time.</p>
{% if request.query_params.get('paperless_error') %}
<p class="muted">Error: {{ request.query_params.get('paperless_error') }}</p>
{% endif %}
{% endif %}
</section>
{% endif %}
<section class="card form-card evidence-board">

View File

@@ -21,7 +21,7 @@
<a class="nav-link" href="http://10.5.30.7:8787/">Dashboard</a>
<a class="nav-link" href="http://10.5.30.7:8787/services.html">Services</a>
<a class="nav-link" href="http://10.5.30.7:8092/">Kitchen</a>
<a class="nav-link" href="http://10.5.30.6:8093/">Barbell</a>
<a class="nav-link" href="https://gym.paccoco.com/">Barbell</a>
</nav>
</div>
<div class="casefile-title-row">
@@ -134,7 +134,7 @@
<strong>Doris Kitchen</strong>
<small>Meal discovery, import repair, and planner triage.</small>
</a>
<a class="family-app-card" href="http://10.5.30.6:8093/">
<a class="family-app-card" href="https://gym.paccoco.com/">
<span class="family-app-kicker">Training desk</span>
<strong>Doris Barbell</strong>
<small>Workout logs, body metrics, and progression dossiers.</small>

View File

@@ -0,0 +1,9 @@
TZ=America/Chicago
TRUSTED_PARENT_INTERFACE=enp5s0.51
TRUSTED_SUBNET=10.5.1.0/24
TRUSTED_GATEWAY=10.5.1.1
LOCALSEND_BIND_IP=10.5.1.16
LOCALSEND_DEVICE_NAME="Doris Trusted Inbox"
LOCALSEND_CLI_REF=54702513990eeb763e1615f274d5365442e3e393
APPDATA_LOCALSEND_ROOT=/opt/localsend-nomad/data
INBOX_DIR=/home/fizzlepoof/private/inbox-secrets

View File

@@ -0,0 +1,19 @@
FROM golang:1.25-alpine AS build
ARG LOCALSEND_CLI_REF=54702513990eeb763e1615f274d5365442e3e393
RUN apk add --no-cache git patch
WORKDIR /src
RUN git clone https://github.com/0w0mewo/localsend-cli.git . \
&& git checkout "$LOCALSEND_CLI_REF"
COPY patches/localsend-cli-doris.patch /tmp/localsend-cli-doris.patch
RUN git apply /tmp/localsend-cli-doris.patch \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/localsend .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 1000 doris
WORKDIR /app
COPY --from=build /out/localsend /usr/local/bin/localsend
USER doris
ENTRYPOINT ["/usr/local/bin/localsend"]

81
localsend-nomad/README.md Normal file
View File

@@ -0,0 +1,81 @@
# LocalSend trusted-lane receiver on N.O.M.A.D.
Repo-tracked Docker stack that runs a headless LocalSend receiver on N.O.M.A.D. with its own Trusted-LAN IP.
- Repo stack path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad`
- Live stack path: `/opt/localsend-nomad`
- Trusted bind IP: `10.5.1.16`
- Trusted VLAN: `51`
- LocalSend ports: `53317/tcp`, `53317/udp`
- Inbox path on host: `/home/fizzlepoof/private/inbox-secrets`
- Advertised device name: `Doris Trusted Inbox`
## Why this exists
N.O.M.A.D. lives on the `Servers` VLAN (`10.5.30.7`), but some operator handoff flows need a LocalSend receiver directly reachable from the `Trusted` LAN (`10.5.1.0/24`). This stack gives LocalSend its own Trusted-LAN address without moving the host itself off the Servers network.
## Network model
- Docker uses a `macvlan` network with parent `enp5s0.51`.
- `enp5s0.51` is the Trusted VLAN subinterface on N.O.M.A.D.'s primary NIC.
- Docker will create the VLAN subinterface automatically when the macvlan network is created.
- Same-host testing from N.O.M.A.D. to `10.5.1.16` is unreliable because macvlan isolates the host from the container's address on the same parent interface.
- Verify reachability from another Trusted-LAN peer instead. Do not rely on PD's old `10.5.1.6` identity; use any current Trusted-LAN test client that can reach `10.5.1.16`.
## LocalSend build provenance
The image is built locally from upstream `0w0mewo/localsend-cli` at:
- commit: `54702513990eeb763e1615f274d5365442e3e393`
Then it applies `patches/localsend-cli-doris.patch`, which keeps the receiver compatible with the LocalSend endpoint behavior already working in the existing Doris receiver flow.
## Files
- `docker-compose.yaml` — live stack definition
- `Dockerfile` — reproducible CLI build from upstream + patch
- `.env.example` — deployment variables
- `bin/prepare_nomad.sh` — creates runtime dirs, validates config, optional `--up`
- `patches/localsend-cli-doris.patch` — repo-tracked patch applied during image build
## Deploy / refresh on NOMAD
```bash
cd /opt/localsend-nomad
cp .env.example .env
./bin/prepare_nomad.sh --up
```
## Verification
From N.O.M.A.D.:
```bash
docker compose --env-file .env ps
sudo docker logs --tail 50 localsend-trusted
systemctl --user status minerva-localsend-autosort.path --no-pager
systemctl --user status minerva-localsend-autosort.service --no-pager
```
From another Trusted-LAN host:
```bash
ping 10.5.1.16
nc -vz 10.5.1.16 53317
```
Then confirm `Doris Trusted Inbox` appears as a LocalSend target from a Trusted-LAN device.
## Shared inbox / Minerva handoff
- The receiver writes into `/home/fizzlepoof/private/inbox-secrets`.
- Minerva's staging flow watches that shared inbox via `minerva-localsend-autosort.path` and runs `/home/fizzlepoof/.local/bin/minerva-localsend-autosort.py`.
- Sender-specific routing lives in `/home/fizzlepoof/private/minerva-dropbox/sender-map.json`.
- Metadata/manifests for staged items land under `/home/fizzlepoof/private/minerva-dropbox/manifests/`.
- If you need to confirm sender attribution, check both `journalctl --user -u doris-localsend.service` and `docker logs localsend-trusted`; the autosort helper reads both so Trusted-LAN receives keep sender-IP-aware routing.
## Notes
- The container writes directly into `/home/fizzlepoof/private/inbox-secrets` on the host so the existing autosort / intake flow can keep using the same inbox path.
- This stack does not publish host ports; the dedicated Trusted-LAN IP is the service endpoint.
- If the upstream CLI behavior changes later, rebuild from a reviewed newer commit and update the patch or drop it if upstream no longer needs the workaround.

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${ENV_FILE:-$STACK_DIR/.env}"
DO_UP="${1:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE — copy .env.example to .env and fill in real values first." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
if [[ -z "${APPDATA_LOCALSEND_ROOT:-}" || -z "${INBOX_DIR:-}" ]]; then
echo "APPDATA_LOCALSEND_ROOT and INBOX_DIR must be set in .env" >&2
exit 1
fi
mkdir -p \
"$APPDATA_LOCALSEND_ROOT/state" \
"$INBOX_DIR"
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" config >/dev/null
if [[ "$DO_UP" == "--up" ]]; then
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" up -d --build
docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" ps
fi

View File

@@ -0,0 +1,35 @@
services:
localsend:
build:
context: .
args:
LOCALSEND_CLI_REF: ${LOCALSEND_CLI_REF}
image: localsend-nomad:local
container_name: localsend-trusted
hostname: localsend-trusted
restart: unless-stopped
environment:
TZ: ${TZ}
networks:
trusted_macvlan:
ipv4_address: ${LOCALSEND_BIND_IP}
working_dir: /app/state
volumes:
- ${APPDATA_LOCALSEND_ROOT}/state:/app/state
- ${INBOX_DIR}:/data/inbox
command:
- recv
- --devname
- ${LOCALSEND_DEVICE_NAME}
- --dir
- /data/inbox
networks:
trusted_macvlan:
driver: macvlan
driver_opts:
parent: ${TRUSTED_PARENT_INTERFACE}
ipam:
config:
- subnet: ${TRUSTED_SUBNET}
gateway: ${TRUSTED_GATEWAY}

View File

@@ -0,0 +1,92 @@
diff --git a/internal/localsend/localsend.go b/internal/localsend/localsend.go
index e30e6f5..c0c2b8e 100644
--- a/internal/localsend/localsend.go
+++ b/internal/localsend/localsend.go
@@ -2,6 +2,7 @@ package localsend
import (
"encoding/json"
+ "fmt"
"net"
"github.com/0w0mewo/localsend-cli/internal/localsend/constants"
@@ -13,41 +14,53 @@ import (
func GetDeviceInfo(ip string, https bool) (models.DeviceInfo, error) {
remoteAddr := net.JoinHostPort(ip, "53317")
- agent := fiber.AcquireAgent()
- defer fiber.ReleaseAgent(agent)
-
scheme := "http"
if https {
scheme = "https"
}
- req := agent.Request()
- req.URI().SetScheme(scheme)
- req.URI().SetHost(remoteAddr)
- req.URI().SetPath(constants.InfoPath)
- req.Header.SetMethod(fiber.MethodGet)
- err := agent.Parse()
- if err != nil {
- return models.DeviceInfo{}, err
- }
+ paths := []string{constants.InfoPath, "/api/localsend/v2/info", constants.InfoPathLegacy}
+ var lastErr error
- status, b, errs := agent.InsecureSkipVerify().Bytes()
- if len(errs) != 0 {
- return models.DeviceInfo{}, errs[0]
- }
- err = constants.ParseError(status)
- if err != nil {
- return models.DeviceInfo{}, err
- }
+ for _, path := range paths {
+ agent := fiber.AcquireAgent()
+ req := agent.Request()
+ req.URI().SetScheme(scheme)
+ req.URI().SetHost(remoteAddr)
+ req.URI().SetPath(path)
+ req.Header.SetMethod(fiber.MethodGet)
+ err := agent.Parse()
+ if err != nil {
+ fiber.ReleaseAgent(agent)
+ return models.DeviceInfo{}, err
+ }
- var res models.DeviceInfo
- err = json.Unmarshal(b, &res)
- if err != nil {
- return models.DeviceInfo{}, err
+ status, b, errs := agent.InsecureSkipVerify().Bytes()
+ fiber.ReleaseAgent(agent)
+ if len(errs) != 0 {
+ lastErr = errs[0]
+ continue
+ }
+ err = constants.ParseError(status)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+
+ var res models.DeviceInfo
+ err = json.Unmarshal(b, &res)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ res.IP = ip
+ return res, nil
}
- res.IP = ip
- return res, nil
+ if lastErr == nil {
+ lastErr = fmt.Errorf("device info not found on known endpoints")
+ }
+ return models.DeviceInfo{}, lastErr
}
func NewFileSender(useDownloadAPI ...bool) send.FileSender {

View File

@@ -13,13 +13,29 @@ SEERR_DB_NAME=seerr
SEERR_DB_USER=seerr
SEERR_DB_PASS=changeme
# GameVault postgres target on shared-postgres
# GameVault dedicated postgres target on the PD media stack
GAMEVAULT_DB_NAME=gamevault
GAMEVAULT_DB_USER=gamevault
GAMEVAULT_DB_PASS=changeme
METADATA_IGDB_CLIENT_ID=changeme
METADATA_IGDB_CLIENT_SECRET=changeme
# RomM dedicated MariaDB target on the PD media stack
ROMM_DB_ROOT_PASSWORD=changeme
ROMM_DB_NAME=romm
ROMM_DB_USER=romm
ROMM_DB_PASS=changeme
STEAMGRIDDB_API_KEY=changeme
MOBYGAMES_API_KEY=changeme
IGDB_CLIENT_ID=changeme
IGDB_CLIENT_SECRET=changeme
ROMM_AUTH_SECRET_KEY=changeme
ENABLE_SCHEDULED_RESCAN=true
GUNICORN_WORKERS=2
RESCAN_ON_FILESYSTEM_CHANGE_DELAY=5
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB=true
SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON=0 4 * * *
SCHEDULED_RESCAN_CRON=0 3 * * *
SCAN_TIMEOUT=14400
DISABLE_CSRF_PROTECTION=false
DISABLE_DOWNLOAD_ENDPOINT_AUTH=false

View File

@@ -28,6 +28,25 @@ services:
retries: 5
start_period: 30s
gamevault-db:
container_name: gamevault-db
image: postgres:17
restart: unless-stopped
networks:
- media-net
environment:
POSTGRES_USER: ${GAMEVAULT_DB_USER}
POSTGRES_PASSWORD: ${GAMEVAULT_DB_PASS}
POSTGRES_DB: gamevault
volumes:
- /mnt/docker-ssd/docker/appdata/gamevault-db/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${GAMEVAULT_DB_USER} -d gamevault"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
plex:
container_name: ix-plex-plex-1
image: lscr.io/linuxserver/plex:latest
@@ -108,6 +127,94 @@ services:
retries: 3
start_period: 30s
gamevault:
container_name: gamevault
user: "99:100"
image: phalcode/gamevault-backend:latest
restart: unless-stopped
networks:
- media-net
- ix-databases_shared-databases
- pangolin
ports:
- "8785:8080"
environment:
TZ: ${TZ}
PUID: 99
PGID: 100
NODE_ENV: production
DB_HOST: gamevault-db
DB_PORT: 5432
DB_USERNAME: ${GAMEVAULT_DB_USER}
DB_PASSWORD: ${GAMEVAULT_DB_PASS}
SAVEFILES_ENABLED: "true"
MEDIA_GC_INTERVAL_IN_MINUTES: 60
METADATA_IGDB_ENABLED: "true"
METADATA_IGDB_CLIENT_ID: ${METADATA_IGDB_CLIENT_ID}
METADATA_IGDB_CLIENT_SECRET: ${METADATA_IGDB_CLIENT_SECRET}
RAWG_API_URL: https://rawg2steam.phalco.de/api
RAWG_API_CACHE_DAYS: 36500
SERVER_PORT: 8080
SERVER_HTTPS_PORT: 8443
YES: "yes"
volumes:
- /mnt/unraid/data/media/Games-Apps Isos:/files
- /mnt/unraid/data/media/Saved Games:/savefiles
- /mnt/docker-ssd/docker/appdata/gamevault/media:/media
- /mnt/docker-ssd/docker/appdata/gamevault/logs:/logs
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:$${SERVER_PORT}/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 300s
romm:
container_name: romm
image: rommapp/romm:latest
restart: unless-stopped
depends_on:
romm-db:
condition: service_healthy
networks:
- media-net
- pangolin
ports:
- "8457:8080"
environment:
TZ: ${TZ}
DB_HOST: romm-db
DB_PORT: 3306
DB_USER: ${ROMM_DB_USER}
DB_NAME: ${ROMM_DB_NAME}
DB_PASSWD: ${ROMM_DB_PASS}
STEAMGRIDDB_API_KEY: ${STEAMGRIDDB_API_KEY}
ENABLE_SCHEDULED_RESCAN: ${ENABLE_SCHEDULED_RESCAN}
GUNICORN_WORKERS: ${GUNICORN_WORKERS}
MOBYGAMES_API_KEY: ${MOBYGAMES_API_KEY}
IGDB_CLIENT_ID: ${IGDB_CLIENT_ID}
RESCAN_ON_FILESYSTEM_CHANGE_DELAY: ${RESCAN_ON_FILESYSTEM_CHANGE_DELAY}
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB: ${ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB}
SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON: ${SCHEDULED_UPDATE_SWITCH_TITLEDB_CRON}
DISABLE_DOWNLOAD_ENDPOINT_AUTH: ${DISABLE_DOWNLOAD_ENDPOINT_AUTH}
ROMM_AUTH_SECRET_KEY: ${ROMM_AUTH_SECRET_KEY}
IGDB_CLIENT_SECRET: ${IGDB_CLIENT_SECRET}
SCHEDULED_RESCAN_CRON: ${SCHEDULED_RESCAN_CRON}
SCAN_TIMEOUT: ${SCAN_TIMEOUT}
DISABLE_CSRF_PROTECTION: ${DISABLE_CSRF_PROTECTION}
volumes:
- /mnt/unraid/data/media/RomM:/romm/library
- /mnt/docker-ssd/docker/appdata/romm/resources:/romm/resources
- /mnt/docker-ssd/docker/appdata/romm/assets:/romm/assets
- /mnt/docker-ssd/docker/appdata/romm/config:/romm/config
- /mnt/docker-ssd/docker/appdata/romm/redis-data:/redis-data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
seerr:
container_name: seerr
image: ghcr.io/seerr-team/seerr:latest

View File

@@ -5,10 +5,13 @@ services:
ports:
- "8081:3001"
- "4404:4404"
- "1883:1883"
restart: unless-stopped
volumes:
- /mnt/tank/docker/appdata/meshmonitor/data:/data
- /mnt/docker-ssd/docker/compose/meshtastic/scripts:/data/scripts
devices:
- /dev/ttyACM0:/dev/ttyACM0
env_file: .env
environment:
- NODE_ENV=production
@@ -68,28 +71,6 @@ services:
networks:
- default
- pangolin
mqtt-proxy:
image: ghcr.io/ln4cy/mqtt-proxy:master
container_name: meshmonitor-mqtt-proxy
restart: unless-stopped
environment:
- INTERFACE_TYPE=tcp
- TCP_NODE_HOST=meshmonitor
- TCP_NODE_PORT=4404
- LOG_LEVEL=INFO
- TCP_TIMEOUT=300
- CONFIG_WAIT_TIMEOUT=60
- HEALTH_CHECK_ACTIVITY_TIMEOUT=300
depends_on:
- meshmonitor
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/healthy && find /tmp/healthy -mmin -1 | grep -q healthy"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- default
networks:
ix-databases_shared-databases:
external: true

View File

@@ -171,10 +171,16 @@ curl -X PUT http://10.5.30.6:6333/collections/knowledge_base \
(768 dimensions matches `nomic-embed-text`. If you use a different embedding model, adjust the size.)
### Pull nomic-embed-text into Ollama (required for workflows 02, 05, 08)
### Pull `nomic-embed-text` into the Ollama host backing these workflows (required for workflows 02, 05, 08)
For N.O.M.A.D.-local workflows / Honcho-style routing:
```bash
curl http://10.5.30.6:11434/api/pull -d '{"name": "nomic-embed-text"}'
curl http://127.0.0.1:11434/api/pull -d '{"name": "nomic-embed-text:v1.5"}'
```
For PD-hosted shared workflows, use the PD endpoint explicitly instead:
```bash
curl http://10.5.30.6:11434/api/pull -d '{"name": "nomic-embed-text:latest"}'
```
### Paperless Webhook (required for workflows 03, 05, 17, and 19)

View File

@@ -1,11 +1,13 @@
# PD Pi-hole / Unbound / Keepalived Stack
Primary-node stack for the Pi-hole HA plan.
Historical primary-node stack for the retired Pi-hole HA plan.
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole`
- **Live PD stack path:** `/mnt/docker-ssd/docker/compose/pihole`
- **Node role:** PD primary / MASTER
- **VIP target:** `10.5.30.53/24`
- **Former live PD stack path:** `/mnt/docker-ssd/docker/compose/pihole`
- **Former node role:** PD primary / MASTER
- **Former VIP target:** `10.5.30.53/24`
Status: retired on 2026-05-27 after cutover to the Technitium trio (`10.5.30.8`, `10.5.30.9`, `10.5.30.10`).
This stack intentionally covers the **PD primary** plus PD-hosted sync:
- Pi-hole
@@ -13,7 +15,7 @@ This stack intentionally covers the **PD primary** plus PD-hosted sync:
- Keepalived
- Nebula Sync
NOMAD replica deployment is now documented and live; only the RPi4 replica remains future work.
NOMAD replica deployment is documented here for historical reference; only the RPi4 replica remains optional future work if you ever revive a separate backup-DNS design.
## Files
@@ -39,7 +41,7 @@ If that validates cleanly, bring it up:
/usr/bin/bash bin/prepare_pd.sh --up
```
## Current live topology
## Retired topology snapshot
- **PD primary**
- service root: `/mnt/docker-ssd/docker/compose/pihole`
@@ -59,6 +61,7 @@ If that validates cleanly, bring it up:
## Important notes
- This stack is no longer live. Keep it only as a historical build/repair reference.
- `KEEPALIVED_PASSWORD` must be **8 chars or fewer** because VRRP PASS auth is annoying and old.
- Verify `LAN_INTERFACE` on PD before deployment (`ip link show`).
- `FTLCONF_dns_listeningMode=LOCAL` is set because TrueNAS binds loopback on port 53.

View File

@@ -1,17 +1,19 @@
# NOMAD Pi-hole / Unbound / staged Keepalived Replica
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole/nomad`
- **Suggested live stack path:** `/opt/pihole-nomad`
- **Node role:** NOMAD Pi-hole replica on Servers VLAN
- **Staged future VIP target:** `10.5.30.53/24`
- **Former live stack path:** `/opt/pihole-nomad`
- **Former node role:** NOMAD Pi-hole replica on Servers VLAN
- **Former staged VIP target:** `10.5.30.53/24`
- **LAN interface:** `enp5s0`
- **NOMAD LAN IP:** `10.5.30.7`
Current state:
Status: retired on 2026-05-27 after network-wide DNS cutover to the Technitium trio.
- `pihole-nomad` and `unbound-pihole-nomad` stay up normally on NOMAD.
- `keepalived-pihole-nomad` is intentionally disabled by default after NOMAD moved from Trusted to Servers VLAN.
- Enable HA later with `COMPOSE_PROFILES=ha` only after a same-subnet peer and VIP design are ready.
Retired state summary:
- `pihole-nomad`, `unbound-pihole-nomad`, and `keepalived-pihole-nomad` were retired on 2026-05-27.
- The old VIP `10.5.30.53` no longer answers.
- Treat the files here as historical/reference material unless you intentionally revive this design.
Bring-up:
@@ -37,12 +39,12 @@ sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
Pi-hole on NOMAD is pinned to `enp5s0` with `FTLCONF_dns_interface=enp5s0` and `FTLCONF_dns_listeningMode=SINGLE` so it answers cleanly on the LAN IP without fighting Docker bridge noise.
Current live containers:
Former live containers:
- `pihole-nomad`
- `unbound-pihole-nomad`
Staged but disabled-by-default HA container:
Former HA container:
- `keepalived-pihole-nomad`
Current LAN admin URL:
Former LAN admin URL:
- `http://10.5.30.7:8954/admin/`

View File

@@ -3,7 +3,8 @@
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole/rpi4`
- **Suggested live stack path:** `~/pihole/stack`
- **Node role:** BACKUP2
- **VIP target:** `10.5.30.53/24`
- **Historical VIP target:** `10.5.30.53/24`
This node still needs its actual LAN IP confirmed before live deployment.
This node still needs its actual LAN IP confirmed before any future deployment.
The original Pi-hole HA design using `10.5.30.53` is retired; keep this only as historical/future-idea reference unless you intentionally build a new backup-DNS path.
Native keepalived config example is in `keepalived.conf.example`.

View File

@@ -3,9 +3,11 @@
- **Repo stack path:** `/home/fizzlepoof/repos/truenas-stacks/pihole/serenity`
- **Suggested live stack path:** `/boot/config/plugins/compose.manager/projects/pihole-ha`
- **Node role:** Serenity backup / BACKUP1
- **VIP target:** `10.5.30.53/24`
- **Historical VIP target:** `10.5.30.53/24`
- **LAN interface:** `br0`
Status: historical reference only. Serenity's Pi-hole replica was previously cleaned up, and the overall Pi-hole HA path is now retired in favor of Technitium.
Bring-up:
```bash

44
search/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Search stack operator notes
Live stack path on PD:
- `/mnt/docker-ssd/docker/compose/search`
Live SearXNG config path on PD:
- `/mnt/tank/docker/appdata/searxng/settings.yml`
Known update-sensitive points:
1. Firecrawl containers share Gluetun's network namespace.
- `firecrawl-playwright-service` and `firecrawl-api` run with `network_mode: service:search-gluetun`.
- If `search-gluetun` is recreated during an update, those containers must be recreated too or they can die with namespace errors like:
- `joining network namespace of container ... No such container`
- `getaddrinfo EAI_AGAIN firecrawl-redis`
- The compose file now uses long-form `depends_on` with `restart: true` so explicit Compose updates to `search-gluetun` also restart the Firecrawl dependents.
2. SearXNG upstream can ship config drift.
- After an image update, compare `/mnt/tank/docker/appdata/searxng/settings.yml` against `/mnt/tank/docker/appdata/searxng/settings.yml.new`.
- Merge upstream changes deliberately instead of overwriting local behavior blindly.
- One confirmed breakage was an obsolete `ask` engine stanza lingering in the live settings after upstream removed that engine module; the fix was to remove the stale stanza and restart `searxng`.
Basic verification after updates:
- `sudo -n docker compose -f /mnt/docker-ssd/docker/compose/search/docker-compose.yaml ps`
- `curl -sS "http://127.0.0.1:8888/search?q=test&format=json" | head`
- `curl -sS http://127.0.0.1:3302/`
- optional functional scrape smoke test:
- POST `{"url":"https://example.com"}` to `http://127.0.0.1:3302/v1/scrape`
Firecrawl stability note on PD:
- The upstream `harness.js --start-docker` launcher fans out into API + queue worker + extract worker + NUQ workers.
- On PD, the default fan-out has caused first-start flaps (`Port 3002 did not become available within 60000ms`) and steady `WORKER STALLED` / `Can't accept connection due to RAM/CPU load` messages.
- Keep the PD compose defaults conservative unless the host is re-sized: `NUM_WORKERS_PER_QUEUE=1`, `CRAWL_CONCURRENT_REQUESTS=1`, `MAX_CONCURRENT_JOBS=1`, `BROWSER_POOL_SIZE=1`, `NUQ_WORKER_COUNT=1`, and `HARNESS_STARTUP_TIMEOUT_MS=180000`.
- After changing those values, force-recreate `firecrawl-api` and confirm `curl -sS http://127.0.0.1:3302/` answers on the first start without an immediate harness timeout.
Rollback-first notes:
- Back up `docker-compose.yaml` before live edits under `/home/truenas_admin/doris-backups/`
- Back up `settings.yml` before SearXNG config edits under `/home/truenas_admin/doris-backups/`
- If a Compose-side update breaks the stack and quick repair fails, restore the previous compose/config files and `docker compose up -d` the search stack again.
AI maintenance flow:
- Homelab Docker maintenance is seeded into the Hermes `homelab` kanban board on a daily cadence.
- The maintenance lane should treat this stack as update-sensitive and must run the verification commands above after any search-stack image change.
- If the stack cannot be repaired quickly after an update, roll back first, then leave the exact failure evidence in the kanban task so alerts route cleanly.

View File

@@ -78,8 +78,11 @@ services:
image: ghcr.io/firecrawl/playwright-service:latest
container_name: firecrawl-playwright-service
restart: unless-stopped
# Shares gluetun's network namespace; restart with gluetun updates so the namespace stays valid.
depends_on:
- search-gluetun
search-gluetun:
condition: service_started
restart: true
network_mode: "service:search-gluetun"
environment:
- PORT=3000
@@ -93,17 +96,20 @@ services:
image: ghcr.io/firecrawl/firecrawl:latest
container_name: firecrawl-api
restart: unless-stopped
# Shares gluetun's network namespace; compose should restart it when gluetun/playwright are recreated.
depends_on:
firecrawl-redis:
condition: service_started
firecrawl-playwright-service:
condition: service_started
restart: true
firecrawl-rabbitmq:
condition: service_healthy
firecrawl-postgres:
condition: service_started
search-gluetun:
condition: service_started
restart: true
network_mode: "service:search-gluetun"
environment:
- HOST=0.0.0.0
@@ -120,10 +126,11 @@ services:
- POSTGRES_HOST=firecrawl-postgres
- POSTGRES_PORT=5432
- USE_DB_AUTHENTICATION=${USE_DB_AUTHENTICATION:-false}
- NUM_WORKERS_PER_QUEUE=${NUM_WORKERS_PER_QUEUE:-4}
- CRAWL_CONCURRENT_REQUESTS=${CRAWL_CONCURRENT_REQUESTS:-4}
- MAX_CONCURRENT_JOBS=${MAX_CONCURRENT_JOBS:-2}
- BROWSER_POOL_SIZE=${BROWSER_POOL_SIZE:-2}
- NUM_WORKERS_PER_QUEUE=${NUM_WORKERS_PER_QUEUE:-1}
- CRAWL_CONCURRENT_REQUESTS=${CRAWL_CONCURRENT_REQUESTS:-1}
- MAX_CONCURRENT_JOBS=${MAX_CONCURRENT_JOBS:-1}
- BROWSER_POOL_SIZE=${BROWSER_POOL_SIZE:-1}
- NUQ_WORKER_COUNT=${NUQ_WORKER_COUNT:-1}
- BULL_AUTH_KEY=${BULL_AUTH_KEY}
- TEST_API_KEY=${TEST_API_KEY}
- LOGGING_LEVEL=${LOGGING_LEVEL:-info}
@@ -132,7 +139,7 @@ services:
- SEARXNG_CATEGORIES=${SEARXNG_CATEGORIES:-}
- ALLOW_LOCAL_WEBHOOKS=${ALLOW_LOCAL_WEBHOOKS:-false}
- BLOCK_MEDIA=${BLOCK_MEDIA:-true}
- HARNESS_STARTUP_TIMEOUT_MS=60000
- HARNESS_STARTUP_TIMEOUT_MS=${HARNESS_STARTUP_TIMEOUT_MS:-180000}
- NUQ_RABBITMQ_URL=amqp://firecrawl-rabbitmq:5672
command: node dist/src/harness.js --start-docker

View File

@@ -9,7 +9,7 @@ Non-disruptive Technitium DNS Server pilot for PlausibleDeniability.
- Web console: `http://10.5.30.8/`
- Friendly hostname inside Technitium: `http://dns.home.paccoco.com/`
This pilot intentionally does not touch the live Pi-hole service on `10.5.30.6` or the client VIP on `10.5.30.53`.
This stack is no longer just a side-by-side pilot. It is the active DNS source of truth, and the old Pi-hole service/VIP path has been retired.
Current mirrored baseline from Pi-hole:
- blocklists:
@@ -33,7 +33,7 @@ Notes:
- Authentik native OIDC callback for Technitium is `https://dns.paccoco.com/sso/callback`.
- For Authentik native OIDC, the Technitium OAuth2 provider must have the default OpenID scope mappings attached: `openid`, `email`, and `profile`. Without them, Authentik will mint a token with no OpenID scopes and Technitium fails the `/application/o/userinfo/` call with `Scope mismatch` / `SSO authentication failed`.
- The authoritative zone `home.paccoco.com` is now hosted in Technitium with `dns.home.paccoco.com -> 10.5.30.8`.
- Clients will resolve `dns.home.paccoco.com` only when they query Technitium directly, or after DHCP/cutover points them at Technitium.
- Clients now resolve `dns.home.paccoco.com` through the Technitium trio because the Pi-hole VIP path has been retired.
- `bin/sync_backup_nodes.sh` is the PD-side replication runner that pushes the live Technitium config to the Nomad (`10.5.30.9`) and Serenity (`10.5.30.10`) backup nodes, then recycles those backup stacks and verifies both a private authoritative answer and a public recursive answer.
- Active automation model: root cron on PD runs the sync every 15 minutes from `/mnt/docker-ssd/docker/compose/technitium-pilot`, logging to `/mnt/tank/docker/appdata/technitium-pilot/logs/sync-backup-nodes.log`.
- External fallback `9.9.9.9` keeps general internet DNS available if the Technitium trio is down, but it is not a substitute for the private `home.paccoco.com` zone.