Compare commits

...

52 Commits

Author SHA1 Message Date
Fizzlepoof
5c889f108e Add Doris Schoolhouse and clean pending homelab changes 2026-05-15 21:58:42 +00:00
Fizzlepoof
0e7dd21c66 Add vault-backed Karakeep API helper 2026-05-15 17:44:30 +00:00
Fizzlepoof
aace6e710d Fix Karakeep Chrome connectivity on PD 2026-05-15 17:11:34 +00:00
Fizzlepoof
1c5cce5535 Update Pi-hole docs for live HA rollout 2026-05-15 17:00:07 +00:00
Fizzlepoof
bd065445cd Harden NOMAD Pi-hole DNS binding 2026-05-15 16:09:03 +00:00
Fizzlepoof
1ad5035960 Define NOMAD live services under /opt 2026-05-15 15:49:15 +00:00
Fizzlepoof
e5de7c532d Align NOMAD Pi-hole replica with stack SOP 2026-05-15 15:43:17 +00:00
Fizzlepoof
6278191614 Move Pi-hole backup node from Serenity to NOMAD 2026-05-15 15:37:35 +00:00
Fizzlepoof
66c97ae6b5 Add Nebula Sync to PD Pi-hole stack 2026-05-15 15:11:50 +00:00
Fizzlepoof
ed1e0110f2 Disable Pi-hole NTP on PD 2026-05-15 15:00:37 +00:00
Fizzlepoof
cb8d6a030b Fix Pi-hole prepare script replace behavior 2026-05-15 14:37:54 +00:00
Fizzlepoof
5cc5b5f569 Scaffold PD Pi-hole primary stack 2026-05-15 03:17:56 +00:00
Fizzlepoof
4d78dd4524 Harden Paperless and school intake n8n workflows 2026-05-15 03:08:03 +00:00
Fizzlepoof
f7001d5130 Pin Paperless document stack component versions 2026-05-15 03:08:03 +00:00
Fizzlepoof
ff2e13a16f Align Kima Hub stack with live deployment 2026-05-15 03:08:03 +00:00
Fizzlepoof
5783963816 Standardize NOMAD host-run service paths 2026-05-15 03:08:03 +00:00
Fizzlepoof
837d6e5866 Add PD to Serenity backup automation scaffolding 2026-05-15 03:08:03 +00:00
Fizzlepoof
458989e6cd Add Doris KitchenOwl recipe importer 2026-05-15 03:03:58 +00:00
Fizzlepoof
79b546c70e feat(dashboard): rework paperless review actions 2026-05-14 19:43:57 +00:00
Fizzlepoof
1b61f7a669 fix(dashboard): make thumbs-down dismiss a single story 2026-05-14 19:36:47 +00:00
Fizzlepoof
88ea84b89a fix(dashboard): scope thumbs-up to a story and restore refreshes 2026-05-14 19:35:00 +00:00
Fizzlepoof
72724f8e2c docs(nomad): point to canonical doris dashboard path 2026-05-14 18:16:54 +00:00
Fizzlepoof
d7670fde4b feat(home): import doris dashboard from workspace repo
Migrates workspace-only dashboard work from commits 6f0c5cd, c851073, and d04eff3 into the canonical truenas-stacks repo.
2026-05-14 18:14:34 +00:00
Fizzlepoof
84b87baf2c docs(nomad): capture container ownership and recovery notes 2026-05-14 17:35:24 +00:00
Fizzlepoof
80842f95a7 docs(homelab): record pd static ip 2026-05-14 02:25:17 +00:00
Fizzlepoof
e567b97df8 refactor(n8n): consolidate school paperless enrichment 2026-05-14 02:18:40 +00:00
Fizzlepoof
bf5a138d1c docs(homelab): require secrets sync after env changes 2026-05-14 02:00:25 +00:00
Fizzlepoof
0a398eea9a fix(n8n): repair school intake upload flow 2026-05-14 01:56:07 +00:00
Fizzlepoof
a5c0c0b9ad feat(n8n): add school intake workflow v1.1 2026-05-13 21:32:18 +00:00
Fizzlepoof
f6482abcd6 docs(n8n): version school intake workflow artifacts 2026-05-13 21:09:43 +00:00
Fizzlepoof
3269cc1fc5 feat(n8n): add school paperless intake pipeline 2026-05-13 20:53:00 +00:00
Fizzlepoof
a04b2eecd7 fix(n8n): restore paperless webhook compatibility 2026-05-13 20:10:25 +00:00
Fizzlepoof
1b4838504b docs(gitea): document internal SSH remote
Record the working internal Gitea SSH remote as ssh://git@10.5.1.6:2222/<owner>/<repo>.git and replace stale HTTPS/gitea.paccoco.com examples for PD/NOMAD operations. Port 2222 is SSH, while the web UI remains on port 3002.
2026-05-13 17:15:39 +00:00
Fizzlepoof
cf5474369d docs(paperless): document Office ingestion support
Document Paperless Tika/Gotenberg sidecars, supported Office/OpenDocument ingestion formats, stack paths, validation/deploy procedure, and verification commands. Updates service inventory and architecture overview so PD docs match the deployed documents stack.
2026-05-13 17:06:52 +00:00
Fizzlepoof
5ed02928ec feat(paperless): add Tika and Gotenberg sidecars
Enable Paperless Office/OpenDocument ingestion by wiring internal Tika and Gotenberg services into the documents stack. Adds PAPERLESS_TIKA_* environment keys to .env.example and places both sidecars on an internal-only network with resource limits.
2026-05-13 17:06:47 +00:00
Fizzlepoof
7fe24d1956 feat: add paperless intake triage workflow 2026-05-13 03:38:31 +00:00
Fizzlepoof
30e01a9a09 docs: expand SECRETS_MANAGEMENT with full runbook and sync workflow 2026-05-12 08:30:12 -05:00
Fizzlepoof
e40fd48547 fix: sync-envs-to-secrets depth 3, fix ((copied++)) set -e bug 2026-05-12 08:22:40 -05:00
Fizzlepoof
faf92cfbf7 feat: add sync-envs-to-secrets.sh script 2026-05-12 08:20:00 -05:00
1a6a74a067 Merge branch 'main' of gitea.paccoco.com:fizzlepoof/truenas-stacks 2026-05-12 08:17:06 -05:00
Fizzlepoof
2e0bd6d49a feat: add git-crypt secrets repo setup and docs
- scripts/setup-secrets-repo.sh: replace dpkg-deb install with Docker method
- dev/docker-compose.yaml: add git-crypt-init service (profile: setup)
- docs/operations/SECRETS_MANAGEMENT.md: document git-crypt encrypted repo, unlock procedure, rebuild runbook, and TrueNAS install methods
2026-05-12 08:15:50 -05:00
Fizzlepoof
bc3722732a feat: add git-crypt secrets repo setup and docs
- scripts/setup-secrets-repo.sh: replace dpkg-deb install with Docker method
- dev/docker-compose.yaml: add git-crypt-init service (profile: setup)
- docs/operations/SECRETS_MANAGEMENT.md: document git-crypt encrypted repo,
  unlock procedure, rebuild runbook, and TrueNAS install methods
2026-05-12 08:06:31 -05:00
35077b1f7c Merge branch 'main' of github.com:Paccoco/truenas-stacks
# Conflicts:
#	rocinante/docker-compose.yaml
#	rocinante/watcher/watch.py
2026-05-10 09:57:48 -05:00
f3f1c346ac Local homelab changes 2026-05-10 09:56:29 -05:00
Paccoco
87dbd3e6ea Add OpenClaw to AI stack 2026-05-10 09:52:48 -05:00
Paccoco
96f1c69fd9 fix: chunk size limit + keep MP3s + Respond Accepted fix; add rocinante stack
- Chunk Text: word-level fallback for sentences > 600 chars
- watch.py: keep MP3 after transcription
- Respond Accepted: remove cross-node reference
- rocinante/: Whisper CUDA + watcher stack for RTX 4090 transcription
2026-05-09 21:10:23 -05:00
Paccoco
9aef43ee10 workflow 08: v1.1 fixes + docs update
- 08-class-recording-rag-v1.1.json: Whisper URL → 10.5.1.16:8786,
  model → faster-distil-whisper-small.en, auth headers → env vars,
  inputDataFieldName fix, Paperless response format text
- PLAUSIBLEDENABILITY.md: remove Whisper from GPU list (moved to N.O.M.A.D.)
- n8n-workflows/README.md: fix class recordings path to /mnt/tank,
  correct workflow 08 trigger (webhook, not folder watcher),
  add curl upload example and docker-compose setup instructions
2026-05-09 19:07:27 -05:00
Paccoco
11904f39b2 Workflow 16 NFS Watchdog v1.2 working; fix mount points, container names, sudo path 2026-05-09 16:04:44 -05:00
Paccoco
35019e08cc Test git webhook trigger 2026-05-09 15:24:56 -05:00
Paccoco
ca3de65e34 Add workflow 01 v1.4; update README env var docs and TODO 2026-05-09 15:21:11 -05:00
Paccoco
6df8f80d24 Workflow 01 v1.4 tested and working; update docs, TODO, and automation compose 2026-05-09 15:16:30 -05:00
Paccoco
8e8b54d176 Version all workflows at v1.0 2026-05-09 13:51:58 -05:00
172 changed files with 20702 additions and 217 deletions

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@ __pycache__/
# Temp scripts
update-homelab-docs.py
# Generated stack config
pihole/keepalived.conf
# Editor
.vscode/
.idea/

View File

@@ -7,7 +7,7 @@ Docker Compose stacks for PlausibleDeniability (TrueNAS Scale), plus documentati
| Server | Role | OS | IP |
|--------|------|----|----|
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.1.6 |
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
## Repo Layout
@@ -17,7 +17,7 @@ databases/ # shared-postgres, shared-mariadb, shared-redis — deploy FIRS
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
photos/ # immich server + machine learning
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
dev/ # gitea, rackpeek
ai/ # ollama, openwebui
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map

View File

@@ -8,3 +8,6 @@ OPENWEBUI_DB_PASS=changeme
# Scriberr — Hugging Face token for model downloads
HF_TOKEN=changeme
# OpenClaw — generate with: openssl rand -hex 32
OPENCLAW_GATEWAY_TOKEN=changeme

View File

@@ -105,26 +105,3 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
whisper:
container_name: whisper
image: fedirz/faster-whisper-server:latest-cuda
restart: unless-stopped
networks:
- ai-net
- ai-services
ports:
- "8786:8000"
environment:
TZ: ${TZ}
WHISPER__MODEL: ${WHISPER_MODEL}
WHISPER__DEVICE: cuda
WHISPER__COMPUTE_TYPE: float16
volumes:
- /mnt/docker-ssd/docker/appdata/whisper:/root/.cache/huggingface
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]

View File

@@ -30,7 +30,7 @@ model_list:
- model_name: "medium"
litellm_params:
model: "ollama/qwen2.5:14b"
api_base: "http://host.docker.internal:11434"
api_base: "http://ollama:11434"
timeout: 120
stream_timeout: 120
@@ -74,7 +74,7 @@ model_list:
- model_name: "ollama/qwen2.5:14b"
litellm_params:
model: "ollama/qwen2.5:14b"
api_base: "http://host.docker.internal:11434"
api_base: "http://ollama:11434"
- model_name: "ollama/deepseek-r1"
litellm_params:

View File

@@ -3,3 +3,35 @@ TZ=America/Chicago
N8N_HOST=n8n.paccoco.com
N8N_DB_PASS=CHANGE_ME
N8N_ENCRYPTION_KEY=CHANGE_ME
PAPERLESS_BASE_URL=https://paperless.paccoco.com
PAPERLESS_API_TOKEN=CHANGE_ME
PAPERLESS_CORRESPONDENT_SCHOOL=
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
PAPERLESS_TAG_SCHOOL=
PAPERLESS_TAG_ASSIGNMENTS=
PAPERLESS_TAG_TELEGRAM=
PAPERLESS_TAG_CLASS_MAP_JSON={}
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
NODE_FUNCTION_ALLOW_BUILTIN=crypto
# KitchenOwl recipe importer
KITCHENOWL_BASE_URL=https://owl.paccoco.com
KITCHENOWL_API_TOKEN=CHANGE_ME
KITCHENOWL_HOUSEHOLD_ID=1
# Backup automation scaffolding
SERENITY_BACKUP_HOST=root@10.5.1.5
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
POSTGRES_CONTAINER=shared-postgres
POSTGRES_DB_LIST="n8n paperless"
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
BACKUP_KNOWN_HOSTS=/home/truenas_admin/.ssh/known_hosts
DOCKER_BIN=/usr/bin/docker
SUDO_BIN=/usr/bin/sudo
USE_SUDO_FOR_DOCKER=true

104
automation/README.md Normal file
View File

@@ -0,0 +1,104 @@
# Automation helpers
This directory includes repo-side helpers for homelab operational tasks.
## KitchenOwl recipe import helper
- `bin/kitchenowl_recipe_import.py` — Doris-managed KitchenOwl recipe importer
- tries KitchenOwl's own scrape endpoint first
- falls back to direct page fetch + schema.org `Recipe` JSON-LD parsing
- normalizes ingredients/tags/timings into KitchenOwl's create-recipe payload
- supports dry-run by default and live create with `--create`
See `docs/operations/KITCHENOWL_RECIPE_IMPORT.md` for setup and usage.
## Karakeep API helper
- `bin/karakeep_api.py` — tiny Karakeep REST helper backed by the local encrypted vault entry
- reads `.secrets/karakeep_secrets.json.enc`
- uses `baseUrl` + `apiKey`
- targets `/api/v1/...` routes by default
- supports ad hoc GET/POST/PATCH/DELETE calls with query/body args
Examples:
```bash
automation/bin/karakeep_api.py bookmarks --query limit=5 --pretty
automation/bin/karakeep_api.py tags --pretty
automation/bin/karakeep_api.py /api/health --raw-path --pretty
```
## Backup scripts
- `bin/pd_backup_postgres.sh` — creates gzipped `pg_dump` exports for the configured Postgres DB list (quote space-separated DB names in `.env`, e.g. `POSTGRES_DB_LIST="n8n paperless"`)
- `bin/pd_backup_sync_to_serenity.sh``rsync`s configured backup/config roots to Serenity
- `bin/run_pd_backups.sh` — wrapper that runs both steps in order
These scripts are staged repo-side only. They still need:
1. real values in `.env`
2. a writable dump destination on PD
3. SSH trust / key path for Serenity (`root@10.5.1.5` by default)
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
4. a real scheduler (cron/systemd timer/n8n) on the live host
## PD / TrueNAS deployment recommendation
Use a plain cron job on PD, not n8n and not a custom systemd timer.
Reasons:
- survives n8n outages
- keeps the backup runner close to the compose/data host
- avoids extra appliance fights on TrueNAS SCALE
- matches the existing shell-first operational style on PD
Preferred mode on PD: **root cron**.
Why: the dump path under `/mnt/tank/...` and Docker access are typically cleaner from root on TrueNAS than from a limited operator account.
TrueNAS-specific notes:
- scripts use `/usr/bin/bash` explicitly
- default Docker path is `/usr/bin/docker`
- if run as root, scripts call Docker directly
- if run as a non-root user, default behavior is `sudo -n /usr/bin/docker ...`
- keep all live paths under `/mnt/...`; never rely on rootfs write locations
## Example live flow
```bash
cd /mnt/docker-ssd/docker/compose/automation
cp .env.example .env
chmod 600 .env
mkdir -p /mnt/tank/docker/backups/db-dumps
bin/run_pd_backups.sh
```
## Recommended PD cron block
Install in **root's crontab on PD**:
```cron
# BEGIN PD BACKUPS
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
# END PD BACKUPS
```
Recommended first-run checklist:
```bash
cd /mnt/docker-ssd/docker/compose/automation
cp .env.example .env # if not already present
chmod 600 .env
mkdir -p /mnt/tank/docker/backups/db-dumps
/usr/bin/bash bin/run_pd_backups.sh
tail -100 /mnt/tank/docker/backups/pd-backups.log
```
## Safety notes
- DB dumps are the priority restore artifacts; do not rely only on raw volume copies.
- The sync script uses `rsync --delete` inside the destination backup root, so point it at a dedicated backup path.
- Keep `.env` and SSH material out of git.
- If cron runs under a non-root PD account, `sudo -n /usr/bin/docker` must work or the DB dump step will fail.

139
automation/bin/karakeep_api.py Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Small Karakeep API helper that reads its token from the local encrypted vault.
Default behavior:
- reads `.secrets/karakeep_secrets.json.enc`
- uses `baseUrl` + `apiKey`
- targets `/api/v1/...` routes unless an absolute path is given
Examples:
automation/bin/karakeep_api.py bookmarks --pretty
automation/bin/karakeep_api.py tags --pretty
automation/bin/karakeep_api.py /api/health --raw-path --pretty
automation/bin/karakeep_api.py bookmarks --query limit=5 --pretty
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib import error, parse, request
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent.parent
WORKSPACE = Path('/home/fizzlepoof/.openclaw/workspace')
DEFAULT_SECRETS_DIR = WORKSPACE / '.secrets'
DEFAULT_SECRET_FILE = DEFAULT_SECRETS_DIR / 'karakeep_secrets.json.enc'
DEFAULT_KEY_FILE = DEFAULT_SECRETS_DIR / 'encryption.key'
DEFAULT_TIMEOUT = 30
def decrypt_json(path: Path, key_file: Path) -> dict[str, Any]:
raw = subprocess.check_output([
'openssl', 'enc', '-d', '-aes-256-cbc', '-pbkdf2',
'-in', str(path),
'-pass', f'file:{key_file}',
])
return json.loads(raw)
def parse_kv(items: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for item in items:
if '=' not in item:
raise SystemExit(f'Expected key=value, got: {item}')
key, value = item.split('=', 1)
out[key] = value
return out
def build_url(base_url: str, endpoint: str, raw_path: bool) -> str:
base = base_url.rstrip('/')
if raw_path:
if not endpoint.startswith('/'):
endpoint = '/' + endpoint
return base + endpoint
endpoint = endpoint.strip('/')
return f'{base}/api/v1/{endpoint}'
def api_request(method: str, url: str, api_key: str, *, query: dict[str, str] | None = None,
body: dict[str, Any] | list[Any] | None = None, timeout: int = DEFAULT_TIMEOUT) -> tuple[int, Any, dict[str, str]]:
if query:
url += ('&' if '?' in url else '?') + parse.urlencode(query, doseq=True)
data = None
headers = {
'Authorization': f'Bearer {api_key}',
'Accept': 'application/json',
'User-Agent': 'doris-karakeep-helper/1.0',
}
if body is not None:
data = json.dumps(body).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = request.Request(url, data=data, method=method.upper(), headers=headers)
try:
with request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode('utf-8', 'replace')
ctype = resp.headers.get('Content-Type', '')
payload = json.loads(raw) if 'json' in ctype else raw
return resp.status, payload, dict(resp.headers.items())
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, dict(exc.headers.items())
def main() -> int:
parser = argparse.ArgumentParser(description='Query Karakeep using the encrypted local vault secret.')
parser.add_argument('endpoint', help='Endpoint alias/path. Default base is /api/v1/. Example: bookmarks, tags, lists')
parser.add_argument('--method', default='GET', help='HTTP method (default: GET)')
parser.add_argument('--query', action='append', default=[], metavar='KEY=VALUE', help='Query params')
parser.add_argument('--data', action='append', default=[], metavar='KEY=VALUE', help='JSON body fields')
parser.add_argument('--json-body', help='Raw JSON body string')
parser.add_argument('--raw-path', action='store_true', help='Use endpoint as-is instead of prefixing /api/v1/')
parser.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
parser.add_argument('--headers', action='store_true', help='Print response headers to stderr')
parser.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT)
parser.add_argument('--secret-file', default=str(DEFAULT_SECRET_FILE))
parser.add_argument('--key-file', default=str(DEFAULT_KEY_FILE))
args = parser.parse_args()
secret = decrypt_json(Path(args.secret_file), Path(args.key_file))
base_url = secret['baseUrl']
api_key = secret['apiKey']
query = parse_kv(args.query)
body: dict[str, Any] | list[Any] | None = None
if args.json_body:
body = json.loads(args.json_body)
elif args.data:
body = parse_kv(args.data)
url = build_url(base_url, args.endpoint, args.raw_path)
status, payload, headers = api_request(args.method, url, api_key, query=query, body=body, timeout=args.timeout)
if args.headers:
for k, v in headers.items():
print(f'{k}: {v}', file=sys.stderr)
if isinstance(payload, (dict, list)):
if args.pretty:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print(json.dumps(payload))
else:
print(payload)
return 0 if 200 <= status < 300 else 1
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,475 @@
#!/usr/bin/env python3
"""Doris-managed KitchenOwl recipe importer.
Flow:
1. Try KitchenOwl's native scrape endpoint for supported sites.
2. If that fails, fetch the page and extract schema.org Recipe JSON-LD.
3. Normalize ingredients/tags into KitchenOwl's create-recipe payload.
4. Optionally create the recipe via the KitchenOwl API.
Secrets stay in env vars or a local .env, never in git.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import textwrap
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib import error, parse, request
DEFAULT_TIMEOUT = 30
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent.parent
DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env'
UNIT_WORDS = {
'teaspoon','teaspoons','tsp','tsp.','tablespoon','tablespoons','tbsp','tbsp.','cup','cups',
'oz','ounce','ounces','lb','lbs','pound','pounds','gram','grams','g','kg','ml','l','pinch',
'dash','clove','cloves','can','cans','package','packages','pkg','slice','slices','stalk','stalks'
}
STOPWORDS = {'fresh','large','small','medium','optional','to','taste','for','the','a','an'}
TAG_KEYS = ('recipeCategory', 'recipeCuisine', 'keywords')
class ScriptExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.in_script = False
self.script_type = None
self._buf: list[str] = []
self.scripts: list[tuple[str | None, str]] = []
self.title: str | None = None
self._in_title = False
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attrs_d = dict(attrs)
if tag == 'script':
self.in_script = True
self.script_type = attrs_d.get('type')
self._buf = []
elif tag == 'title':
self._in_title = True
def handle_endtag(self, tag: str) -> None:
if tag == 'script' and self.in_script:
self.scripts.append((self.script_type, ''.join(self._buf)))
self.in_script = False
self.script_type = None
self._buf = []
elif tag == 'title':
self._in_title = False
def handle_data(self, data: str) -> None:
if self.in_script:
self._buf.append(data)
elif self._in_title:
self.title = (self.title or '') + data
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding='utf-8').splitlines():
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def env(name: str, default: str | None = None) -> str | None:
return os.environ.get(name, default)
def api_request(method: str, url: str, token: str, *, params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None, timeout: int = DEFAULT_TIMEOUT) -> tuple[int, Any, dict[str, str]]:
if params:
url += ('&' if '?' in url else '?') + parse.urlencode(params, doseq=True)
data = None
headers = {
'Authorization': f'Bearer {token}',
'User-Agent': 'doris-kitchenowl-importer/1.0',
'Accept': 'application/json',
}
if body is not None:
data = json.dumps(body).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = request.Request(url, data=data, method=method.upper(), headers=headers)
try:
with request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode('utf-8', 'replace')
ctype = resp.headers.get('Content-Type', '')
payload = json.loads(raw) if 'json' in ctype else raw
return resp.status, payload, dict(resp.headers.items())
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, dict(exc.headers.items())
def fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT) -> str:
req = request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Doris KitchenOwl Importer)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
)
try:
with request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or 'utf-8'
return resp.read().decode(charset, 'replace')
except error.HTTPError as exc:
if exc.code not in {403, 406, 429}:
raise
curl = subprocess.run(
[
'curl', '-fsSL', '--max-time', str(timeout),
'-A', 'Mozilla/5.0 (Doris KitchenOwl Importer)',
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'-H', 'Accept-Language: en-US,en;q=0.9',
url,
],
check=False,
capture_output=True,
)
if curl.returncode != 0:
raise RuntimeError(f'Failed to fetch URL via urllib/curl: {url} ({curl.stderr.decode("utf-8", "replace").strip()})')
return curl.stdout.decode('utf-8', 'replace')
def flatten_graph(node: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if isinstance(node, list):
for item in node:
out.extend(flatten_graph(item))
elif isinstance(node, dict):
if '@graph' in node:
out.extend(flatten_graph(node['@graph']))
else:
out.append(node)
return out
def is_recipe_node(node: dict[str, Any]) -> bool:
t = node.get('@type')
if isinstance(t, list):
return 'Recipe' in t
return t == 'Recipe'
def choose_recipe_node(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
recipes = [n for n in nodes if is_recipe_node(n)]
if not recipes:
return None
recipes.sort(key=lambda r: len(r.get('recipeIngredient') or []), reverse=True)
return recipes[0]
def parse_json_ld_recipe(html: str) -> tuple[dict[str, Any] | None, str | None]:
parser = ScriptExtractor()
parser.feed(html)
nodes: list[dict[str, Any]] = []
for script_type, content in parser.scripts:
if script_type and 'ld+json' not in script_type:
continue
content = content.strip()
if not content:
continue
try:
data = json.loads(content)
except Exception:
continue
nodes.extend(flatten_graph(data))
return choose_recipe_node(nodes), (parser.title.strip() if parser.title else None)
DURATION_RE = re.compile(r'^P(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$')
def parse_duration_minutes(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
m = DURATION_RE.match(value)
if m:
hours = int(m.group('hours') or 0)
minutes = int(m.group('minutes') or 0)
seconds = int(m.group('seconds') or 0)
total = hours * 60 + minutes + (1 if seconds >= 30 else 0)
return total or None
num = re.search(r'(\d+)', value)
return int(num.group(1)) if num else None
def coerce_text(value: Any) -> str:
if value is None:
return ''
if isinstance(value, list):
return '\n'.join(coerce_text(v) for v in value if coerce_text(v))
if isinstance(value, dict):
if 'text' in value:
return coerce_text(value['text'])
if '@value' in value:
return coerce_text(value['@value'])
return json.dumps(value, ensure_ascii=False)
return unescape(str(value)).strip()
def slugify_tag(value: str) -> str:
value = value.strip().lower()
value = re.sub(r'[^a-z0-9]+', '-', value)
return value.strip('-')[:48]
def normalize_keywords(value: Any) -> list[str]:
out: list[str] = []
if isinstance(value, str):
parts = re.split(r'[,;|]', value)
out.extend(p.strip() for p in parts if p.strip())
elif isinstance(value, list):
for item in value:
out.extend(normalize_keywords(item))
return out
def split_ingredient(line: str) -> tuple[str, str]:
original = ' '.join(line.strip().split())
if not original:
return '', ''
cleaned = original.replace('', '-').replace('', '-')
tokens = cleaned.split()
desc_tokens: list[str] = []
name_tokens = tokens[:]
while name_tokens:
token = name_tokens[0].strip(',()').lower()
if re.fullmatch(r'[\d/.-]+', token) or token in {'x', '×'}:
desc_tokens.append(name_tokens.pop(0))
continue
if token in UNIT_WORDS:
desc_tokens.append(name_tokens.pop(0))
continue
if token in {'of'} and desc_tokens:
desc_tokens.append(name_tokens.pop(0))
continue
break
if not name_tokens:
return original[:128], ''
while name_tokens and name_tokens[0].strip(',').lower() in STOPWORDS and len(name_tokens) > 1:
desc_tokens.append(name_tokens.pop(0))
name = ' '.join(name_tokens).strip(' ,;')
if ',' in name:
first, rest = [p.strip() for p in name.split(',', 1)]
if first and len(first.split()) <= 5:
name = first
if rest:
desc_tokens.append(rest)
description = ' '.join(desc_tokens).strip(' ,;')
return name[:128], description[:512]
def normalize_recipe(url: str, recipe_node: dict[str, Any], title_fallback: str | None = None) -> dict[str, Any]:
name = coerce_text(recipe_node.get('name')) or (title_fallback or url)
description_parts: list[str] = []
desc = coerce_text(recipe_node.get('description'))
instructions = recipe_node.get('recipeInstructions')
instructions_text = coerce_text(instructions)
if desc:
description_parts.append(desc)
if instructions_text:
description_parts.append(instructions_text)
description = '\n\n'.join(part for part in description_parts if part).strip()
ingredients = recipe_node.get('recipeIngredient') or []
items = []
seen_items: set[tuple[str, str]] = set()
for raw in ingredients:
line = coerce_text(raw)
if not line:
continue
item_name, item_desc = split_ingredient(line)
if not item_name:
item_name = line[:128]
key = (item_name.lower(), item_desc.lower())
if key in seen_items:
continue
seen_items.add(key)
items.append({'name': item_name, 'description': item_desc, 'optional': False})
tags: list[str] = []
for key in TAG_KEYS:
tags.extend(normalize_keywords(recipe_node.get(key)))
deduped_tags: list[str] = []
seen_tags: set[str] = set()
for tag in tags:
slug = slugify_tag(tag)
if slug and slug not in seen_tags:
seen_tags.add(slug)
deduped_tags.append(slug)
yields_val = recipe_node.get('recipeYield')
yields_num = None
if isinstance(yields_val, list) and yields_val:
yields_val = yields_val[0]
if yields_val is not None:
match = re.search(r'(\d+)', str(yields_val))
if match:
yields_num = int(match.group(1))
payload = {
'name': name[:128],
'description': description[:20000],
'source': url,
'visibility': 0,
'items': items,
'tags': deduped_tags[:20],
}
cook_time = parse_duration_minutes(recipe_node.get('cookTime'))
prep_time = parse_duration_minutes(recipe_node.get('prepTime'))
total_time = parse_duration_minutes(recipe_node.get('totalTime'))
if cook_time is not None:
payload['cook_time'] = cook_time
if prep_time is not None:
payload['prep_time'] = prep_time
if total_time is not None:
payload['time'] = total_time
elif cook_time is not None or prep_time is not None:
payload['time'] = (cook_time or 0) + (prep_time or 0)
if yields_num is not None:
payload['yields'] = yields_num
return payload
def find_existing_recipe(base_url: str, token: str, household_id: int, payload: dict[str, Any]) -> dict[str, Any] | None:
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe', token)
if status != 200 or not isinstance(data, list):
return None
want_source = (payload.get('source') or '').strip().lower()
want_name = (payload.get('name') or '').strip().lower()
for recipe in data:
source = str(recipe.get('source') or '').strip().lower()
name = str(recipe.get('name') or '').strip().lower()
if want_source and source and source == want_source:
return recipe
if want_name and name == want_name:
return recipe
return None
def import_one(args: argparse.Namespace, base_url: str, token: str, household_id: int, url: str) -> dict[str, Any]:
result: dict[str, Any] = {'url': url}
payload = None
scrape_error = None
if not args.skip_kitchenowl_scrape:
status, data, _ = api_request('GET', f'{base_url}/api/household/{household_id}/recipe/scrape', token, params={'url': url}, timeout=args.timeout)
if status == 200 and isinstance(data, dict):
payload = data
result['strategy'] = 'kitchenowl-scrape'
else:
scrape_error = {'status': status, 'detail': data}
if payload is None:
html = fetch_url(url, timeout=args.timeout)
recipe_node, title = parse_json_ld_recipe(html)
if not recipe_node:
raise RuntimeError(f'No schema.org Recipe JSON-LD found for {url}')
payload = normalize_recipe(url, recipe_node, title)
result['strategy'] = 'json-ld-fallback'
if scrape_error:
result['scrape_error'] = scrape_error
payload['visibility'] = args.visibility
result['payload'] = payload
existing = None if args.allow_duplicates else find_existing_recipe(base_url, token, household_id, payload)
if existing:
result['existing'] = {'id': existing.get('id'), 'name': existing.get('name'), 'source': existing.get('source')}
result['status'] = 'duplicate'
return result
if args.create:
status, created, _ = api_request('POST', f'{base_url}/api/household/{household_id}/recipe', token, body=payload, timeout=args.timeout)
result['create_status'] = status
result['created'] = created
result['status'] = 'created' if status == 200 else 'error'
else:
result['status'] = 'dry-run'
return result
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description='Import recipe links into KitchenOwl with Doris-managed fallback parsing.')
p.add_argument('urls', nargs='+', help='Recipe URLs to normalize/import')
p.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Optional .env file to load before reading env vars')
p.add_argument('--base-url', default=None, help='KitchenOwl base URL, e.g. https://owl.paccoco.com')
p.add_argument('--token', default=None, help='KitchenOwl bearer token (prefer env var instead)')
p.add_argument('--household-id', type=int, default=None, help='KitchenOwl household id')
p.add_argument('--create', action='store_true', help='Create recipes in KitchenOwl. Default is dry-run only.')
p.add_argument('--allow-duplicates', action='store_true', help='Do not skip name/source duplicates.')
p.add_argument('--skip-kitchenowl-scrape', action='store_true', help='Go straight to Doris JSON-LD parsing.')
p.add_argument('--visibility', type=int, choices=[0,1,2], default=0, help='Recipe visibility: 0 private, 1 link, 2 public.')
p.add_argument('--pretty', action='store_true', help='Pretty-print JSON output')
p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='HTTP timeout in seconds')
return p.parse_args()
def main() -> int:
args = parse_args()
load_env_file(Path(args.env_file).expanduser())
base_url = (args.base_url or env('KITCHENOWL_BASE_URL') or env('KITCHENOWL_URL') or '').rstrip('/')
token = args.token or env('KITCHENOWL_API_TOKEN') or ''
household_raw = args.household_id or env('KITCHENOWL_HOUSEHOLD_ID')
if not base_url:
print('Missing KitchenOwl base URL. Set KITCHENOWL_BASE_URL or KITCHENOWL_URL.', file=sys.stderr)
return 2
if not token:
print('Missing KitchenOwl API token. Set KITCHENOWL_API_TOKEN.', file=sys.stderr)
return 2
if not household_raw:
print('Missing KitchenOwl household id. Set KITCHENOWL_HOUSEHOLD_ID.', file=sys.stderr)
return 2
household_id = int(household_raw)
results = []
failures = 0
for url in args.urls:
try:
results.append(import_one(args, base_url, token, household_id, url))
except Exception as exc:
failures += 1
results.append({'url': url, 'status': 'error', 'error': str(exc)})
dump = json.dumps(results, ensure_ascii=False, indent=2 if args.pretty else None)
print(dump)
return 1 if failures else 0
if __name__ == '__main__':
raise SystemExit(main())

73
automation/bin/pd_backup_lib.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AUTOMATION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${ENV_FILE:-$AUTOMATION_DIR/.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
echo "Copy automation/.env.example to automation/.env and fill in real values first." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
required_vars=(
SERENITY_BACKUP_HOST
SERENITY_BACKUP_ROOT
PD_APPDATA_ROOT
PD_DATABASES_ROOT
PD_TANK_DOCKER_ROOT
PD_DB_DUMP_ROOT
POSTGRES_CONTAINER
)
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "Required variable $var is empty in $ENV_FILE" >&2
exit 1
fi
done
mkdir -p "$PD_DB_DUMP_ROOT"
DOCKER_BIN="${DOCKER_BIN:-/usr/bin/docker}"
SUDO_BIN="${SUDO_BIN:-/usr/bin/sudo}"
USE_SUDO_FOR_DOCKER="${USE_SUDO_FOR_DOCKER:-true}"
BACKUP_KNOWN_HOSTS="${BACKUP_KNOWN_HOSTS:-${HOME:-/root}/.ssh/known_hosts}"
if [[ ! -x "$DOCKER_BIN" ]]; then
echo "Docker binary not executable: $DOCKER_BIN" >&2
exit 1
fi
docker_cmd() {
if [[ "${EUID:-$(id -u)}" == "0" ]]; then
"$DOCKER_BIN" "$@"
elif [[ "$USE_SUDO_FOR_DOCKER" == "true" ]]; then
"$SUDO_BIN" -n "$DOCKER_BIN" "$@"
else
"$DOCKER_BIN" "$@"
fi
}
timestamp_utc() {
date -u +"%Y%m%dT%H%M%SZ"
}
ssh_args() {
local opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$BACKUP_KNOWN_HOSTS"
if [[ -n "${BACKUP_SSH_KEY:-}" ]]; then
printf '%s' "-i $BACKUP_SSH_KEY $opts"
else
printf '%s' "$opts"
fi
}
ssh_cmd() {
printf 'ssh %s' "$(ssh_args)"
}

View File

@@ -0,0 +1,22 @@
#!/usr/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/pd_backup_lib.sh"
stamp="$(timestamp_utc)"
dbs="${POSTGRES_DB_LIST:-}"
if [[ -z "$dbs" ]]; then
echo "POSTGRES_DB_LIST is empty; nothing to dump." >&2
exit 1
fi
for db in $dbs; do
out="$PD_DB_DUMP_ROOT/${db}_${stamp}.sql.gz"
echo "Dumping Postgres database '$db' to $out"
docker_cmd exec "$POSTGRES_CONTAINER" pg_dump -U postgres "$db" | gzip -9 > "$out"
done
echo "Postgres dumps complete: $PD_DB_DUMP_ROOT"

View File

@@ -0,0 +1,51 @@
#!/usr/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/pd_backup_lib.sh"
ssh_opts="$(ssh_args)"
remote_root="$SERENITY_BACKUP_HOST:$SERENITY_BACKUP_ROOT"
# shellcheck disable=SC2086
ssh $ssh_opts "$SERENITY_BACKUP_HOST" mkdir -p \
"$SERENITY_BACKUP_ROOT/appdata" \
"$SERENITY_BACKUP_ROOT/databases" \
"$SERENITY_BACKUP_ROOT/tank-docker" \
"$SERENITY_BACKUP_ROOT/db-dumps"
rsync_cmd=(
rsync -a --delete
--exclude '*.sqlite-journal'
--exclude '*.db-journal'
--exclude '*.sqlite-wal'
--exclude '*.db-wal'
--exclude '*.tmp'
)
rsync_cmd+=( -e "$(ssh_cmd)" )
run_rsync() {
local label="$1"
shift
echo "$label"
set +e
"${rsync_cmd[@]}" "$@"
local rc=$?
set -e
if [[ $rc -eq 24 ]]; then
echo "rsync completed with vanished-file warning (code 24); treating as acceptable for live appdata."
return 0
fi
return $rc
}
run_rsync "Syncing appdata to Serenity" "$PD_APPDATA_ROOT/" "$remote_root/appdata/"
run_rsync "Syncing database volume roots to Serenity" "$PD_DATABASES_ROOT/" "$remote_root/databases/"
run_rsync "Syncing selected tank docker data to Serenity" "$PD_TANK_DOCKER_ROOT/" "$remote_root/tank-docker/"
run_rsync "Syncing DB dumps to Serenity" "$PD_DB_DUMP_ROOT/" "$remote_root/db-dumps/"
echo "Serenity sync complete: $remote_root"

View File

@@ -0,0 +1,9 @@
#!/usr/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
"$SCRIPT_DIR/pd_backup_postgres.sh"
"$SCRIPT_DIR/pd_backup_sync_to_serenity.sh"
echo "PD backup run complete."

View File

@@ -38,6 +38,20 @@ services:
N8N_EMAIL_MODE: smtp
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 336
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
LITELLM_API_KEY: ${LITELLM_API_KEY}
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
NODE_FUNCTION_ALLOW_BUILTIN: ${NODE_FUNCTION_ALLOW_BUILTIN}
volumes:
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
- /mnt/data/class-recordings:/data/class-recordings

0
databases/initdb-mariadb/01-create-uptime-kuma-db.sh Executable file → Normal file
View File

View File

@@ -39,6 +39,25 @@ services:
retries: 3
start_period: 30s
# Run once to install git-crypt on the TrueNAS host (apt is blocked there).
# Usage: docker compose --profile setup up git-crypt-init
git-crypt-init:
container_name: git-crypt-init
image: debian:bookworm-slim
profiles:
- setup
restart: "no"
volumes:
- /root/bin:/out
command: >
bash -c "
apt-get update -qq &&
apt-get install -y -qq git-crypt &&
cp /usr/bin/git-crypt /out/git-crypt &&
chmod +x /out/git-crypt &&
echo 'git-crypt installed to /root/bin/git-crypt'
"
rackpeek:
container_name: ix-rackpeek-rackpeek-1
image: aptacode/rackpeek:latest

View File

@@ -24,7 +24,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
- **Homepage** (3300) — dashboard / service index
- **Uptime Kuma** (3001) — monitoring and alerting
- **Gotify** (8443) — push notifications
- **Gitea** (3002) — self-hosted git
- **Gitea** (3002 web / 2222 SSH) — self-hosted git; internal SSH remote uses `ssh://git@10.5.1.6:2222/<owner>/<repo>.git`
- **Dockhand** (3230) — Docker management UI
### AI Layer
@@ -41,7 +41,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
### Productivity Layer
- **Immich** (2283) — photo management; library on Serenity NFS
- **Paperless-NGX** (8083) — document management
- **Paperless-NGX** (8083) — document management; uses internal Tika + Gotenberg sidecars for Office/OpenDocument text extraction and conversion
- **Karakeep** (3100) — bookmarks / read-later
- **n8n** (5678) — workflow automation
- **KitchenOwl** (8086), **DoneTick** (2021) — household and task management
@@ -61,4 +61,4 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
3. **External Docker networks**`ai-services` bridges all AI containers across compose files; `ix-databases_shared-databases` provides shared DB access
4. **NFS for Immich** — photo library lives on Serenity's large array; PD mounts on demand (not at boot)
5. **TrueNAS manages Docker** — never use `systemctl restart docker` on PD; use `docker compose` or `docker restart` for individual containers
6. **Credential hygiene** — Gitea remote uses HTTPS + credential store; GitHub remote uses SSH key (`/root/.ssh/id_gitea`)
6. **Credential hygiene** — Gitea pushes from PD/NOMAD use internal SSH (`ssh://git@10.5.1.6:2222/<owner>/<repo>.git`); GitHub remote uses SSH key (`/root/.ssh/id_gitea`)

View File

@@ -6,7 +6,7 @@
- **Other VLANs:** Cameras, Guest, IoT
- **Serenity IP:** 10.5.1.5
- **N.O.M.A.D. IP:** 10.5.1.16
- **PlausibleDeniability:** DHCP on LAN
- **PlausibleDeniability:** 10.5.1.6 (static on LAN)
## Remote Access
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.

View File

@@ -2,7 +2,7 @@
All homelab services, their hosts, ports, and current status.
*Last updated: 2026-05-09*
*Last updated: 2026-05-10*
## Shared Databases (PD)
@@ -20,7 +20,7 @@ All homelab services, their hosts, ports, and current status.
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
| Gotify | 8443 | https://pd:8443 | ✅ Active |
| Gitea | 3002 / 2222 | http://pd:3002 (SSH: 2222) | ✅ Active |
| Gitea | 3002 / 2222 | http://pd:3002 / http://10.5.1.6:3002 (SSH: `ssh://git@10.5.1.6:2222/<owner>/<repo>.git`) | ✅ Active |
| Shlink | 8087 | http://pd:8087 | ✅ Active |
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
@@ -32,8 +32,10 @@ All homelab services, their hosts, ports, and current status.
| LiteLLM | PD | 4000 | ✅ Active |
| OpenWebUI | PD | 8282 | ✅ Active |
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ Active |
| Whisper (faster-whisper) | PD | 8786 | ✅ Active |
| Whisper (faster-whisper, CPU) | N.O.M.A.D. (10.5.1.16) | 8786 | ✅ Active |
| Whisper (faster-whisper, CUDA large-v3) | Rocinante (10.5.1.112) | 8787 | ✅ Active |
| SearXNG | PD | 8888 | ✅ Active |
| OpenClaw | PD | 18789 | ✅ Active |
| Ollama — light tier | N.O.M.A.D. (10.5.1.16) | 11434 | ✅ Active |
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ✅ Active |
| Reranker (TEI) | Serenity (10.5.1.5) | 9787 | ✅ Active |
@@ -53,7 +55,7 @@ All homelab services, their hosts, ports, and current status.
| Service | Port | Status |
|---------|------|--------|
| Immich | 2283 | ✅ Active |
| Paperless-NGX | 8083 | ✅ Active |
| Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars |
| Karakeep | 3100 | ✅ Active |
| n8n | 5678 | ✅ Active |
| KitchenOwl | 8086 | ✅ Active |

View File

@@ -26,6 +26,16 @@ Back up at minimum:
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
Repo-side scaffolding now lives under `automation/`:
- `automation/bin/pd_backup_postgres.sh`
- `automation/bin/pd_backup_sync_to_serenity.sh`
- `automation/bin/run_pd_backups.sh`
These are staged helper scripts only until they are deployed with a real `.env`, SSH trust to Serenity, and a live scheduler on PD.
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
## .env File Backup Options
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)

View File

@@ -0,0 +1,137 @@
# KitchenOwl Recipe Import (Doris-managed)
## Why this exists
KitchenOwl's built-in import/scrape flow is unreliable for the way John and Leanne actually send recipe links around.
This repo-side helper gives Doris a safer path:
1. try KitchenOwl's native `/recipe/scrape` endpoint for sites it understands
2. if that fails, fetch the page directly
3. extract schema.org `Recipe` JSON-LD
4. normalize ingredients/tags/timings into KitchenOwl's create-recipe payload
5. optionally create the recipe through the KitchenOwl API
That means Doris can ingest recipe links from chat without depending on KitchenOwl's flaky native importer.
## Script
```bash
automation/bin/kitchenowl_recipe_import.py
```
Default mode is **dry-run**. It prints the normalized KitchenOwl payload and does **not** create anything unless `--create` is passed.
## Required env vars
Put these in a live `.env` file or export them ad hoc. Do **not** commit live secrets.
```env
KITCHENOWL_BASE_URL=https://owl.paccoco.com
KITCHENOWL_API_TOKEN=CHANGE_ME
KITCHENOWL_HOUSEHOLD_ID=1
```
Notes:
- `KITCHENOWL_API_TOKEN` should be a long-lived token for a user who can create recipes in the target household.
- `KITCHENOWL_HOUSEHOLD_ID` for the current live instance is `1` (`Stafford's`) unless that changes later.
## Usage
### Dry-run a link
```bash
cd /home/fizzlepoof/repos/truenas-stacks
export KITCHENOWL_BASE_URL=https://owl.paccoco.com
export KITCHENOWL_API_TOKEN=...redacted...
export KITCHENOWL_HOUSEHOLD_ID=1
automation/bin/kitchenowl_recipe_import.py --pretty \
"https://example.com/my-recipe"
```
### Actually create the recipe
```bash
automation/bin/kitchenowl_recipe_import.py --pretty --create \
"https://example.com/my-recipe"
```
### Import several links at once
```bash
automation/bin/kitchenowl_recipe_import.py --create --pretty \
"https://example.com/recipe-1" \
"https://example.com/recipe-2"
```
## Behavior details
### Strategy order
By default the helper tries:
1. `GET /api/household/<id>/recipe/scrape?url=...`
2. if KitchenOwl returns `Unsupported website` or otherwise fails, Doris falls back to direct HTML fetch + JSON-LD parse
If you want to skip the built-in scrape and go straight to Doris mode:
```bash
automation/bin/kitchenowl_recipe_import.py --skip-kitchenowl-scrape --pretty URL
```
### Duplicate protection
Before creating a recipe, the helper loads existing household recipes and skips creation if it finds:
- an exact matching `source` URL, or
- an exact matching recipe `name`
To force duplicates anyway:
```bash
automation/bin/kitchenowl_recipe_import.py --create --allow-duplicates URL
```
### Visibility
KitchenOwl visibility enum:
- `0` = private (default)
- `1` = link
- `2` = public
Example:
```bash
automation/bin/kitchenowl_recipe_import.py --create --visibility 0 URL
```
## What parses well
The fallback parser works best on recipe pages that expose proper schema.org `Recipe` JSON-LD, which many decent recipe sites do.
It pulls:
- name
- description
- instructions
- ingredient list
- total/prep/cook time
- yield/servings
- categories/cuisine/keywords → KitchenOwl tags
## Known limitations
- Some sites block scraping aggressively; Doris first tries normal fetch, then falls back to `curl` headers for better odds.
- Ingredient normalization is heuristic, not magic. It aims for a sane KitchenOwl item name plus a description/amount, but some weird ingredients may still need manual cleanup.
- Image upload/import is not wired yet in this helper.
- The helper expects structured JSON-LD. If a site hides the recipe entirely in client-side blobs or anti-bot nonsense, manual copy/paste may still be needed.
- `GET /api/settings` on the live KitchenOwl instance currently returns `500`, but that does not block recipe import.
## Doris workflow expectation
Preferred operator flow when John or Leanne sends recipe links:
1. Doris runs a dry-run first when the site is unfamiliar or suspicious.
2. If the normalized payload looks sane, Doris reruns with `--create`.
3. If the page is too messy, Doris asks for the raw ingredients/steps and imports it manually.
That keeps KitchenOwl as the recipe store while making Doris the reliable intake layer.

View File

@@ -0,0 +1,49 @@
# Paperless-NGX Operations
## Stack Location
- Compose path on PD: `/mnt/docker-ssd/docker/compose/documents/docker-compose.yaml`
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
- Consume source: `/mnt/unraid/data/paperless/consume`
- Consume container path: `/usr/src/paperless/consume`
## Office Document Ingestion
Paperless-NGX uses internal-only Tika and Gotenberg sidecars:
- Tika endpoint: `http://tika:9998`
- Gotenberg endpoint: `http://gotenberg:3000`
- Network: `documents_paperless_internal`
- Host ports: none
- Resource limits: Tika `512m`, Gotenberg `256m`
This enables ingestion/extraction for Office and OpenDocument files such as:
- `.docx`, `.doc`
- `.pptx`, `.xlsx`
- `.odt`, `.ods`, `.odp`
## Deploy / Restart Procedure
Validate before changing running containers:
```bash
cd /mnt/docker-ssd/docker/compose/documents
docker compose --env-file .env config
```
Deploy with compose only:
```bash
docker compose --env-file .env down && docker compose --env-file .env up -d
```
Never use `systemctl restart docker` on PD for this stack.
## Verification
```bash
docker compose --env-file .env ps
docker logs paperless --tail 200 | grep -Ei 'tika|gotenberg'
docker exec paperless sh -lc 'echo "$PAPERLESS_TIKA_ENABLED $PAPERLESS_TIKA_ENDPOINT $PAPERLESS_TIKA_GOTENBERG_ENDPOINT"; mount | grep /usr/src/paperless/consume'
```

View File

@@ -0,0 +1,85 @@
# PD Backup Deployment
Deploy the PD → Serenity backup runner from the real compose path on PD:
```bash
/mnt/docker-ssd/docker/compose/automation
```
## Why this model
- PD is the compose/data host, so backups should originate there.
- TrueNAS SCALE is happier with plain cron + shell than extra appliance-fighting service glue.
- Root cron on PD avoids the common `sudo`/Docker socket permission mess.
- Backups should still run even if n8n is down.
## Prerequisites
1. Repo content present on PD at `/mnt/docker-ssd/docker/compose/automation`
2. Writable dump directory:
```bash
mkdir -p /mnt/tank/docker/backups/db-dumps
```
3. SSH trust from PD to Serenity for the selected account
4. Real `.env` created from `.env.example`
5. Preferred: install the cron job in root's crontab on PD
6. If using a non-root PD account instead, `sudo -n /usr/bin/docker` must work
## Suggested `.env` values to review
```dotenv
SERENITY_BACKUP_HOST=root@10.5.1.5
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
POSTGRES_CONTAINER=shared-postgres
POSTGRES_DB_LIST="n8n paperless"
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
DOCKER_BIN=/usr/bin/docker
SUDO_BIN=/usr/bin/sudo
USE_SUDO_FOR_DOCKER=true
```
## Manual first run
```bash
cd /mnt/docker-ssd/docker/compose/automation
cp .env.example .env
chmod 600 .env
mkdir -p /mnt/tank/docker/backups/db-dumps
/usr/bin/bash bin/run_pd_backups.sh
```
Confirm:
- fresh `*.sql.gz` files appear in `/mnt/tank/docker/backups/db-dumps`
- appdata/database/tank-docker content lands under the chosen Serenity backup root
- no sudo prompt appeared during `docker exec`
## Cron install
Preferred: install in **root's crontab** on PD.
```cron
# BEGIN PD BACKUPS
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
# END PD BACKUPS
```
## Post-deploy checks
```bash
tail -100 /mnt/tank/docker/backups/pd-backups.log
ls -lh /mnt/tank/docker/backups/db-dumps
ssh root@10.5.1.5 'find /mnt/user/backups/plausible-deniability -maxdepth 2 -type d | sort | head -40'
```
## Important limitations
- Do **not** use `systemctl restart docker` on PD.
- Keep source/destination paths under `/mnt/...`.
- `rsync --delete` is intentional; use a dedicated destination root, not a shared miscellaneous folder.
- This scaffolding does not yet back up `.env` files here because those are already handled by the separate encrypted/private-repo process.
- If you insist on a non-root cron user on PD, you will need passwordless Docker access (`sudo -n /usr/bin/docker ...`) and write access to the chosen dump directory.

View File

@@ -0,0 +1,130 @@
# School Paperless Intake Workflow
Current workflow versions:
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
This workflow set is meant for a Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX while recording intake metadata in shared Postgres.
## Versioning rule
When these workflow artifacts change, the version must change too:
- the filename keeps a `-vX.Y` suffix
- the internal n8n workflow `name` also carries the same version
- docs and testing notes should reference the current version explicitly
## What it does
### Workflow 18 — v1.2
1. Accepts a multipart upload at `POST /webhook/school/intake/upload`
2. Requires form fields:
- `class_name`
- `assignment_name`
- `submission_kind`
3. Accepts optional fields:
- `semester`
- `course_code`
- `paper_kind`
- `notes`
- `source` (defaults to `telegram`)
- `telegram_chat_id`
- `telegram_message_id`
4. Generates a deterministic `intake_id` from date + class + assignment + file checksum
5. Stores/updates the intake row in Postgres first
6. Uploads the file to Paperless using the deterministic intake ID in the filename
7. Marks the row as `uploaded` after Paperless accepts the upload
## Shared Postgres expectations
- Use the shared Postgres network/credentials pattern already used by the automation stack.
- Earlier drafts used shared Postgres tracking, but the current live-tested v1.2/v1.1 path works without Postgres dependency in the webhook chain.
- Deterministic filename/title are now the source of correlation between upload and enrichment.
- Tracked schema: `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` (kept for future DB-backed intake tracking if reinstated)
## Required environment variables
Add to `automation/.env`:
```env
PAPERLESS_BASE_URL=https://paperless.paccoco.com
PAPERLESS_API_TOKEN=CHANGE_ME
LITELLM_API_KEY=CHANGE_ME
PAPERLESS_CORRESPONDENT_SCHOOL=
PAPERLESS_DOCUMENT_TYPE_SCHOOL=
PAPERLESS_TAG_SCHOOL=
PAPERLESS_TAG_ASSIGNMENTS=
PAPERLESS_TAG_TELEGRAM=
PAPERLESS_TAG_CLASS_MAP_JSON={}
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON={}
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON={}
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON={}
NODE_FUNCTION_ALLOW_BUILTIN=crypto
```
Add to the `n8n` service `environment:` block in `automation/docker-compose.yaml`:
```yaml
PAPERLESS_BASE_URL: ${PAPERLESS_BASE_URL}
PAPERLESS_API_TOKEN: ${PAPERLESS_API_TOKEN}
PAPERLESS_CORRESPONDENT_SCHOOL: ${PAPERLESS_CORRESPONDENT_SCHOOL}
PAPERLESS_DOCUMENT_TYPE_SCHOOL: ${PAPERLESS_DOCUMENT_TYPE_SCHOOL}
PAPERLESS_TAG_SCHOOL: ${PAPERLESS_TAG_SCHOOL}
PAPERLESS_TAG_ASSIGNMENTS: ${PAPERLESS_TAG_ASSIGNMENTS}
PAPERLESS_TAG_TELEGRAM: ${PAPERLESS_TAG_TELEGRAM}
PAPERLESS_TAG_CLASS_MAP_JSON: ${PAPERLESS_TAG_CLASS_MAP_JSON}
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
NODE_FUNCTION_ALLOW_BUILTIN: crypto
```
Notes:
- `PAPERLESS_CORRESPONDENT_SCHOOL` and `PAPERLESS_DOCUMENT_TYPE_SCHOOL` should be numeric IDs if you want those fields auto-assigned.
- `PAPERLESS_TAG_CLASS_MAP_JSON`, `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON`, `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON`, and `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` are optional JSON maps used by workflow 19.
- Leave optional Paperless IDs and JSON maps blank/default if you prefer to start with deterministic title + generic tags only.
- Workflow 18 v1.1 no longer requires `crypto`, so `NODE_FUNCTION_ALLOW_BUILTIN=crypto` is optional instead of required.
- Workflow 18 v1.2 fixes the Paperless multipart upload by using `inputDataFieldName` for the binary form field.
- `N8N_BLOCK_ENV_ACCESS_IN_NODE` must remain `false`.
## Paperless metadata behavior
The upload uses:
- filename: `<intake_id>.<ext>`
- title: `<class_name> — <assignment_name> — <submission_kind>`
Workflow 18 deliberately keeps the Paperless upload minimal and safe:
- multipart `document`
- deterministic title
The deterministic filename/title preserve the `intake_id` for later correlation.
### Workflow 19 — v1.1
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
- fetch Paperless document by webhook document id
- resolve `intake_id` from the stored filename
- look up the intake record in shared Postgres
- re-apply deterministic title
- merge generic school tags plus optional class/submission-kind mappings
- optionally set document type / correspondent from JSON env mappings
## Testing
Use the example curl request in:
- `n8n-workflows/fixtures/school-paperless-intake-webhook.curl.example.txt`
Minimum test checklist:
1. Import the workflow into n8n.
2. Attach the `Shared Postgres` credential.
3. Send the sample multipart request.
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
6. Re-submit the same file and metadata to confirm the same deterministic `intake_id` pattern is reused.
## Known assumptions / caveats
- The workflow is repo-only and was not live-tested against the running n8n/Paperless stack.
- The Postgres node uses SQL expressions inline rather than parameter binding because exported n8n node JSON can differ by version.
- Workflow 18 v1.0 needed `require('crypto')`; workflow 18 v1.1 removed that dependency for easier live rollout.
- If Paperless needs tags as repeated `tags[]` fields instead of a comma-separated `tags` field, adjust the HTTP Request node after import.

View File

@@ -1,11 +1,25 @@
# Secrets Management
## Overview
Secrets are managed in two layers:
1. **Live secrets**`.env` files on PD at `/mnt/docker-ssd/docker/compose/<stack>/.env`, gitignored and never committed to the main repo
2. **Encrypted backup** — all `.env` files are synced to a private git-crypt encrypted Gitea repo at `/mnt/docker-ssd/docker/secrets/`
The sync is a one-command operation and is safe to re-run at any time.
---
## Principles
- `.env` files are gitignored and never committed to any public repo
- `.env.example` files are committed with placeholder values
- Real `.env` files live only on the host at the stack directory
- `.env` files are gitignored in the main repo — never committed there
- `.env.example` files with placeholder values are committed instead
- The secrets repo encrypts everything at rest using git-crypt (symmetric key)
- Secrets are generated with standard tools, never reused across services
---
## Generating Secrets
```bash
@@ -19,46 +33,182 @@ openssl rand -hex 32
openssl rand -base64 32
```
## .env File Locations
---
## Live .env File Locations
All `.env` files live alongside their `docker-compose.yaml`:
```
/mnt/docker-ssd/docker/compose/<stack>/.env
/mnt/docker-ssd/docker/compose/
ai/.env
automation/.env
databases/.env
dev/.env
documents/.env
home/.env
infrastructure/.env
media/.env
media/kima-hub/.env
mesh-mqtt-observer/.env
meshtastic/.env
monitoring/.env
photos/.env
```
## Backing Up .env Files
---
## Secrets Repo (git-crypt)
| | |
|---|---|
| **Gitea repo** | `https://gitea.paccoco.com/fizzlepoof/homelab-secrets` (private) |
| **SSH remote** | `ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git` |
| **Local path on PD** | `/mnt/docker-ssd/docker/secrets/` |
| **Symmetric key location** | `/mnt/docker-ssd/docker/secrets/.git-crypt-secrets.key` (if copied there) or retrieve from password manager |
| **Key backup** | Password manager + `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` |
### Repo structure
| Directory | Contents |
|-----------|----------|
| `env/` | `.env` files for each stack, named `<stack>.env` |
| `keys/` | API keys and tokens |
| `certs/` | TLS certificates |
| `tokens/` | Service tokens (Paperless, LiteLLM, etc.) |
| `ssh/` | SSH private keys |
All files except `README.md` and `.gitattributes` are encrypted by git-crypt on commit.
---
## Syncing .env Files to the Secrets Repo
Use the sync script — no root required, safe to re-run:
### Short term: Private Gitea repo
Create a private repo on Gitea (not GitHub) and push all .env files there:
```bash
# In a separate directory, not the main stacks repo
git init homelab-secrets
cd homelab-secrets
# Copy and add .env files
git remote add origin https://gitea.paccoco.com/fizzlepoof/homelab-secrets.git
git push -u origin main
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
```
### Long term: Restic + offsite
Include the compose directory (with .env files) in a restic backup:
What it does:
- Scans all stack directories under `/mnt/docker-ssd/docker/compose/` for `.env` files
- Copies changed/new files to `/mnt/docker-ssd/docker/secrets/env/` as `<stack>.env`
- Skips unchanged files
- Commits and pushes to Gitea automatically
Run this any time you create or update a stack's `.env` file.
**Operational rule:** after any live `.env` change on PD, Doris or the operator must run the sync script and confirm whether it committed/pushed changes or reported the secrets repo already up to date. Do not treat live `.env` edits as complete until this sync step is checked.
---
## git-crypt on TrueNAS
apt is blocked on TrueNAS Scale. git-crypt is installed via Docker.
### Preferred installation location
```
/mnt/docker-ssd/bin/git-crypt
```
### Compatibility note
The original bootstrap script installs to `/root/bin/git-crypt`, while the newer runbook expects `/mnt/docker-ssd/bin/git-crypt`. Standardize on `/mnt/docker-ssd/bin/git-crypt` and make sure PATH includes that directory for both `root` and `truenas_admin`.
Recommended PATH entry:
```bash
restic -r <repo> backup /mnt/docker-ssd/docker/compose --exclude="*.example"
export PATH="/mnt/docker-ssd/bin:$PATH"
```
### Best long term: Vaultwarden
Once deployed, store each service's .env as a secure note in Vaultwarden. Single source of truth, accessible from anywhere.
### Re-installing after a full rebuild
**Option 1 — one-liner:**
```bash
sudo -i
docker run --rm -v /tmp:/out debian:bookworm-slim \
bash -c "apt-get update -qq && apt-get install -y -qq git-crypt && cp /usr/bin/git-crypt /out/git-crypt"
mkdir -p /mnt/docker-ssd/bin
cp /tmp/git-crypt /mnt/docker-ssd/bin/git-crypt
chmod +x /mnt/docker-ssd/bin/git-crypt
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /root/.bashrc
echo 'export PATH="/mnt/docker-ssd/bin:$PATH"' >> /home/truenas_admin/.bashrc
```
**Option 2 — via dev stack compose:**
```bash
cd /mnt/docker-ssd/docker/compose/dev
sudo docker compose --profile setup up git-crypt-init
# copies git-crypt to /root/bin — then move to persistent location:
sudo cp /root/bin/git-crypt /mnt/docker-ssd/bin/git-crypt
```
---
## Unlocking the Secrets Repo on a New Machine
```bash
# Install git-crypt first (see above), then:
git clone ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
cd /mnt/docker-ssd/docker/secrets
git-crypt unlock /path/to/.git-crypt-secrets.key
```
After unlock, the `env/` directory contains plaintext `.env` files ready to copy back into the stack directories.
---
## Full Rebuild Runbook
On a fresh PD after reinstalling TrueNAS:
1. **Install git-crypt** (Option 1 above — docker is available immediately after TrueNAS install)
2. **Restore secrets repo:**
```bash
git clone ssh://git@10.5.1.6:2222/fizzlepoof/homelab-secrets.git /mnt/docker-ssd/docker/secrets
cd /mnt/docker-ssd/docker/secrets
git-crypt unlock /path/to/.git-crypt-secrets.key
```
3. **Restore .env files** from `secrets/env/` back to their stack directories:
```bash
cp /mnt/docker-ssd/docker/secrets/env/ai.env /mnt/docker-ssd/docker/compose/ai/.env
cp /mnt/docker-ssd/docker/secrets/env/databases.env /mnt/docker-ssd/docker/compose/databases/.env
# ... etc for each stack
```
4. **Clone the main stacks repo:**
```bash
git clone ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git /mnt/docker-ssd/docker/compose
```
5. **Redeploy stacks** per `DEPLOYMENT_CHECKLIST.md`
6. **After any future live `.env` edit**, immediately run:
```bash
export PATH="/mnt/docker-ssd/bin:$PATH"
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
```
Then verify the secrets repo is either updated and pushed or already unchanged.
---
## Known Credentials Locations
| Service | Where stored |
|---------|-------------|
| Service | File |
|---------|------|
| shared-postgres | `databases/.env` |
| shared-mariadb | `databases/.env` |
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
| Donetick | `home/.env` + `selfhosted.yaml` |
| Meshmonitor | `meshtastic/.env` |
| Gitea | `dev/.env` |
| LiteLLM | `ai/.env` |
| Paperless | `documents/.env` |
---
## Security Reminders
- **Key backup**: `C:\Users\Fizzlepoof\Downloads\.git-crypt-secrets.key` — also store in password manager
- Regenerate Wings token on N.O.M.A.D. (was exposed in chat — TODO)
- Regenerate N.O.M.A.D. Newt secret (was exposed in chat — TODO)
- Disable signups in OpenWebUI after creating initial account
- The Gitea token in `setup-secrets-repo.sh` should be rotated after initial setup

View File

@@ -1,11 +1,16 @@
# Homelab Stack Standards
## Repository Root
`/mnt/docker-ssd/docker/compose`
PD live compose root: `/mnt/docker-ssd/docker/compose`
NOMAD exception: standalone live deployments belong under `/opt/<service>` rather than a shared `/mnt/docker-ssd/docker/compose` tree.
Remotes:
- GitHub: `git@github.com:Paccoco/truenas-stacks.git`
- Gitea: `https://gitea.paccoco.com/fizzlepoof/truenas-stacks.git`
- Gitea web: `http://10.5.1.6:3002/fizzlepoof/truenas-stacks`
- Gitea SSH: `ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git`
Use the internal Gitea SSH remote from PD/NOMAD. Port `2222` is SSH, not HTTP.
## Stack Layout
Each production stack lives in a subdirectory of the repo root.
@@ -16,6 +21,24 @@ Each stack contains:
- `.env.example` — committed with placeholder values
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
## NOMAD Live Services
On NOMAD, live service roots belong under `/opt/<service>`.
This includes:
- host-run services
- standalone Docker Compose stacks
- small service-local runtimes that should stay self-contained
Examples:
- `/opt/project-nomad`
- `/opt/newt`
- `/opt/doris-dashboard`
- `/opt/pihole-nomad`
Default rule on NOMAD: keep runtime data with the service under `/opt/<service>/data` unless a service has a documented reason to use another storage tier.
Use the repo for source/staging, but do not run production services from an agent workspace or random checkout under `/home/fizzlepoof/.openclaw/workspace`.
## Deployment Order
1. `databases/` — must be up before anything else
2. `infrastructure/` — newt creates the pangolin network
@@ -25,8 +48,13 @@ Each stack contains:
Validate before deploying:
```bash
# PD-style shared compose root
cd /mnt/docker-ssd/docker/compose/<stack>
docker compose --env-file .env config
# NOMAD-style service root
cd /opt/<service>
docker compose --env-file .env config
```
Deploy:
@@ -116,7 +144,7 @@ After changing a healthcheck, must `down` then `up` (not just restart).
cd /mnt/docker-ssd/docker/compose
git add .
git commit -m "description"
git push # pushes to both GitHub and Gitea
git push ssh://git@10.5.1.6:2222/fizzlepoof/truenas-stacks.git main
```
All `.env` files are gitignored. Only compose files, `.env.example`, and init scripts are committed.

View File

@@ -1,91 +1,125 @@
# Kima-Hub Deployment Plan
# Kima-Hub Deployment / Repair Reference
**Host:** PlausibleDeniability (PD)
**Port:** 3333
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
**Status:** Pending
**Stack dir in repo:** `media/kima-hub/`
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
**Status:** Repo stack corrected to match upstream all-in-one Kima layout and live service verified healthy on PD
## What It Is
## Current canonical stack
Kima-Hub is a self-hosted music streaming platform — think personal Spotify. Point it at your music library and it handles cataloging, artist discovery, AI-powered playlists, podcast subscriptions, and seamless integration with Lidarr and Audiobookshelf.
The upstream all-in-one Kima image expects this shape:
## Pre-Deploy Checklist
- **Image:** `ghcr.io/chevron7locked/kima:latest`
- **Container name:** `kima`
- **Host port:** `3333`
- **Container port:** `3030`
- **Persistent data:**
- `/mnt/docker-ssd/docker/appdata/kima-hub/data:/data`
- **Media mount:**
- `/mnt/unraid/data/media/music:/music`
- **Network:** `pangolin`
- **Host gateway alias:** `host.docker.internal:host-gateway`
- [ ] Confirm music library path on Unraid NFS (`/mnt/unraid/data/media/music` or similar)
- [ ] Create appdata directory: `sudo mkdir -p /mnt/tank/docker/appdata/kima-hub`
- [ ] Create stack directory: `sudo mkdir -p /mnt/docker-ssd/docker/compose/media-extra`
## Canonical live `.env` shape
## docker-compose.yaml
```env
TZ=America/New_York
SESSION_SECRET=
SETTINGS_ENCRYPTION_KEY=
KIMA_CALLBACK_URL=http://host.docker.internal:3333
DISABLE_CLAP=
LIDARR_API_KEY=
FANART_API_KEY=
LASTFM_API_KEY=
OPENAI_API_KEY=
SOULSEEK_USERNAME=
SOULSEEK_PASSWORD=
```
Notes:
- `SESSION_SECRET` and `SETTINGS_ENCRYPTION_KEY` can be left blank; current upstream all-in-one image will generate and persist them under `/data/secrets` on first start.
- `DISABLE_CLAP=true` is the low-memory / no-AI-vibe fallback if the analyzer proves too heavy.
- `KIMA_CALLBACK_URL` should point at the host-facing Kima URL Lidarr can reach.
## Canonical compose file
```yaml
services:
kima-hub:
image: chevron7locked/kima:latest
container_name: kima-hub
restart: unless-stopped
ports:
- "3333:3030"
volumes:
- /mnt/tank/docker/appdata/kima-hub:/data
- /mnt/unraid/data/media/music:/music:ro
kima:
image: ghcr.io/chevron7locked/kima:latest
container_name: kima
environment:
- TZ=${TZ}
healthcheck:
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
- SESSION_SECRET=${SESSION_SECRET:-}
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
- DISABLE_CLAP=${DISABLE_CLAP:-}
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
- FANART_API_KEY=${FANART_API_KEY:-}
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
volumes:
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
- /mnt/unraid/data/media/music:/music
ports:
- "3333:3030"
networks:
- pangolin
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
networks:
pangolin:
external: true
```
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
> **Music path:** Adjust `/mnt/unraid/data/media/music` to match your actual Unraid music library path.
> **Read-only mount:** Music library is mounted `:ro` — Kima-Hub should not need write access to source files.
## .env.example
```env
TZ=America/New_York
```
## Deploy
## Validate before deploy
```bash
cd /mnt/docker-ssd/docker/compose/media-extra
sudo docker compose --env-file .env config # validate
sudo docker compose --env-file .env up -d
sudo docker logs kima-hub --tail 30
cd /mnt/docker-ssd/docker/compose/media/kima-hub
sudo docker compose --env-file .env config
```
## Post-Deploy
## Deploy / repair
1. Open `http://10.5.1.6:3333` and create your account
2. Point Kima at your music directory (`/music`)
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
```bash
cd /mnt/docker-ssd/docker/compose/media/kima-hub
sudo docker compose --env-file .env up -d
sudo docker compose --env-file .env ps
sudo docker logs --tail 60 kima
```
## Pangolin Resource
## Post-deploy checks
Add in Pangolin dashboard:
- **Subdomain:** `kima.paccoco.com`
- **Target:** `http://10.5.1.6:3333`
- **Auth:** Enable (personal use only)
1. Open `http://10.5.1.6:3333`
2. Confirm the app starts and initial setup loads
3. Confirm `/music` is visible to Kima
4. Confirm Kima creates and uses `/data` persistence correctly
5. Add Pangolin resource for `kima.paccoco.com` if desired
6. Confirm the NFS-backed Serenity media mount at `/mnt/unraid/data/media` is present on PD and contains the music library Kima should scan
## Storage Decision
Verified on 2026-05-15:
- container `kima` running and healthy
- `http://10.5.1.6:3333/api/health` returned status OK
- live mount narrowed to `/mnt/unraid/data/media/music:/music`
| Path | Tier | Reason |
|------|------|--------|
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
## Optional integrations / secrets
Not required just to boot:
- **Lidarr API key** — for Lidarr integration
- **Fanart API key** — for artist images
- **Last.fm API key** — for enrichment/recommendations
- **OpenAI API key** — optional AI features documented upstream; current upstream docs only explicitly mention OpenAI for that part
- **Soulseek username/password** — if Soulseek features are used
Do **not** commit live secrets into the repo copy of `.env`.
## Notes
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
- Kima-Hub has no GPU acceleration — pure CPU transcoding
- The previous repo/live shape using `/app/config`, `/app/data`, `PUID`, `PGID`, `KIMA_MUSIC_PATH`, and `3333:3333` did not match current upstream all-in-one docs.
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.

View File

@@ -1,6 +1,6 @@
# Pi-hole Deployment Plan
**Status:** Pending
**Status:** PD primary and NOMAD backup are live and working; DNS clients are pointed at VIP `10.5.1.53`. RPi4 remains optional future BACKUP2 work.
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
---
@@ -16,8 +16,8 @@
│ 10.5.1.53/24 │
└──────┬──────────────┬──────────────────┘
│ │ │
PD (MASTER) Serenity (BACKUP1) RPi4 (BACKUP2)
10.5.1.6 10.5.1.5 10.5.1.X
PD (MASTER) N.O.M.A.D. (BACKUP1) RPi4 (BACKUP2)
10.5.1.6 10.5.1.16 10.5.1.X
priority 100 priority 90 priority 80
Pi-hole :53 Pi-hole :53 Pi-hole :53
Unbound :5335 Unbound :5335 Unbound :5335
@@ -32,6 +32,7 @@
- **Unbound** on each node acts as the local recursive resolver (replaces Cloudflare/Quad9)
- **Pi-hole** on each node forwards upstream queries to its local Unbound (`127.0.0.1#5335`)
- **Nebula Sync** (running on PD) syncs all Pi-hole configs from primary → replicas daily
- Current live replica target is NOMAD at `10.5.1.16:8954`
All nodes use `network_mode: host` for Pi-hole, Unbound, and Keepalived — this is required for Keepalived to manipulate the VIP and for Pi-hole to bind cleanly to port 53.
@@ -108,7 +109,8 @@ mkdir -p /path/to/appdata/unbound/var
## Node 1: PlausibleDeniability (MASTER)
**Stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
**Repo stack dir:** `/home/fizzlepoof/repos/truenas-stacks/pihole/`
**Live stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
**Web UI port:** `8953`
### Pre-Deploy
@@ -210,23 +212,26 @@ LAN_INTERFACE=eno1
### Deploy
Preferred PD-first flow now that the repo-side stack exists:
```bash
cd /mnt/docker-ssd/docker/compose/pihole
# Copy unbound.conf to appdata
sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/
sudo docker compose --env-file .env config
sudo docker compose --env-file .env up -d
sudo docker logs pihole --tail 20
sudo docker logs unbound --tail 20
cp .env.example .env
nano .env
/usr/bin/bash bin/prepare_pd.sh
/usr/bin/bash bin/prepare_pd.sh --up
sudo docker logs pihole-primary --tail 20
sudo docker logs unbound-pihole-primary --tail 20
```
---
## Node 2: Serenity (BACKUP1)
## Node 2: N.O.M.A.D. (BACKUP1)
Serenity runs Unraid. Deploy via Docker Compose in Unraid's **User Scripts** or the **Compose Manager** plugin.
N.O.M.A.D. runs Ubuntu bare metal. Deploy via Docker Compose on the host.
**Appdata:** `/mnt/user/appdata/`
**Live service root:** `/opt/pihole-nomad`
**Service-local data root:** `/opt/pihole-nomad/data/`
**Web UI port:** `8954`
### docker-compose.yaml
@@ -241,7 +246,7 @@ services:
network_mode: host
restart: unless-stopped
volumes:
- /mnt/user/appdata/unbound-pihole:/opt/unbound/etc/unbound/
- /opt/pihole-nomad/data/unbound:/opt/unbound/etc/unbound/
healthcheck:
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
interval: 30s
@@ -250,7 +255,7 @@ services:
pihole:
image: pihole/pihole:latest
container_name: pihole-secondary
container_name: pihole-nomad
network_mode: host
restart: unless-stopped
depends_on:
@@ -259,11 +264,15 @@ services:
environment:
TZ: ${TZ}
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
FTLCONF_dns_listeningMode: "LOCAL"
FTLCONF_dns_interface: "enp5s0"
FTLCONF_dns_listeningMode: "SINGLE"
FTLCONF_dns_upstreams: "127.0.0.1#5335"
FTLCONF_webserver_port: "8954"
FTLCONF_ntp_ipv4_active: "false"
FTLCONF_ntp_ipv6_active: "false"
FTLCONF_ntp_sync_active: "false"
volumes:
- /mnt/user/appdata/pihole-secondary/etc-pihole:/etc/pihole
- /opt/pihole-nomad/data/pihole/etc-pihole:/etc/pihole
cap_add:
- SYS_NICE
@@ -280,16 +289,30 @@ services:
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
```
### keepalived.conf (Serenity — BACKUP1)
### NOMAD host prerequisite
If `systemd-resolved` is holding a local DNS stub on port 53, disable it before relying on NOMAD Pi-hole for LAN DNS:
```bash
sudo mkdir -p /etc/systemd/resolved.conf.d
cat <<'EOF' | sudo tee /etc/systemd/resolved.conf.d/99-pihole.conf
[Resolve]
DNSStubListener=no
EOF
sudo systemctl restart systemd-resolved
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
```
### keepalived.conf (N.O.M.A.D. — BACKUP1)
```
global_defs {
router_id pihole_serenity
router_id pihole_nomad
}
vrrp_instance VI_DNS {
state BACKUP
interface eth0
interface enp5s0
virtual_router_id 53
priority 90
advert_int 1
@@ -411,7 +434,7 @@ sudo systemctl start keepalived
restart: unless-stopped
environment:
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
REPLICAS: "http://10.5.1.5:8954|${PIHOLE_PASSWORD_SERENITY},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
REPLICAS: "http://10.5.1.16:8954|${PIHOLE_PASSWORD_NOMAD},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
RUN_GRAVITY: "true"
FULL_SYNC: "false"
CRON: "0 3 * * *"
@@ -423,7 +446,7 @@ sudo systemctl start keepalived
> - `REPLICAS` — comma-separated list of secondary instances
> - `CRON` — syncs daily at 3 AM; force a manual sync with `docker exec nebula-sync nebula-sync run`
> - Each Pi-hole instance can (and should) have a **different** password — nebula-sync handles auth per-instance
> - Add `PIHOLE_PASSWORD_SERENITY` and `PIHOLE_PASSWORD_RPI` to your `.env` file
> - Add `PIHOLE_PASSWORD_NOMAD` and `PIHOLE_PASSWORD_RPI` to your `.env` file
### What Nebula Sync copies
- Gravity database (blocklists, allowlists, group assignments)
@@ -447,6 +470,12 @@ Point **only the VIP** as DNS:
---
## Current live endpoints
- **PD admin UI:** `http://10.5.1.6:8953/admin/`
- **NOMAD admin UI:** `http://10.5.1.16:8954/admin/`
- **VIP for DNS clients:** `10.5.1.53`
## Post-Deploy
### Verify VIP is working
@@ -466,7 +495,7 @@ nslookup doubleclick.net 10.5.1.53 # Should return 0.0.0.0 (blocked)
# On PD, bring down keepalived
sudo docker stop keepalived
# Wait ~3 seconds, then check from another machine:
nslookup google.com 10.5.1.53 # Should still work — Serenity took over VIP
nslookup google.com 10.5.1.53 # Should still work — N.O.M.A.D. took over VIP
# Restore
sudo docker start keepalived
```
@@ -488,7 +517,6 @@ Add in Pi-hole (primary) → Local DNS → DNS Records:
| Domain | IP |
|--------|----|
| pd.lan | 10.5.1.6 |
| serenity.lan | 10.5.1.5 |
| nomad.lan | 10.5.1.16 |
| rocinante.lan | 10.5.1.112 |
| rpi4.lan | 10.5.1.X |
@@ -500,7 +528,9 @@ Nebula Sync will propagate these to all replicas.
| Subdomain | Target |
|-----------|--------|
| `pihole.paccoco.com` | `http://10.5.1.6:8953` (primary) |
| `pihole2.paccoco.com` | `http://10.5.1.5:8954` (Serenity) |
| `pihole2.paccoco.com` | `http://10.5.1.16:8954` (N.O.M.A.D.) |
Use `/admin/` on the public hostname for the Pi-hole admin UI path.
Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
@@ -512,7 +542,7 @@ Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|------|------|------|--------|
| PD | `/mnt/tank/docker/appdata/pihole-primary` | tank | Low write frequency |
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
| Serenity | `/mnt/user/appdata/pihole-secondary` | Unraid array | Low write frequency |
| N.O.M.A.D. | `/opt/pihole-nomad/data` | Local service root | Self-contained NOMAD service data |
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
---

View File

@@ -0,0 +1,82 @@
# School Work Intake Pipeline (Telegram → Postgres → Paperless → n8n)
## Goal
Give Doris a reliable way to ingest school work from Telegram while preserving explicit metadata:
- class
- assignment
- submission kind (`draft`, `final`, etc.)
- optional semester / instructor / due date / paper kind
The design must be expandable for new classes and future non-paper workflows.
## Recommended architecture
1. **Telegram chat-driven intake**
- John sends file(s) to Doris on Telegram.
- Doris asks follow-up questions until required metadata is complete.
2. **n8n intake webhook**
- Doris submits the file + metadata to an intake webhook.
- The webhook creates a durable intake record in shared Postgres.
- The webhook stages/uploads the file into Paperless with a deterministic `intake_id` embedded in the filename.
3. **Paperless post-consumption webhook**
- After Paperless finishes processing the file, a second workflow fetches the Paperless document.
- That workflow extracts `intake_id`, looks up the Postgres intake record, and applies metadata.
4. **Metadata + AI enrichment**
- Deterministic metadata first: class tags, version tags, title rules, optional correspondent/document type IDs.
- AI second: document summary/note and any low-risk enrichment.
- Ambiguous cases can be routed to a review queue.
## Why this is better than folder-based consume ingestion
Folder paths are a decent hint, but they are not durable enough to be the only source of truth.
Problems with folder-only inference:
- folder context can disappear after import
- mixed uploads are easy to mislabel
- retries/reprocessing can grab the wrong document
- future use-cases (draft review, assignment history, instructor-specific rules) need explicit metadata anyway
## Shared DB requirements
Use shared Postgres, not SQLite.
Schema artifact:
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
Core tables:
- `school_paperless_intake`
- `school_paperless_intake_events`
## Config-driven class profiles
Class/course behavior should live in config, not hardcoded workflow branches.
Suggested profile fields:
- `class_key`
- `display_name`
- `course_code`
- `default_tags`
- Paperless IDs for correspondent/document type/storage path when needed
- title template override
## Initial rollout target
- ENGL-1010
- HIST-2020
## OpenClaw helper artifact
Local helper:
- `school/intake/submit_to_n8n.js`
This gives Doris a simple handoff point after the Telegram conversation is complete.
## Live work still needed
- configure class profiles with real Paperless IDs / tag mappings
- decide Telegram delivery wording/confirmation behavior
- after any live `.env` changes for this pipeline, sync them into the encrypted homelab secrets repo (`docs/operations/SECRETS_MANAGEMENT.md`)

View File

@@ -1,27 +1,29 @@
# Homelab TODO
## Security (do ASAP)
- [ ] Regenerate Wings token on N.O.M.A.D. (was exposed in chat)
- [ ] Regenerate N.O.M.A.D. Newt secret (was exposed in chat)
- [x] Regenerate Wings token on N.O.M.A.D. (was exposed in chat) — completed 2026-05-15
- [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15
## Infrastructure
- [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.md
- RPi4 IP still TBD — fill in before deploying
- [ ] Deploy Kima-Hub — see docs/planning/DEPLOY_KIMA_HUB.md
- [x] Deploy Pi-hole HA on PD + N.O.M.A.D. with VIP `10.5.1.53` — completed 2026-05-15; see docs/planning/DEPLOY_PIHOLE.md
- PD primary live at `/mnt/docker-ssd/docker/compose/pihole`
- NOMAD backup live at `/opt/pihole-nomad`
- 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
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
- [ ] Add Pangolin resource for `ai.paccoco.com` → OpenWebUI (port 8282)
- [ ] Add Cloudflare DNS A record for `ai.paccoco.com`
- [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
- [ ] Set up private Gitea repo for .env file backups
- [x] Set up private Gitea repo for .env file backups — 2026-05-14
## AI Stack
- [ ] Pull `qwen2.5:14b` on PD ollama: `sudo docker exec -it ollama ollama pull qwen2.5:14b`
- [ ] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md
- [ ] Disable signups in OpenWebUI after creating account
- [ ] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections)
- [ ] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted
- [x] Pull `qwen2.5:14b` on PD ollama — completed 2026-05-15
- [x] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md — completed 2026-05-15
- [x] Disable signups in OpenWebUI after creating account — completed 2026-05-15
- [x] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections) — completed 2026-05-15
- [x] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted — completed 2026-05-15
## Healthchecks
- [ ] Add healthcheck to ix-plex-plex-1
@@ -32,15 +34,23 @@
## Documentation
- [ ] Document meshtastic stack fully (see docs/reference/MESHTASTIC.md)
- [ ] Document N.O.M.A.D. containers (start_nomad.sh services)
- [x] Document N.O.M.A.D. Docker/container audit (compose-managed, Project N.O.M.A.D.-managed, unmanaged, and Pelican/Wings-managed containers) — 2026-05-14
- [x] Decide which services should run on NOMAD, which should not, and whether each should live under raw compose, Project N.O.M.A.D.-managed state, systemd, or another more explicit deployment model — 2026-05-14
- Project N.O.M.A.D.-managed app containers are explicitly hands-off and stay exactly as-is.
- `whisper` and `node-exporter` may be removed; exact runtime was documented in `docs/servers/NOMAD.md` for emergency rebuild.
- Pelican/Wings-managed game containers stay and are documented as permanent game-server infrastructure on NOMAD.
## n8n Workflows
- [ ] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra
- [ ] Add env vars to n8n: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY`
- [ ] Set up `PD SSH` credential in n8n for workflow 16 (see n8n-workflows/README.md)
- [ ] Add sudoers NOPASSWD rule for n8n watchdog (see n8n-workflows/README.md)
- [ ] Import and activate upgraded workflows: 01, 04, 08
- [ ] Import and activate new workflows: 14, 15 (16 already active)
- [x] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra — completed 2026-05-15
- [x] Add remaining env vars to n8n docker-compose + .env: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY` — completed 2026-05-15
- [x] Set up `PD SSH` credential in n8n for workflow 16 — completed 2026-05-15
- [x] Add sudoers NOPASSWD rule for n8n watchdog — completed 2026-05-15
- [x] Import and activate upgraded workflows: 04, 08 (01 done — v1.4 active and tested) — completed 2026-05-15
- [x] Import and activate new workflows: 14, 15 (16 already active) — completed 2026-05-15
- [x] Configure class profiles + Paperless metadata mapping for current courses (ENGL-1010, HIST-2020, future classes) — completed 2026-05-15
- [x] Delete legacy duplicate Paperless notes for school docs 4249 after approval / with proper token context — completed 2026-05-14; docs now each have one canonical note
- [x] Set up DB and Docker backups to Serenity following homelab SOP — completed 2026-05-15
- Live `.env`, SSH trust, scheduler deployment, first run, and verification completed on PD
## Completed
- [x] Deploy Gotify (Phase 1) — 2026-05-05
@@ -56,4 +66,8 @@
- [x] Write n8n workflow upgrade + new workflow plans (01, 04, 08, 14, 15, 16) — 2026-05-09
- [x] Write Pi-hole 3-node HA deployment plan — 2026-05-09
- [x] Write Kima-Hub deployment plan — 2026-05-09
- [x] Create Postgres dedup tables (grafana_alert_dedup, n8n_health_dedup) on PD — 2026-05-09
- [x] Create Postgres dedup tables (grafana_alert_dedup, n8n_health_dedup) in n8n_rss DB on PD — 2026-05-09
- [x] Workflow 01 fully tested end-to-end (dedup, LiteLLM, Gotify) — v1.4 active — 2026-05-09
- [x] Add LITELLM_API_KEY + N8N_BLOCK_ENV_ACCESS_IN_NODE to n8n docker-compose — 2026-05-09
- [x] Build Telegram chat-driven school-work intake for Paperless using shared Postgres, deterministic `intake_id`, and class/version metadata — 2026-05-14
- [x] Build Doris-managed KitchenOwl recipe importer flow with native-scrape fallback, schema.org JSON-LD parsing, duplicate checks, and dry-run support — 2026-05-15

View File

@@ -0,0 +1,391 @@
# Doris Schoolhouse — Technical Spec
## Goal
Build a desktop-first local web app on NOMAD for school intake and tracking.
Primary jobs:
- upload assignment turn-ins
- upload class recordings
- suggest metadata from D2L
- confirm metadata before Paperless submission
- transcribe and summarize recordings
- track assignment lifecycle through grading
- append grades and professor comments into Paperless notes
## Deployment shape
- **Source path:** `home/doris-schoolhouse`
- **Live NOMAD runtime target:** `/opt/doris-schoolhouse`
- **Access:** local + Pangolin-authenticated
- **Networks:** `pangolin`, `ix-databases_shared-databases`
- **DB:** shared Postgres
- **Archive/document system:** Paperless-NGX
- **Workflow helpers:** existing n8n workflows for school intake and class recording processing when appropriate
## Source of truth
| Domain | Source of truth |
|---|---|
| Course roster | D2L sync cache |
| Assignment roster / due dates | D2L sync cache with manual override support |
| Submission workflow state | Doris Schoolhouse DB |
| Uploaded files / transcripts / summaries | Paperless |
| Grades / professor comments | D2L on refresh, cached in Doris Schoolhouse DB |
The app DB owns workflow state and linkage. Paperless owns archived artifacts.
## Existing reusable assets
### D2L
Existing workspace assets already prove the login/scrape path:
- `~/.openclaw/workspace/school/d2l_scraper_final.js`
- `~/.openclaw/workspace/school/d2l_school_watcher.js`
- `~/.openclaw/workspace/school/d2l_current_snapshot.json`
- `~/.openclaw/workspace/school/d2l_grades.json`
Current confirmed D2L coverage:
- courses
- grades
- grade items
- some material/date counts
- calendar/ICS-derived schedule
Gap to close for Schoolhouse:
- normalize D2L data into app DB tables
- stronger assignment/date extraction
- deterministic matching between D2L assignment rows and Paperless-backed submissions
### Paperless / n8n
Existing repo assets:
- `n8n-workflows/08-class-recording-rag-v1.2.json`
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
- `docs/operations/SCHOOL_PAPERLESS_INTAKE.md`
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql`
Current confirmed behavior from docs/testing notes:
- school uploads can already be pushed to Paperless with deterministic filename/title correlation
- class recording pipeline already has a webhook path and downstream Paperless/RAG behavior
- workflow 19 already does deterministic post-consumption enrichment
## App architecture
Use a server-rendered Python app for the first build:
- **Backend:** FastAPI
- **Templates:** Jinja2
- **Frontend:** vanilla JS + server-rendered forms/pages
- **DB access:** SQLAlchemy / psycopg
- **Background work:** app jobs can hand off long-running audio processing to existing n8n recording/transcription pipeline
Why this split:
- easier large-upload handling
- easy form-heavy UI
- less frontend build nonsense
- plays nicely with existing Python-heavy repo bits
## Core pages
### 1. Dashboard `/`
Shows:
- upcoming assignments
- unsubmitted assignments
- recent uploads pending confirmation
- newly graded work
- recording processing status
- sync health
### 2. Assignment Upload `/assignments/upload`
Form fields:
- class
- assignment
- submitted date
- version
- notes
- file upload / drag-drop / paste
Behavior:
1. choose file
2. app suggests class + assignment from D2L cache
3. user confirms/edits metadata
4. upload goes to Paperless intake path
5. app stores linkage row
6. assignment state becomes `submitted`
### 3. Recording Upload `/recordings/upload`
Form fields:
- class
- session date
- optional related assignment
- notes
- `.wav` upload
Behavior:
1. accept large WAV
2. persist upload job row
3. convert WAV → MP3
4. delete WAV only after MP3 success
5. hand MP3 to transcription/summarization flow
6. send transcript + summary to Paperless
7. store resulting Paperless document links
### 4. Classes `/classes` and `/classes/{id}`
Shows:
- assignments
- due dates
- grades
- comments
- linked submissions
- linked recordings
### 5. D2L Sync `/admin/d2l`
Shows:
- last sync time
- sync status
- changed courses/assignments/grades
- unmatched rows requiring manual mapping
## API surface
### Health
- `GET /api/health`
### D2L
- `POST /api/d2l/sync`
- `GET /api/d2l/courses`
- `GET /api/d2l/assignments?course_id=`
- `GET /api/d2l/grades?course_id=`
### Assignments
- `GET /api/assignments`
- `POST /api/assignments/intake`
- `POST /api/assignments/{id}/confirm-paperless`
- `POST /api/assignments/{id}/status`
### Recordings
- `GET /api/recordings`
- `POST /api/recordings/intake`
- `POST /api/recordings/{id}/reprocess`
### Paperless linkage
- `POST /api/paperless/webhook/intake-enriched`
- `POST /api/paperless/webhook/recording-complete`
- `POST /api/paperless/assignments/{id}/append-grade-note`
## Data model
### `schoolhouse_course`
- id
- d2l_course_id
- name
- code
- semester
- instructor_name
- active
- first_seen_at
- last_synced_at
### `schoolhouse_assignment`
- id
- course_id
- d2l_assignment_key
- title
- category
- due_at
- status (`assigned|in_progress|submitted|graded|late|missing`)
- grade_text
- grade_numeric
- professor_comments
- source (`d2l|manual|template`)
- created_at
- updated_at
### `schoolhouse_submission`
- id
- assignment_id
- version_label
- original_filename
- mime_type
- checksum_sha256
- submitted_at
- suggested_metadata_json
- confirmed_metadata_json
- paperless_document_id
- paperless_title
- paperless_note_appended_at
- status (`draft|uploaded|confirmed|failed`)
### `schoolhouse_recording`
- id
- course_id
- assignment_id nullable
- session_date
- original_filename
- mp3_filename
- duration_seconds
- transcription_status (`queued|processing|ready|failed`)
- summary_status (`queued|processing|ready|failed`)
- transcript_paperless_document_id
- summary_paperless_document_id
- recording_paperless_document_id nullable
- notes
### `schoolhouse_d2l_sync_run`
- id
- started_at
- completed_at
- status
- raw_snapshot_path
- changes_summary_json
- error_text
### `schoolhouse_assignment_mapping`
- id
- course_id
- d2l_grade_item_name
- normalized_key
- assignment_id
- confidence
- mapping_source (`auto|manual`)
This table is the bastard that keeps D2L grade items tied to internal assignments cleanly.
## Matching rules
### Assignment matching priority
1. exact D2L assignment key when available
2. normalized title within course
3. filename heuristics
4. latest due date proximity
5. manual confirmation
### Grade/comment update policy
- D2L refresh updates cached grade/comment fields for mapped assignments
- if a submission has a Paperless document ID and grade/comment changed, append note once per distinct change hash
- never overwrite user notes in Paperless; append a dated Schoolhouse block
## Recording pipeline contract
### Input limits
- accept WAV up to at least 1 GB
- support up to 90 minutes
- reject unsupported formats with clear message
### Processing steps
1. store upload metadata row
2. convert WAV → MP3
3. delete WAV only after MP3 success
4. submit MP3 to recording workflow
5. receive transcript/summary callback
6. archive transcript + summary into Paperless
## Secrets/config
Required env:
- `SCHOOLHOUSE_HOST`
- `SCHOOLHOUSE_PORT`
- `DATABASE_URL`
- `PAPERLESS_BASE_URL`
- `PAPERLESS_API_TOKEN`
- `N8N_BASE_URL`
- `SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK`
- `SCHOOLHOUSE_N8N_RECORDING_WEBHOOK`
- `D2L_SCRAPER_WORKDIR`
- `D2L_SCRAPER_COMMAND`
- `UPLOAD_TMP_DIR`
- `MAX_UPLOAD_MB`
- `SESSION_SECRET`
Do not commit live values. Use `.env.example` only.
## Repo / deployment plan
Repo source path:
- `home/doris-schoolhouse`
Expected live NOMAD runtime path later:
- `/opt/doris-schoolhouse`
Expected live data path later:
- `/opt/doris-schoolhouse/data`
Expected compose validation before deploy:
```bash
cd /opt/doris-schoolhouse
docker compose --env-file .env config
```
## Build phases
### Phase 1
- app scaffold
- schema
- D2L import command/service
- assignment upload UI
- Paperless confirmation flow
### Phase 2
- recording upload flow
- MP3 conversion
- recording job tracking
- transcript/summary callbacks
### Phase 3
- grade sync
- Paperless note append logic
- dashboard views
### Phase 4
- manual mappings UI
- recurring assignment templates
- mobile cleanup
## Verification checklist
- Python modules compile
- schema file present and readable
- compose file parses
- route map loads without import failure
- README explains runtime/deploy expectations
- no secrets committed

View File

@@ -35,7 +35,7 @@ See the individual docs in this repo for detailed information:
| Server | Role | OS | IP |
|--------|------|----|----|
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | LAN DHCP |
| PlausibleDeniability | Primary Docker host | TrueNAS Scale 25.10.2.1 | 10.5.1.6 |
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
### Deployment Pattern (PD)

View File

@@ -0,0 +1,47 @@
-- Shared Postgres schema for Telegram-driven school-work intake.
-- Apply in a dedicated `school_intake` database or adapt to an existing shared DB.
CREATE TABLE IF NOT EXISTS school_paperless_intake (
intake_id TEXT PRIMARY KEY,
class_name TEXT NOT NULL,
assignment_name TEXT NOT NULL,
submission_kind TEXT NOT NULL,
semester TEXT,
course_code TEXT,
paper_kind TEXT,
notes TEXT,
source TEXT NOT NULL DEFAULT 'telegram',
telegram_chat_id TEXT,
telegram_message_id TEXT,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size_bytes BIGINT NOT NULL,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received',
paperless_task_id TEXT,
paperless_document_id BIGINT,
paperless_title TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
uploaded_at TIMESTAMPTZ,
enriched_at TIMESTAMPTZ,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_status ON school_paperless_intake(status);
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_doc_id ON school_paperless_intake(paperless_document_id);
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_assignment ON school_paperless_intake(class_name, assignment_name);
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_checksum ON school_paperless_intake(checksum_sha256);
CREATE TABLE IF NOT EXISTS school_paperless_intake_events (
id BIGSERIAL PRIMARY KEY,
intake_id TEXT NOT NULL REFERENCES school_paperless_intake(intake_id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_school_paperless_intake_events_intake_id
ON school_paperless_intake_events(intake_id);

View File

@@ -21,10 +21,215 @@ Offline survival/knowledge server and game server host.
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
## Project N.O.M.A.D.
- 10 containers running via `start_nomad.sh` / `stop_nomad.sh`
- `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
- Core compose project lives at `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
- Main compose file: `/mnt/hdd-1/project-nomad/compose.yml`
- Docker data-root: `/mnt/hdd-2/docker`
- Based on Crosstalk Solutions offline survival/knowledge server
- Current runtime is split between:
- the base `project-nomad` compose stack
- `project-nomad-managed` app containers launched/managed by the N.O.M.A.D. admin layer
- a few extra non-compose / non-NOMAD containers on the host
## Docker Audit — 2026-05-14
### Compose files found on NOMAD
- `/mnt/hdd-1/project-nomad/compose.yml` (same project as `/opt/project-nomad/compose.yml` via symlink)
- `/opt/newt/docker-compose.yaml`
### Running containers tied directly to compose files
**`project-nomad` compose (`/mnt/hdd-1/project-nomad/compose.yml`)**
- `nomad_admin`
- `nomad_mysql`
- `nomad_redis`
- `nomad_dozzle`
- `nomad_disk_collector`
- `nomad_updater`
**`newt` compose (`/opt/newt/docker-compose.yaml`)**
- `newt`
### Running containers managed by Project N.O.M.A.D. itself
These are labeled `com.docker.compose.project=project-nomad-managed` and `io.project-nomad.managed=true`, but no standalone compose files for them were found on disk during this audit.
- `nomad_cyberchef`
- `nomad_flatnotes`
- `nomad_kiwix_server`
- `nomad_kolibri`
- `nomad_ollama`
- `nomad_qdrant`
**Operator decision (John, 2026-05-14):** this layer stays exactly as-is. It is self-maintained by Project N.O.M.A.D. Document it, but **never manually touch, rebuild, migrate, or “clean up” these containers** unless John explicitly overrides that rule.
Known storage paths discovered from mounts / project storage:
- Flatnotes data: `/opt/project-nomad/storage/flatnotes`
- Kiwix ZIM library: `/opt/project-nomad/storage/zim`
- Kolibri data: `/opt/project-nomad/storage/kolibri`
- Ollama data/models: `/opt/project-nomad/storage/ollama`
- Qdrant storage: `/opt/project-nomad/storage/qdrant`
### Running containers not tied to discovered compose files
**Likely host-managed / manually launched / app-managed elsewhere**
#### `whisper` — `fedirz/faster-whisper-server:latest-cpu`
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
- Purpose: ad-hoc Faster Whisper API endpoint
- Network mode: `bridge`
- Container IP during audit: `172.17.0.2`
- Port publish: host `8786/tcp` → container `8000/tcp` (IPv4 and IPv6)
- Restart policy: `unless-stopped`
- Entrypoint: image default
- Command:
- `uv run uvicorn --factory faster_whisper_server.main:create_app`
- Environment:
- `WHISPER__MODEL=Systran/faster-distil-whisper-small.en`
- `WHISPER__INFERENCE_DEVICE=cpu`
- `UVICORN_HOST=0.0.0.0`
- `UVICORN_PORT=8000`
- Mounts: none
- Compose labels: none found
- Rebuild-equivalent `docker run`:
```bash
docker run -d \
--name whisper \
--restart unless-stopped \
-p 8786:8000 \
-e WHISPER__MODEL=Systran/faster-distil-whisper-small.en \
-e WHISPER__INFERENCE_DEVICE=cpu \
-e UVICORN_HOST=0.0.0.0 \
-e UVICORN_PORT=8000 \
fedirz/faster-whisper-server:latest-cpu \
uv run uvicorn --factory faster_whisper_server.main:create_app
```
#### `node-exporter` — `prom/node-exporter:v1.9.0`
**Operator decision (John, 2026-05-14):** this can go away. Do not preserve it as a required service, but keep enough detail here to rebuild it if removing it causes issues.
- Purpose: host Prometheus metrics exporter
- Network mode: `host`
- Restart policy: `unless-stopped`
- Entrypoint: `/bin/node_exporter`
- Command:
- `--path.procfs=/host/proc`
- `--path.sysfs=/host/sys`
- `--path.rootfs=/rootfs`
- `--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)`
- Read-only bind mounts:
- `/proc` → `/host/proc`
- `/sys` → `/host/sys`
- `/` → `/rootfs`
- Compose labels: none found
- Rebuild-equivalent `docker run`:
```bash
docker run -d \
--name node-exporter \
--restart unless-stopped \
--network host \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /:/rootfs:ro \
prom/node-exporter:v1.9.0 \
--path.procfs=/host/proc \
--path.sysfs=/host/sys \
--path.rootfs=/rootfs \
'--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
```
**Pelican/Wings-managed game containers**
**Operator decision (John, 2026-05-14):** this is the game server stack. It stays. Document it; do not remove or migrate it unless John explicitly says so.
- `11d799cb-487a-416a-b6f9-9bf28f0e57e7`
- Image: `ghcr.io/pelican-eggs/yolks:java_25`
- Network: `pelican_nw` (`172.20.0.2` during audit)
- Ports: `25565/tcp` and `25565/udp` exposed on host `0.0.0.0`
- Restart policy: `no` (Wings owns lifecycle)
- Entrypoint: `/usr/bin/tini -g --`
- Command: `/entrypoint.sh`
- Important env:
- `SERVER_IP=0.0.0.0`
- `SERVER_PORT=25565`
- `SERVER_JARFILE=server.jar`
- `STARTUP=java --add-modules=jdk.incubator.vector -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true -jar server.jar`
- `P_SERVER_UUID=11d799cb-487a-416a-b6f9-9bf28f0e57e7`
- Mounts:
- `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/home/container` (rw)
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
- `/etc/pelican/group` → `/etc/group` (ro)
- `/etc/pelican/machine-id/11d799cb-487a-416a-b6f9-9bf28f0e57e7` → `/etc/machine-id` (ro)
- `d2aca31e-0d4d-433a-8295-cfad397dab0c`
- Image: `ghcr.io/pelican-eggs/yolks:java_21`
- Network: `pelican_nw` (`172.20.0.3` during audit)
- Ports: `25566/tcp` and `25566/udp` exposed on host `0.0.0.0`
- Restart policy: `no` (Wings owns lifecycle)
- Entrypoint: `/usr/bin/tini -g --`
- Command: `/entrypoint.sh`
- Important env:
- `SERVER_IP=0.0.0.0`
- `SERVER_PORT=25566`
- `SERVER_MEMORY=8192`
- `MC_VERSION=1.21.1`
- `NEOFORGE_VERSION=21.1.229`
- `STARTUP=java -Xms128M -XX:MaxRAMPercentage=95.0 -Dterminal.jline=false -Dterminal.ansi=true @unix_args.txt`
- `P_SERVER_UUID=d2aca31e-0d4d-433a-8295-cfad397dab0c`
- Mounts:
- `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/home/container` (rw)
- `/etc/pelican/passwd` → `/etc/passwd` (ro)
- `/etc/pelican/group` → `/etc/group` (ro)
- `/etc/pelican/machine-id/d2aca31e-0d4d-433a-8295-cfad397dab0c` → `/etc/machine-id` (ro)
These are expected to be managed by Wings/Pelican rather than a local compose file.
### Dormant compose files found but not currently represented in running containers
- None found during this audit in searched paths.
- Search scope: `/opt`, `/srv`, `/home/fizzlepoof`, and `/mnt` for `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, and `compose.yaml`.
### Decisions captured from this audit
- Project N.O.M.A.D.-managed app containers stay exactly as-is and are **hands-off** unless John explicitly says otherwise.
- `whisper` and `node-exporter` are not protected services; they can be removed, but their exact runtime has been documented above for rebuild/recovery.
- Pelican/Wings-managed game containers are part of the permanent game-server stack on NOMAD and stay in place.
- Doris Dashboard source belongs in the homelab repo, and the live NOMAD runtime belongs at `/opt/doris-dashboard`.
### Doris Dashboard (local NOMAD homepage)
- Live runtime path: `/opt/doris-dashboard`
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard`
- Purpose: local-only dashboard for briefing/news/weather/homelab pulse/Paperless review on NOMAD
- Generator: `home/doris-dashboard/bin/generate_dashboard.py`
- Interactive LAN server: `home/doris-dashboard/bin/dashboard_server.py`
- Service file source: `home/doris-dashboard/doris-dashboard.service`
- Runtime notes:
- Generated output and queue snapshots are runtime data; source/config lives in git, generated JSON/HTML does not need to be treated as canonical.
- Topic thumbs write back to `doris-digest/data/feedback.json`.
- Paperless acknowledge/dismiss state writes to `/opt/doris-dashboard/data/paperless_review_state.json` on the live host.
### Live deployment path rule
- On NOMAD, live service roots belong under `/opt/<service>`.
- That includes host-run services **and** standalone Docker Compose deployments that are not part of the core Project N.O.M.A.D. stack.
- Keep service-local runtime data with the service under `/opt/<service>/data` unless there is a clear reason to place heavy state elsewhere.
- Examples: `/opt/newt`, `/opt/doris-dashboard`, `/opt/pihole-nomad`.
- Do not run production services from any agent workspace or ad-hoc checkout under `/home/fizzlepoof/.openclaw/workspace`.
### Safe to remove / how to restore
#### Safe to remove now
- `whisper`
- `node-exporter`
Removing them should not be treated as a Project N.O.M.A.D. change or a Wings/Pelican change. They are standalone host-level containers from this audits point of view.
#### Restore checklist if removal causes trouble
1. Confirm what broke:
- speech/transcription endpoint dependency → restore `whisper`
- Prometheus scraping / host metrics gap → restore `node-exporter`
2. Recreate the container using the documented `docker run` command in this file.
3. Verify it came back cleanly:
- `docker ps --filter name=whisper`
- `docker ps --filter name=node-exporter`
- `docker logs --tail 50 whisper`
- `docker logs --tail 50 node-exporter`
4. Re-check any dependent dashboards, scrapers, or automations.
5. If the recreated container needs to persist long-term again, document *why* before leaving it in place.
#### Restore intent
- Restore `whisper` only if something still expects a local Faster Whisper HTTP endpoint on port `8786`.
- Restore `node-exporter` only if something still depends on host Prometheus metrics from this box.
- Do **not** fold either one into Project N.O.M.A.D. or Wings/Pelican casually; if they come back, they come back as deliberate standalone services unless John says otherwise.
## Pelican Panel (Game Server Management)
- **URL:** `https://panel.paccoco.com`
@@ -49,7 +254,16 @@ Offline survival/knowledge server and game server host.
- **Docker network:** `pelican_nw` (172.20.0.0/16)
## Active Game Servers
- **Minecraft:** Purpur, Java 21, port 25565 TCP/UDP (direct host port forward)
- **Container `11d799cb-487a-416a-b6f9-9bf28f0e57e7`**
- Workload: Minecraft / Purpur-style Java server
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_25`
- Public port: `25565` TCP/UDP
- Data path: `/var/lib/pelican/volumes/11d799cb-487a-416a-b6f9-9bf28f0e57e7`
- **Container `d2aca31e-0d4d-433a-8295-cfad397dab0c`**
- Workload: modded Minecraft-style server (NeoForge-related env present)
- Java runtime image: `ghcr.io/pelican-eggs/yolks:java_21`
- Public port: `25566` TCP/UDP
- Data path: `/var/lib/pelican/volumes/d2aca31e-0d4d-433a-8295-cfad397dab0c`
## Networking Notes
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback

View File

@@ -31,9 +31,10 @@ Primary Docker host running all production workloads.
RTX 2080 Ti used by:
- Plex (hardware transcoding)
- immich-ml (CUDA face recognition)
- Whisper (CUDA speech-to-text, port 8786)
- Ollama light-tier inference (qwen2.5:14b and smaller)
> **Note:** Whisper moved to N.O.M.A.D. (10.5.1.16:8786) as CPU inference — avoids VRAM conflict with Ollama. See [NOMAD.md](NOMAD.md).
Recommended models for 11GB VRAM:
| Model | VRAM | Best for |
|-------|------|---------|

View File

@@ -5,12 +5,13 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
## PlausibleDeniability
### n8n workflow 16 (NFS Watchdog)
- Runs `sudo docker restart` and `sudo bash .../mount-unraid-nfs.sh` over SSH from n8n container
- Runs `sudo docker restart` and `sudo /usr/bin/bash .../mount-unraid-nfs.sh` over SSH from n8n container
- Requires a NOPASSWD sudoers rule or SSH commands will hang waiting for a password:
```
truenas_admin ALL=(ALL) NOPASSWD: /usr/bin/docker, /bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
truenas_admin ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh
```
Add via `sudo visudo -f /etc/sudoers.d/n8n-watchdog`
- **Must use `/usr/bin/bash` not `/bin/bash`** — on TrueNAS SCALE, bash is at `/usr/bin/bash`. The sudoers rule and the workflow command must match exactly or sudo will prompt for a password.
- SSH credential in n8n must use key-based auth (see n8n-workflows/README.md for keygen steps)
### immich-ml

View File

@@ -2,6 +2,9 @@ PAPERLESS_DBUSER=paperless
PAPERLESS_DBPASS=changeme
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
PAPERLESS_REDIS=redis://:URL_ENCODED_REDIS_PASSWORD@shared-redis:6379
PAPERLESS_TIKA_ENABLED=1
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
# -----------------------------
# Karakeep

39
documents/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Documents Stack
Docker Compose stack for document and knowledge-capture services on PD.
## Paperless-NGX
- URL: `https://paperless.paccoco.com` / `http://pd:8083`
- Compose service: `paperless`
- Appdata: `/mnt/docker-ssd/docker/appdata/paperless/`
- Consume folder: `/mnt/unraid/data/paperless/consume``/usr/src/paperless/consume`
- Database: shared Postgres on `ix-databases_shared-databases`
- Redis: shared Redis via `PAPERLESS_REDIS`
Paperless has internal-only Tika and Gotenberg sidecars for Office/OpenDocument ingestion:
- `tika` (`apache/tika`) at `http://tika:9998`, memory limit `512m`
- `gotenberg` (`gotenberg/gotenberg`) at `http://gotenberg:3000`, memory limit `256m`
- Both sidecars live only on `documents_paperless_internal` and expose no host ports.
Required Paperless Tika env keys:
```env
PAPERLESS_TIKA_ENABLED=1
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
```
Supported additional ingestion types include `.docx`, `.doc`, `.pptx`, `.xlsx`, and OpenDocument formats (`.odt`, `.ods`, `.odp`) in addition to the existing PDF/image OCR path.
## Deploy
From `/mnt/docker-ssd/docker/compose/documents` on PD:
```bash
docker compose --env-file .env config
docker compose --env-file .env down && docker compose --env-file .env up -d
```
Do not restart Docker on PD; use stack-level compose commands only.

View File

@@ -2,7 +2,7 @@ name: documents
services:
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
container_name: paperless
restart: unless-stopped
ports:
@@ -24,6 +24,12 @@ services:
PAPERLESS_TIME_ZONE: America/Chicago
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_URL: https://paperless.paccoco.com
PAPERLESS_TIKA_ENABLED: ${PAPERLESS_TIKA_ENABLED}
PAPERLESS_TIKA_ENDPOINT: ${PAPERLESS_TIKA_ENDPOINT}
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: ${PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
depends_on:
- tika
- gotenberg
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
interval: 30s
@@ -31,9 +37,26 @@ services:
retries: 3
start_period: 60s
networks:
- paperless-internal
- ix-databases_shared-databases
- pangolin
tika:
image: apache/tika:3.1.0.0
container_name: paperless-tika
restart: unless-stopped
mem_limit: 512m
networks:
- paperless-internal
gotenberg:
image: gotenberg/gotenberg:8.17
container_name: paperless-gotenberg
restart: unless-stopped
mem_limit: 256m
networks:
- paperless-internal
karakeep:
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
container_name: karakeep
@@ -50,7 +73,9 @@ services:
NEXTAUTH_SECRET: ${KARAKEEP_NEXTAUTH_SECRET}
MEILI_ADDR: http://karakeep-meilisearch:7700
MEILI_MASTER_KEY: ${KARAKEEP_MEILI_MASTER_KEY}
BROWSER_WEB_URL: http://karakeep-chrome:9222
# Chromium remote debugging now rejects non-local hostname Host headers here,
# so Karakeep must reach Chrome over a stable bridge IP rather than service DNS.
BROWSER_WEB_URL: http://172.16.16.3:9222
DISABLE_SIGNUPS: ${KARAKEEP_DISABLE_SIGNUPS}
DB_WAL_MODE: ${KARAKEEP_DB_WAL_MODE}
LOG_LEVEL: ${KARAKEEP_LOG_LEVEL}
@@ -79,7 +104,8 @@ services:
- --remote-debugging-port=9222
- --hide-scrollbars
networks:
- karakeep
karakeep:
ipv4_address: 172.16.16.3
karakeep-meilisearch:
image: getmeili/meilisearch:v1.41.0
@@ -95,9 +121,18 @@ services:
- karakeep
networks:
paperless-internal:
name: documents_paperless_internal
ix-databases_shared-databases:
external: true
pangolin:
external: true
karakeep:
name: documents_karakeep
# Pin the subnet so karakeep-chrome keeps the IP Karakeep uses for DevTools.
ipam:
config:
- subnet: 172.16.16.0/24
gateway: 172.16.16.1
- subnet: fdd0:0:0:10::/64
gateway: fdd0:0:0:10::1

13
home/doris-dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
logs/
public/index.html
public/style.css.bak.*
bin/generate_dashboard.py.bak.*
data/articles.json
data/dashboard.json
data/homelab.json
data/sky.json
data/todos.json
data/weather.json
data/paperless_review.json
data/paperless_review_state.json

View File

@@ -0,0 +1,255 @@
# Doris Dashboard
Local static homepage for Johns morning briefing and peek-anytime news dashboard.
Live runtime path:
```bash
/opt/doris-dashboard
```
Source path in the homelab repo:
```bash
/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard
```
Generated page:
```bash
/opt/doris-dashboard/public/index.html
```
## What it shows
- Morning briefing with fleshed-out cards
- Today panel for calendar placeholders
- To-do panel for Donetick/to-do placeholders
- Notes panel
- Sky / Space
- Homelab / Smart Home / Ham Radio
- Local news
- Other signal
- Source/candidate stats
## Generate manually
```bash
cd /opt/doris-dashboard
bin/generate_dashboard.py
```
## Preview locally
```bash
cd /opt/doris-dashboard/public
python3 -m http.server 8787
```
Then open:
```text
http://localhost:8787/
```
If viewing from another machine on the LAN, use NOMADs LAN IP instead of localhost and make sure the firewall allows the port.
## Cron refresh
Installed in `fizzlepoof`s crontab:
```cron
# BEGIN DORIS DASHBOARD
DORIS_DASHBOARD_DIR=/opt/doris-dashboard
DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
# Refresh local dashboard after digest collection windows; wrapper enforces Chicago daytime.
5,35 * * * * cd $DORIS_DASHBOARD_DIR && bin/cron_generate_dashboard.sh >> logs/generate.log 2>&1
# END DORIS DASHBOARD
```
Inspect with:
```bash
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
```
## Data inputs
Main news source:
```bash
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/candidates.json
/home/fizzlepoof/.openclaw/workspace/doris-digest/data/preferences.json
```
Local dashboard placeholders:
```bash
data/todos.json
data/calendar.json
data/notes.json
```
These are intentionally simple JSON arrays so calendar/Donetick integrations can be added later without rewriting the page.
## Next integrations to add
1. Donetick tasks
2. Calendar events
3. Weather / severe weather
4. Moon phase / ISS passes / meteor shower calendar
5. Homelab health pulse
6. Article feedback buttons or quick links back to Doris
## Privacy/deployment note
This dashboard is local-only on NOMAD unless John explicitly asks to expose it. Do not publish it externally without asking.
## LAN service
The dashboard is exposed on NOMAD's LAN IP via systemd:
```text
http://10.5.1.16:8787/
```
Service file:
```bash
/etc/systemd/system/doris-dashboard.service
```
Manage it:
```bash
sudo systemctl status doris-dashboard.service
sudo systemctl restart doris-dashboard.service
sudo systemctl stop doris-dashboard.service
```
The service runs as `fizzlepoof`, serves `public/`, binds `0.0.0.0:8787`, and regenerates the page before starting.
At setup time UFW was inactive, so no firewall rule was required.
## Buildout added 2026-05-12
The dashboard generator now builds a richer local homepage directly on NOMAD.
Added sections/data:
- Weather metrics for Clarksville via Open-Meteo (`data/weather.json`)
- Moon phase / sky note calculated locally (`data/sky.json`)
- Homelab pulse checks (`data/homelab.json`): dashboard service, cron, digest freshness, workspace disk
- Article export from Doris Digest candidates (`data/articles.json`)
- Dashboard metadata (`data/dashboard.json`)
The generated page still lives at:
```bash
/opt/doris-dashboard/public/index.html
```
And the LAN service remains:
```text
http://10.5.1.16:8787/
```
Verification commands:
```bash
cd /opt/doris-dashboard
python3 -m py_compile bin/generate_dashboard.py
bin/generate_dashboard.py
curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard'
systemctl is-active doris-dashboard.service
crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p'
```
Notes:
- Weather.gov and NASA API are not used; Open-Meteo worked reliably without an API key.
- Donetick live task import is still not enabled in the dashboard; `data/todos.json` remains the safe placeholder until the exact read-only endpoint is confirmed.
- Raw secrets are not written to dashboard output.
## Compact news layout
2026-05-13: News/article sections were changed from large cards to compact rows so the dashboard can keep plenty of articles without making John scroll forever.
Implementation:
- `generate_dashboard.py` renders article sections with `compact-card` rows by default.
- `public/style.css` contains the compact layout rules.
- Metrics/weather/homelab pulse remain large cards.
## Donetick integration
2026-05-13: The dashboard now imports open Donetick chores read-only during generation.
Implementation notes:
- Secrets remain encrypted in `/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc`.
- `bin/generate_dashboard.py` decrypts secrets at runtime using the local encryption key and logs in with the Doris service account.
- It reads `/api/v1/chores` and `/api/v1/users` only.
- It writes sanitized task data to `data/todos.json` for the dashboard.
- It does not expose tokens, passwords, or raw secrets in generated HTML/JSON.
Verify:
```bash
cd /opt/doris-dashboard
bin/generate_dashboard.py
python3 - <<'PY'
import json
from pathlib import Path
for item in json.loads(Path('data/todos.json').read_text()):
print(item.get('time'), item.get('title'), item.get('assignee'))
PY
```
## Donetick compact board + topic feedback
2026-05-14: The dashboard's Donetick section was reworked to match the tighter PD Dakboard pattern more closely.
Current behavior:
- Donetick renders compact task cards with due cue, task title, label chips, and a short subtitle.
- Tasks are sorted by urgency first (overdue/today/upcoming/no due date), then due time.
- The dashboard injects a pinned Paperless follow-up card into the Donetick area so that cleanup work stays visible.
- News cards now show `👍 / 👎` topic buttons.
- Topic feedback is persisted through the local dashboard server into `../doris-digest/data/feedback.json` with `origin: "dashboard"`.
- `Reset hidden topics` clears both browser state and dashboard-origin feedback entries.
- Paperless review cards now have `Acknowledge` / `Dismiss` actions persisted to `data/paperless_review_state.json`.
- Major dashboard sections are collapsible and remember collapsed state in the browser.
Important note:
- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`.
## Runtime path sanity
The live systemd unit and crontab must point only at:
```bash
/opt/doris-dashboard
```
Do not run the dashboard from an agent workspace. Agent workspaces are for editing and staging; NOMAD host-run services belong under `/opt/<service>`.
## Cron fix — 2026-05-13
The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently.
## Paperless review queue contract
The dashboard displays sanitized Paperless intake triage items written by n8n at `data/paperless_review.json`. Example contract: `data/paperless_review.example.json`.
Expected shape is a JSON array. Each item may include:
- `id`, `title`, `original_filename`, `added`, `archive_url`
- `current.correspondent`, `current.document_type`, `current.tags`
- `triage.summary`, `triage.suggested_title`, `triage.document_type`, `triage.suggested_tags`, `triage.due_date`, `triage.needs_review`, `triage.urgency`, `triage.reason`, `triage.confidence`
- `safety.forced_review`, `safety.eligible_for_safe_apply`, `safety.apply_safe_enabled`
- `queued_at`
Do not write tokens, raw OCR text, API responses, or credentials into this file. It is display-only derived data for local review.

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export DORIS_DASHBOARD_DIR="${DORIS_DASHBOARD_DIR:-$(pwd)}"
export DORIS_DIGEST_DIR="${DORIS_DIGEST_DIR:-/home/fizzlepoof/.openclaw/workspace/doris-digest}"
hour="$(TZ=America/Chicago date +%H)"
if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi
bin/generate_dashboard.py

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
from functools import partial
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PUBLIC = ROOT / 'public'
DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest'))
DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json'
PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json'
HOST = os.environ.get('HOST', '0.0.0.0')
PORT = int(os.environ.get('PORT', '8787'))
def load_json(path: Path, default):
try:
return json.loads(path.read_text()) if path.exists() else default
except Exception:
return default
def save_json(path: Path, value) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2) + '\n')
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, directory=None, **kwargs):
super().__init__(*args, directory=str(PUBLIC), **kwargs)
def do_POST(self):
length = int(self.headers.get('Content-Length', '0') or '0')
raw = self.rfile.read(length) if length else b'{}'
try:
payload = json.loads(raw.decode('utf-8') or '{}')
except Exception:
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'invalid_json'})
if self.path == '/api/topic-feedback':
return self._topic_feedback(payload)
if self.path == '/api/topic-feedback/reset':
return self._topic_reset()
if self.path == '/api/paperless-action':
return self._paperless_action(payload)
return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'})
def _topic_feedback(self, payload: dict):
term = str(payload.get('term', '')).strip()
vote = str(payload.get('vote', '')).strip().lower()
if not term or vote not in {'up', 'down'}:
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
items = load_json(DIGEST_FEEDBACK, [])
items = [x for x in items if not (str(x.get('origin', '')) == 'dashboard' and str(x.get('term', '')).strip().lower() == term.lower())]
items.append({
'term': term,
'vote': vote,
'note': f"Dashboard {'hide' if vote == 'down' else 'prefer'} topic: {term}",
'origin': 'dashboard',
'source': str(payload.get('source', '')).strip(),
'domain': str(payload.get('domain', '')).strip(),
})
save_json(DIGEST_FEEDBACK, items)
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
def _topic_reset(self):
items = load_json(DIGEST_FEEDBACK, [])
items = [x for x in items if str(x.get('origin', '')) != 'dashboard']
save_json(DIGEST_FEEDBACK, items)
return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)})
def _paperless_action(self, payload: dict):
item_id = str(payload.get('id', '')).strip()
action = str(payload.get('action', '')).strip().lower()
if not item_id or action not in {'needs_attention', 'good_to_go'}:
return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'})
state = load_json(PAPERLESS_STATE, {})
current = state.get(item_id, {}) if isinstance(state, dict) else {}
current_status = str(current.get('status') or '').lower()
if action == 'needs_attention':
new_status = 'active' if current_status == 'needs_attention' else 'needs_attention'
else:
new_status = 'good_to_go'
state[item_id] = {'status': new_status}
save_json(PAPERLESS_STATE, state)
return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status})
def _json(self, status: HTTPStatus, payload: dict):
body = json.dumps(payload).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print('%s - - [%s] %s' % (self.address_string(), self.log_date_time_string(), fmt % args))
if __name__ == '__main__':
handler = partial(Handler, directory=str(PUBLIC))
ThreadingHTTPServer((HOST, PORT), handler).serve_forever()

View File

@@ -0,0 +1,599 @@
#!/usr/bin/env python3
from __future__ import annotations
import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
from typing import Any
ROOT=Path(__file__).resolve().parents[1]
DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest'))
PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs'
PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json'
LAT=36.53; LON=-87.36
LOCAL_TZ=ZoneInfo('America/Chicago')
LABELS={
'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'),
'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'),
'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')}
WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'}
LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'}
PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'}
def esc(v:Any)->str: return html.escape(str(v or ''),quote=True)
def load_json(p:Path,d:Any)->Any:
try: return json.loads(p.read_text()) if p.exists() else d
except Exception: return d
def strip_markup(text:str)->str:
text=re.sub(r'<[^>]+>',' ',text or '')
return re.sub(r'\s+',' ',html.unescape(text)).strip()
def parse_time(v:str)->datetime:
try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc)
except Exception: return datetime.now(timezone.utc)
def parse_dt(v:str|None)->datetime|None:
if not v: return None
try: return datetime.fromisoformat(v.replace('Z','+00:00'))
except Exception: return None
def c_to_f(c:float|int|None)->str:
if c is None: return ''
return f"{round(c*9/5+32)}°F"
def kmh_to_mph(k:float|int|None)->str:
if k is None: return ''
return f"{round(k*0.621371)} mph"
def moon_phase(dt:datetime)->dict[str,str]:
# Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days.
known=datetime(2000,1,6,18,14,tzinfo=timezone.utc)
days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400
age=days%29.53058867
illum=(1-math.cos(2*math.pi*age/29.53058867))/2
names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')]
name,emoji='New Moon','🌑'
for limit,n,e in names:
if age<limit: name,emoji=n,e; break
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
def fetch_weather()->dict[str,Any]:
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
'&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature'
'&hourly=temperature_2m,precipitation_probability'
'&daily=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3')
try:
with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r)
cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{})
probs=hourly.get('precipitation_probability') or []
return {'ok':True,'current':cur,'daily':daily,'max_precip':max(probs[:24] or [0]),'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),'source':'Open-Meteo'}
except Exception as e:
return {'ok':False,'error':str(e),'source':'Open-Meteo'}
def decrypt_donetick()->dict[str,Any]|None:
secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc')
keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key')
if not secret.exists() or not keyfile.exists(): return None
try:
res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True)
return json.loads(res.stdout).get('donetick')
except Exception:
return None
def fetch_donetick_todos()->list[dict[str,Any]]:
sec=decrypt_donetick()
if not sec: return []
try:
login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST')
with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token']
headers={'Authorization':'Bearer '+token,'Accept':'application/json'}
req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers)
with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[])
users={}
try:
ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers)
with urllib.request.urlopen(ureq,timeout=12) as r:
users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])}
except Exception: pass
out=[]
for c in chores:
if c.get('status') not in (0,None): continue
due=c.get('nextDueDate') or ''
assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}")
due_title,state,due_local=due_title_and_state(due)
out.append({
'title':c.get('name','Untitled task'),
'time':due[:16].replace('T',' '),
'body':strip_markup(c.get('description','')),
'assignee':assignee,
'priority':priority_num(c.get('priority')) or 0,
'priority_text':priority_text(c.get('priority')) or '',
'due':due,
'due_title':due_title,
'state':state,
'subtitle':subtitle_text(c),
'chips':visible_label_chips(c),
'source':'Donetick',
'id':c.get('id'),
'_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1),
'_sort_missing':0 if due_local else 1,
'_sort_due':due_local.timestamp() if due_local else 99999999999,
'_sort_name':str(c.get('name','')).lower(),
})
out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name','')))
for item in out:
for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'):
item.pop(k,None)
return out[:10]
except Exception:
return []
def priority_text(priority:Any)->str|None:
try:
p=int(priority)
return f'P{p}' if p in (1,2,3,4) else None
except Exception:
return None
def priority_num(priority:Any)->int|None:
try:
p=int(priority)
return p if p in (1,2,3,4) else None
except Exception:
return None
def recurrence_text(task:dict[str,Any])->str|None:
ftype=str(task.get('frequencyType','') or '').strip().lower()
freq=task.get('frequency')
try: freq_num=int(freq) if freq is not None else None
except Exception: freq_num=None
if not ftype: return None
if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days'
if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks'
if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months'
if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years'
if ftype=='once': return 'One-time'
return ftype.capitalize()
def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]:
dt=parse_dt(due_raw)
if not dt: return ('No due date','nodue',None)
local=dt.astimezone(LOCAL_TZ)
now=datetime.now(LOCAL_TZ)
days=(local.date()-now.date()).days
time_text=local.strftime('%-I:%M %p')
if days < 0:
if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local)
return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local)
if days == 0: return (f'Due today • {time_text}','today',local)
if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local)
if days <= 6: return (f"Due {local.strftime('%a')}{time_text}",'upcoming',local)
return (f"Due {local.strftime('%b %-d')}{time_text}",'upcoming',local)
def dedupe_parts(parts:list[str])->list[str]:
seen=set(); out=[]
for p in parts:
if not p: continue
key=p.strip().lower()
if key in seen: continue
seen.add(key); out.append(p)
return out
def subtitle_text(task:dict[str,Any])->str:
parts=[]
labels=task.get('labelsV2') or []
label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')]
prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS]
low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS]
parts.extend(prominent[:3])
ptxt=priority_text(task.get('priority'))
if ptxt: parts.append(ptxt)
rtxt=recurrence_text(task)
if rtxt:
label_keys={x.strip().lower() for x in label_names}
if rtxt.strip().lower() not in label_keys: parts.append(rtxt)
if not prominent and low: parts.extend(low[:1])
return ''.join(dedupe_parts(parts))
def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]:
chips=[]
for lbl in task.get('labelsV2') or []:
name=str(lbl.get('name','')).strip()
if not name or name.lower() in LOW_VALUE_LABELS: continue
color=str(lbl.get('color','')).strip() or '#607d8b'
chips.append({'name':name,'color':color})
return chips[:4]
def run(cmd:list[str])->str:
try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip()
except Exception: return ''
def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]:
usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace')
disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)'
dash=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown'
cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing'
newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc))
age=(datetime.now(timezone.utc)-newest).total_seconds()/3600
return [
{'label':'Dashboard service','value':dash,'state':'ok' if dash=='active' else 'warn'},
{'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'},
{'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'},
{'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'},
]
def score_item(item:dict[str,Any],prefs:dict[str,Any])->float:
score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower()
if item.get('category') in prefs.get('boost_categories',[]): score+=2
if item.get('category') in prefs.get('quiet_categories',[]): score-=3
for kw in prefs.get('boost_keywords',[]):
if kw.lower() in text: score+=1.5
for kw in prefs.get('mute_keywords',[]):
if kw.lower() in text: score-=3
if item.get('source')=='CISA Alerts':
terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])]
if terms and not any(t in text for t in terms): score-=8
return score
def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]:
now=datetime.now(timezone.utc); recent=[]
for i in items:
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
out=[]; seen=set(); sources=Counter()
for i in recent:
url=i.get('url',''); src=i.get('source','')
if url in seen or sources[src]>=2: continue
out.append(i); seen.add(url); sources[src]+=1
if len(out)>=max_items: break
return out
def label(cat:str)->str:
e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}'
def slugify(text:str)->str:
return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section'
def why(i:dict[str,Any])->str:
cat=i.get('category'); src=i.get('source','')
if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.'
if cat=='homelab': return 'Practical self-hosting / infrastructure signal.'
if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.'
if cat=='local': return 'Local or regional relevance.'
if cat=='radio': return 'Ham-radio interest signal.'
if cat=='security': return 'Kept only because it appears practically relevant.'
return f'Interesting signal from {src}.'
def card(i:dict[str,Any], compact:bool=False)->str:
summary=(i.get('summary') or '').strip()[:170 if compact else 300]
when=(i.get('published_at','')[:16].replace('T',' '))
cls='compact-card' if compact else 'card'
why_html='' if compact else f"<div class='why'>Why: {esc(why(i))}</div>"
topic=(i.get('category') or 'other').strip() or 'other'
source=(i.get('source') or '').strip()
domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.')
feedback_html='' if not compact else (
f"<div class='feedback'>"
f"<button type='button' class='feedback-btn' data-feedback='up' aria-pressed='false' title='Prefer more {esc(topic)} stories'>👍</button>"
f"<button type='button' class='feedback-btn' data-feedback='down' aria-pressed='false' title='Less like this — hide this story'>👎</button>"
f"</div>"
)
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str:
body=''.join(card(i,compact=compact) for i in items) if items else f"<p class='empty'>{esc(empty)}</p>"
style='news-list' if compact else 'grid'
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",slugify(title),open_by_default=True)
def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='')->str:
open_attr=' open' if open_by_default else ''
sub_html=f"<p>{esc(sub)}</p>" if sub else ''
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]:
out=list(items or [])
title='Paperless notes/tags audit'
if not any((x.get('title') or '').strip().lower()==title.lower() for x in out):
out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"})
return out
def load_paperless_review()->list[dict[str,Any]]:
p=DATA/'paperless_review.json'
remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json')
ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys')
if remote:
tmp=DATA/'paperless_review.remote.tmp'
try:
cmd=['scp','-q','-o','ConnectTimeout=8']
if ssh_key and Path(ssh_key).exists():
cmd.extend(['-i',ssh_key])
cmd.extend([remote,str(tmp)])
res=subprocess.run(cmd,capture_output=True,text=True,timeout=12)
if res.returncode==0 and tmp.exists(): tmp.replace(p)
elif tmp.exists(): tmp.unlink()
except Exception:
try: tmp.unlink()
except Exception: pass
try: return json.loads(p.read_text())
except Exception: return []
def load_paperless_state()->dict[str,Any]:
return load_json(PAPERLESS_STATE_PATH,{}) or {}
def paperless_review_section()->str:
items=load_paperless_review()
state=load_paperless_state()
if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','<p class="empty">No documents waiting.</p>','paperless-review',open_by_default=False,extra_class='panel')
urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'}
active=[]; needs_attention=[]
for item in items:
status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower()
if status in {'dismissed','good_to_go'}:
continue
if status=='acknowledged':
status='needs_attention'
(needs_attention if status=='needs_attention' else active).append(item)
def render_card(item:dict[str,Any], needs_attention_card:bool=False)->str:
triage=item.get('triage',{}) or {}
current=item.get('current',{}) or {}
urgency=triage.get('urgency','none')
summary=strip_markup(triage.get('summary',''))
reason=strip_markup(triage.get('reason',''))
meta_parts=[x for x in [triage.get('document_type',''), current.get('correspondent','')] if x]
meta=''.join(str(x) for x in meta_parts)
status_class=' needs-attention' if needs_attention_card else ''
primary_label='Clear flag' if needs_attention_card else 'Needs attention'
primary_action='needs_attention'
return f"""<article class='paperless-card {urgency_class.get(urgency,'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
<div class='paperless-head'><div class='meta'><span class='urgency {urgency_class.get(urgency,'ok')}' data-urgency='{esc(urgency)}'>{esc(str(urgency).upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div><div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='{primary_action}'>{primary_label}</button><button type='button' class='paperless-btn good' data-paperless-action='good_to_go'>Good to go</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div></div>
<h4>{esc(item.get('title') or triage.get('suggested_title','Untitled'))}</h4>
{f"<p class='summary'>{esc(summary)}</p>" if summary else ''}
{f"<p class='paperless-meta'>{esc(meta)}</p>" if meta else ''}
{f"<p class='paperless-reason'>{esc(reason)}</p>" if reason else ''}
</article>"""
visible_active=active[:3]
overflow_active=active[3:]
active_cards=''.join(render_card(item,False) for item in visible_active)
flagged_cards=''.join(render_card(item,True) for item in needs_attention[:8])
count=len(active)
flag_count=len(needs_attention)
body=f"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
if overflow_active:
body += f"<details class='paperless-more'><summary>Show {esc(str(len(overflow_active)))} more active items</summary><div class='paperless-grid overflow-grid'>{''.join(render_card(item,False) for item in overflow_active[:8])}</div></details>"
if flag_count:
body += f"<details class='paperless-acked' open><summary>Needs attention <span class='badge'>{esc(str(flag_count))}</span></summary><div class='paperless-grid acked-grid'>{flagged_cards}</div><p class='empty'>Flagged items stay here so I can revisit them later and, if needed, ask you about the issue.</p></details>"
if count+flag_count>3:
body += f"<p class='empty'>Showing {esc(str(min(count,3)))} active up front and keeping the rest tucked away to cut page bloat.</p>"
return collapsible_section(f'📄 Paperless Review ({count} active)',f'{flag_count} flagged for follow-up · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=(count+flag_count)>0,extra_class='panel')
def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str:
if not items:
body=f"<li class='empty'>{esc(empty)}</li>"
else:
rows=[]
for x in items[:8]:
kicker=x.get('time') or x.get('title') or x.get('label')
headline=x.get('title') if x.get('time') else ''
assignee=x.get('assignee') or ''
detail=x.get('body') or x.get('summary') or x.get('value') or ''
rows.append(f"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
body=''.join(rows)
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
if not items:
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
rows=[]
for x in items[:6]:
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" for c in (x.get('chips') or []))
subtitle_text=(x.get('subtitle') or '').strip()
chip_names={str(c.get('name','')).strip().lower() for c in (x.get('chips') or []) if c.get('name')}
subtitle_parts=[]
for part in [p.strip() for p in subtitle_text.split('') if p.strip()]:
if part.lower() in chip_names:
continue
subtitle_parts.append(part)
subtitle=''.join(subtitle_parts)
state=esc(x.get('state') or 'upcoming')
due_style=''
if state=='overdue': due_style=' style="color:#ff6b6b;"'
elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"'
rows.append(f"<article class='task-card {state}'><div class='task-top'><div class='task-state-dot' aria-hidden='true'></div><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(subtitle)}</div>' if subtitle else ''}</article>")
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
def feedback_bootstrap()->str:
return """<script>
(()=>{
const KEY='doris-dashboard-topic-prefs-v2';
const HIDDEN_CARD_KEY='doris-dashboard-hidden-cards-v1';
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"hidden":[]}')}catch(e){return {hidden:[]}}};
const safeHiddenCards=()=>{try{return JSON.parse(localStorage.getItem(HIDDEN_CARD_KEY)||'[]')}catch(e){return []}};
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({hidden:[...new Set(prefs.hidden||[])]}));
const saveHiddenCards=(ids)=>localStorage.setItem(HIDDEN_CARD_KEY, JSON.stringify([...new Set(ids||[])]));
const prefs=safeRead();
let hiddenCards=safeHiddenCards();
const collapsed=safeCollapse();
const cards=[...document.querySelectorAll('[data-topic]')];
const sections=[...document.querySelectorAll('details[data-section-key]')];
const toast=document.createElement('div');
toast.className='dashboard-toast';
toast.hidden=true;
document.body.appendChild(toast);
let lastAction=null;
let toastTimer=null;
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
const cardId=(card)=>card.querySelector('h3 a')?.getAttribute('href')||card.querySelector('h3 a')?.textContent||card.dataset.topic||Math.random().toString(36).slice(2);
cards.forEach(card=>{card.dataset.cardId=cardId(card);});
const setToast=(html)=>{
toast.innerHTML=html;
toast.hidden=false;
clearTimeout(toastTimer);
toastTimer=setTimeout(()=>{toast.hidden=true;},4500);
};
const sync=()=>{
cards.forEach(card=>{
const topic=card.dataset.topic||'other';
const hidden=(prefs.hidden||[]).includes(topic) || hiddenCards.includes(card.dataset.cardId||'');
card.style.display=hidden?'none':'';
card.classList.remove('topic-liked');
card.querySelectorAll('[data-feedback]').forEach(btn=>{
btn.setAttribute('aria-pressed', 'false');
btn.classList.toggle('active-up', false);
btn.classList.toggle('active-down', false);
});
});
const badge=document.querySelector('[data-hidden-topics-count]');
if (badge) badge.textContent=String((prefs.hidden||[]).length);
};
sections.forEach(sec=>{
const key=sec.dataset.sectionKey;
if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
sec.addEventListener('toggle',()=>{
collapsed[key]=!sec.open;
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
});
});
document.addEventListener('click', (ev)=>{
const btn=ev.target.closest('[data-feedback]');
if(!btn) return;
const card=btn.closest('[data-topic]');
if(!card) return;
const topic=card.dataset.topic||'other';
const id=card.dataset.cardId||'';
if(btn.dataset.feedback==='down'){
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
hiddenCards=hiddenCards.filter(x=>x!==id);
hiddenCards.push(id);
saveHiddenCards(hiddenCards);
setToast(`Right — less <strong>${topic}</strong> like this, and this storys off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
} else {
lastAction={prefs:JSON.parse(JSON.stringify(prefs)), hiddenCards:[...hiddenCards]};
hiddenCards=hiddenCards.filter(x=>x!==id);
hiddenCards.push(id);
saveHiddenCards(hiddenCards);
setToast(`Right — Ill look for more <strong>${topic}</strong> like this, and Ive cleared this story off the board. <button type="button" class="toast-action" data-undo-topic>Undo</button>`);
}
save(prefs); sync();
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
});
document.addEventListener('click',(ev)=>{
const undo=ev.target.closest('[data-undo-topic]');
if(!undo || !lastAction) return;
prefs.hidden=[...(lastAction.prefs.hidden||[])];
hiddenCards=[...(lastAction.hiddenCards||[])];
save(prefs); saveHiddenCards(hiddenCards); sync();
setToast('Undid the last topic/story action.');
});
document.addEventListener('click',(ev)=>{
const btn=ev.target.closest('[data-paperless-action]');
if(!btn) return;
const card=btn.closest('[data-paperless-id]');
if(!card) return;
const id=card.dataset.paperlessId;
const action=btn.dataset.paperlessAction;
btn.disabled=true;
postJson('/api/paperless-action',{id,action}).then(()=>{
window.location.reload();
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
});
const reset=document.querySelector('[data-reset-topic-prefs]');
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; hiddenCards=[]; save(prefs); saveHiddenCards(hiddenCards); sync(); setToast('Reset hidden topics and local story hides.'); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
sync();
})();
</script>"""
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
def forecast_panel(weather:dict[str,Any])->str:
if not weather.get('ok'):
return ""
daily=weather.get('daily',{}) or {}
days=daily.get('time') or []
precip=daily.get('precipitation_sum') or []
sunrise=daily.get('sunrise') or []
sunset=daily.get('sunset') or []
cards=[]
for idx, day in enumerate(days[:3]):
try:
label=datetime.fromisoformat(day).strftime('%a')
except Exception:
label=day
rain=precip[idx] if idx < len(precip) else ''
rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '')
set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '')
cards.append(f"""<article class='forecast-card'>
<span class='forecast-day'>{esc(label)}</span>
<strong>{esc(str(rain))} mm rain</strong>
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
</article>""")
if not cards:
return ""
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'forecast',open_by_default=False,extra_class='panel forecast-panel')
def render()->str:
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
for i in selected:
if i.get('url','') in top_urls: continue
cat=i.get('category')
if cat=='astronomy': groups['space'].append(i)
elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i)
elif cat=='local': groups['local'].append(i)
else: groups['signals'].append(i)
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates)
now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates)
if weather['ok']:
cur=weather['current']; daily=weather.get('daily',{})
weather_metrics=''.join([
metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'),
metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','')}%",'right now','weather'),
metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'),
metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'),
metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or [''])[0][-5:]} / {(daily.get('sunset') or [''])[0][-5:]}",'local time','weather')])
else:
weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn')
moon_metrics=''.join([
metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'),
metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')])
pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse)
forecast_html=forecast_panel(weather)
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central · auto-refreshes at :05 / :35</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=False)}
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}</section>",'today-overview',open_by_default=True)}
{forecast_html}
{paperless_review_section()}
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False)}
{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)}
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:5],'No sky signal right now.',compact=True)}
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True)}
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
def main()->int:
PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True)
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos())
generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))}
(DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n')
(DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n')
(DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n')
(DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n')
(DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n')
if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n')
(PUBLIC/'index.html').write_text(render(),encoding='utf-8')
return 0
if __name__=='__main__': raise SystemExit(main())

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,12 @@
[
{
"title": "Dashboard MVP",
"body": "To-do and calendar sections are wired as local JSON placeholders until Donetick/calendar integrations are added.",
"tag": "setup"
},
{
"title": "Paperless notes/tags audit",
"body": "Explicit follow-up: review and correct all Paperless notes and tags, not just school-intake edge cases. Keep this visible until the broader cleanup pass is actually finished.",
"tag": "paperless"
}
]

View File

@@ -0,0 +1,32 @@
[
{
"id": 12345,
"title": "scan_2026_05_12",
"original_filename": "utility_statement_may.pdf",
"added": "2026-05-13T02:14:00Z",
"archive_url": "https://paperless.paccoco.com/documents/12345/details",
"current": {
"correspondent": "CDE Lightband",
"document_type": "Statement",
"tags": ["inbox"]
},
"triage": {
"summary": "Monthly utility bill from CDE Lightband for $142.17 due on 2026-06-05.",
"suggested_title": "CDE Lightband Utility Bill - May 2026",
"document_type": "bill",
"suggested_correspondent": "CDE Lightband",
"suggested_tags": ["paperless-triage", "utility", "bill"],
"due_date": "2026-06-05",
"needs_review": true,
"urgency": "medium",
"reason": "Contains a payment due date; safety gate requires human review.",
"confidence": "high"
},
"safety": {
"forced_review": true,
"eligible_for_safe_apply": false,
"apply_safe_enabled": false
},
"queued_at": "2026-05-13T03:22:00Z"
}
]

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Doris Dashboard local LAN web server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=fizzlepoof
Group=fizzlepoof
Environment=DORIS_DASHBOARD_DIR=/opt/doris-dashboard
Environment=DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
WorkingDirectory=/opt/doris-dashboard
ExecStartPre=/opt/doris-dashboard/bin/generate_dashboard.py
ExecStart=/usr/bin/python3 /opt/doris-dashboard/bin/dashboard_server.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}}
/* Compact article layout: keep the number of stories, reduce scroll debt. */
.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}}
.feedback-btn[aria-pressed='true']{font-weight:700}.feedback-btn.active-up{border-color:rgba(94,231,135,.65);background:rgba(94,231,135,.12);color:#c8ffd9}.feedback-btn.active-down{border-color:rgba(255,107,107,.6);background:rgba(255,107,107,.12);color:#ffd9d9}.dashboard-toast{position:fixed;right:16px;bottom:16px;z-index:1000;max-width:min(420px,calc(100vw - 24px));background:rgba(12,17,27,.96);border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:16px;padding:12px 14px;color:var(--text);box-shadow:0 14px 34px rgba(0,0,0,.35)}.dashboard-toast[hidden]{display:none}.toast-action{margin-left:10px;background:transparent;border:0;color:var(--accent);font:inherit;font-weight:700;cursor:pointer;padding:0}
.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92}
.donetick-panel .task-grid{gap:8px}.task-card{padding:9px 10px;border-radius:14px}.task-top{gap:6px;margin-bottom:3px}.task-state-dot{width:9px;height:9px;border-radius:999px;background:var(--muted);flex:0 0 auto;box-shadow:0 0 0 2px rgba(255,255,255,.03)}.task-card.overdue .task-state-dot{background:var(--warn)}.task-card.today .task-state-dot{background:var(--local)}.task-card.tomorrow .task-state-dot,.task-card.upcoming .task-state-dot{background:#8ecae6}.task-card.nodue .task-state-dot{background:#6c7a90}.task-due{font-size:.76rem;line-height:1.1}.task-name{font-size:.95rem;line-height:1.15;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.task-subtitle{font-size:.74rem;line-height:1.1;opacity:.9;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.task-chips{gap:4px;margin-bottom:4px}.task-chip{padding:2px 7px;font-size:.68rem}
/* Forecast */
.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem}
/* Paperless review queue */
.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:1fr;gap:10px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.needs-attention{box-shadow:0 0 0 1px rgba(255,209,102,.25),0 10px 24px rgba(0,0,0,.22)}.paperless-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.paperless-card h4{margin:.35rem 0;font-size:.96rem;line-height:1.2}.paperless-card .summary{margin:.2rem 0;color:#d7e3f5;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.paperless-meta,.paperless-reason{margin:.2rem 0;color:var(--muted);font-size:.8rem;line-height:1.2}.paperless-card .link{display:inline-block;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:flex-end}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:5px 9px;font:inherit;cursor:pointer}.paperless-btn.good{border-color:rgba(94,231,135,.45);color:#c8ffd9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked,.paperless-more{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary,.paperless-more summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:720px){.paperless-head{display:block}.paperless-actions{justify-content:flex-start;margin-top:8px}}

View File

@@ -0,0 +1,23 @@
TZ=America/Chicago
SCHOOLHOUSE_HOST=0.0.0.0
SCHOOLHOUSE_PORT=8091
SCHOOLHOUSE_BASE_URL=https://schoolhouse.example.com
SESSION_SECRET=changeme
DATABASE_URL=postgresql+psycopg://schoolhouse:changeme@shared-postgres:5432/schoolhouse
PAPERLESS_BASE_URL=https://paperless.example.com
PAPERLESS_API_TOKEN=changeme
N8N_BASE_URL=https://n8n.example.com
SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK=/webhook/school/intake/upload
SCHOOLHOUSE_N8N_RECORDING_WEBHOOK=/webhook/class/upload
D2L_SCRAPER_WORKDIR=/workspace/.openclaw-workspace/school
D2L_SCRAPER_COMMAND=node d2l_scraper_final.js
UPLOAD_TMP_DIR=/data/uploads
SCHOOLHOUSE_STATE_DIR=/data/state
SCHOOLHOUSE_STORAGE_BACKEND=auto
MAX_UPLOAD_MB=1536

6
home/doris-schoolhouse/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
/data/
/uploads/
.venv/

View File

@@ -0,0 +1,68 @@
# Doris Schoolhouse Deployment Notes
## Intended runtime shape on NOMAD
Doris Schoolhouse should run as a standalone NOMAD service from **`/opt/doris-schoolhouse`**.
- **Repo/source path:** `/home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse`
- **Live runtime path:** `/opt/doris-schoolhouse`
- **Persistent app data:** `/opt/doris-schoolhouse/data`
- **Published app port:** `8091`
- **Workspace mount for D2L snapshot input:** `/home/fizzlepoof/.openclaw/workspace:/workspace/.openclaw-workspace:ro`
## Important NOMAD networking note
NOMAD does **not** currently have PD's Docker-only external networks (`pangolin`, `ix-databases_shared-databases`).
For NOMAD, Schoolhouse should:
- publish `8091` on the host
- let Pangolin/Newt target the host port externally
- reach shared Postgres on PD over LAN TCP (`10.5.1.6:5432`)
- reach LiteLLM / Qdrant / Paperless / n8n over their existing LAN/public endpoints
## Pre-deploy checklist
1. Copy the repo source into `/opt/doris-schoolhouse`.
2. Create a real `.env` from `.env.nomad.example`.
3. Replace placeholder values for:
- `SESSION_SECRET`
- `DATABASE_URL` password
- `PAPERLESS_API_TOKEN`
4. Ensure the shared Postgres target database/user exist on PD.
5. Apply `app/db/schema.sql` against the `schoolhouse` database.
6. Ensure `/opt/doris-schoolhouse/data` exists and is writable by Docker.
7. Confirm Pangolin/Newt has a resource forwarding `schoolhouse.paccoco.com` to NOMAD host port `8091` if public access is wanted immediately.
## Current live integration status
### Working
- Assignment intake webhook (`/webhook/school/intake/upload`) succeeded in live smoke testing on 2026-05-15.
- Recording workflow (`/webhook/class/upload`) was repaired live on 2026-05-15 and now returns HTTP 200.
- Direct Whisper transcription endpoint on NOMAD succeeded in live smoke testing.
- LiteLLM `medium` on PD succeeded after its Ollama route was fixed.
## Suggested deploy flow
```bash
sudo mkdir -p /opt/doris-schoolhouse/data
sudo rsync -a --delete --exclude '.env' --exclude 'data/' /home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse/ /opt/doris-schoolhouse/
cd /opt/doris-schoolhouse
sudo cp .env.nomad.example .env
# edit .env
sudo docker compose --env-file .env config
sudo docker compose --env-file .env up -d --build
```
## Post-deploy verification
```bash
curl -fsS http://127.0.0.1:8091/api/health
curl -fsS http://127.0.0.1:8091/api/health/details
curl -fsS http://127.0.0.1:8091/api/dashboard/summary
```
Then verify:
- D2L snapshot counts load
- assignment intake confirms into n8n/Paperless
- recording conversion works inside container
- recording downstream workflow returns success

View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY .env.example ./
COPY README.md ./
COPY bin ./bin
EXPOSE 8091
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8091"]

View File

@@ -0,0 +1,204 @@
# Doris Schoolhouse
Desktop-first local web app for school intake, recordings, D2L sync, and Paperless linkage.
## Purpose
This app gives John a fast local UI to:
- upload assignment turn-ins
- upload class recordings
- confirm suggested metadata
- track assignment status through grading
- sync classes/assignments/grades/comments from D2L
- archive submissions/transcripts/summaries in Paperless
## Source vs runtime
- **Repo source:** `home/doris-schoolhouse`
- **Expected live NOMAD runtime:** `/opt/doris-schoolhouse`
Do not run production from an agent workspace.
## Stack
- FastAPI
- Jinja2 templates
- shared Postgres
- Pangolin network
- existing Paperless and n8n school workflows
- container build now includes Python deps + `ffmpeg` for recording conversion
## Current state
This is an implementation scaffold with:
## Current local persistence
Until the full shared-Postgres integration is wired, the scaffold uses JSON state files for fast local iteration:
- default state dir: `/data/state`
- default upload temp dir: `/data/uploads`
For local host-side testing, override with env vars such as:
```bash
export SCHOOLHOUSE_STATE_DIR=/tmp/doris-schoolhouse-state
export UPLOAD_TMP_DIR=/tmp/doris-schoolhouse-uploads
```
Production target remains shared Postgres plus Paperless/n8n integration.
`SCHOOLHOUSE_STORAGE_BACKEND=auto` prefers Postgres when psycopg and a PostgreSQL DSN are available, and falls back to JSON state for local iteration.
- route stubs
- templates
- schema draft
- compose/env examples
- D2L / Paperless integration placeholders
It is not deployed yet.
## Layout
```text
home/doris-schoolhouse/
├── .env.example
├── docker-compose.yaml
├── requirements.txt
└── app/
├── main.py
├── config.py
├── models.py
├── db/schema.sql
├── routes/
├── services/
├── static/
└── templates/
```
## Environment and integration helpers
Useful helpers now included:
```bash
bin/check-env.sh
bin/apply-postgres-schema.sh
bin/test-assignment-handoff.sh <file> <class_name> [assignment_name]
bin/test-recording-handoff.sh <file> <class_name>
```
These are safe prep helpers. The handoff scripts are dry-run previews; they print the exact curl commands rather than firing them automatically.
## Helper scripts
Local iteration helpers:
```bash
bin/bootstrap-local.sh
bin/run-local.sh
bin/process-recording.sh <recording_id>
```
These default to the JSON backend and `/tmp` paths unless you override the env vars.
## Bootstrap local state quickly
For local iteration without live Postgres:
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse
export SCHOOLHOUSE_STORAGE_BACKEND=json
export SCHOOLHOUSE_STATE_DIR=/tmp/doris-schoolhouse-state
export UPLOAD_TMP_DIR=/tmp/doris-schoolhouse-uploads
PYTHONPATH=. python3 app/scripts/bootstrap_state.py
```
That seeds local state from the latest D2L snapshot.
## Recording worker helper
There is now a local worker entrypoint for WAV → MP3 processing:
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse
export SCHOOLHOUSE_STORAGE_BACKEND=json
export SCHOOLHOUSE_STATE_DIR=/tmp/doris-schoolhouse-state
export UPLOAD_TMP_DIR=/tmp/doris-schoolhouse-uploads
PYTHONPATH=. python3 app/scripts/process_recording.py <recording_id>
```
If `ffmpeg` is not installed, the worker records a clean `blocked` result with the exact conversion command it wanted to run.
## Local validation
```bash
cd /home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse
python3 -m py_compile app/main.py app/config.py app/models.py app/routes/*.py app/services/*.py
python3 - <<'PY'
import yaml
from pathlib import Path
print(yaml.safe_load(Path('docker-compose.yaml').read_text())['services'].keys())
PY
```
## Container build
The source now includes a `Dockerfile` so the app can run with its own dependencies instead of relying on the host.
That image includes:
- Python dependencies from `requirements.txt`
- `ffmpeg`
- `curl` for healthchecks
So even if the host is missing `ffmpeg`, the containerized runtime can still do WAV → MP3 conversion.
## Future deploy shape
Expected live deploy flow on NOMAD:
```bash
sudo mkdir -p /opt/doris-schoolhouse/data
sudo rsync -a --delete --exclude '.env' --exclude 'data/' /home/fizzlepoof/repos/truenas-stacks/home/doris-schoolhouse/ /opt/doris-schoolhouse/
cd /opt/doris-schoolhouse
sudo cp .env.nomad.example .env
docker compose --env-file .env config
docker compose --env-file .env up -d --build
```
Not doing that yet. No live deployment should happen without explicit rollout work.
## Live integration notes
Current verified live behavior:
- `https://n8n.paccoco.com/webhook/school/intake/upload` accepts Doris Schoolhouse multipart assignment uploads and returned a successful live smoke-test response on 2026-05-15.
- `https://n8n.paccoco.com/webhook/class/upload` now succeeds in live smoke testing after upstream workflow and LiteLLM fixes on 2026-05-15.
- Direct Whisper testing against `http://10.5.1.16:8786/v1/audio/transcriptions` succeeded with a tiny MP3.
- Direct LiteLLM testing against `http://10.5.1.6:4000/v1/chat/completions` now succeeds for model `medium` after the PD route fix.
The app now captures webhook HTTP failures as structured responses instead of crashing blindly.
## NOMAD-specific deployment notes
On NOMAD, Schoolhouse should run as a standalone service from `/opt/doris-schoolhouse`.
Unlike PD stacks, this host does **not** currently use the PD Docker external networks for Pangolin or shared databases. The practical deploy model here is:
- publish host port `8091`
- point Pangolin/Newt at that host port
- reach shared Postgres on PD over `10.5.1.6:5432`
Use `.env.nomad.example` as the starting point for the live runtime.
## Deployment references
- `DEPLOYMENT.md` — clean NOMAD rollout notes
- `.env.nomad.example` — compose-oriented runtime example for NOMAD

View File

@@ -0,0 +1,24 @@
from dataclasses import dataclass
import os
@dataclass
class Settings:
schoolhouse_host: str = os.getenv("SCHOOLHOUSE_HOST", "0.0.0.0")
schoolhouse_port: int = int(os.getenv("SCHOOLHOUSE_PORT", "8091"))
schoolhouse_base_url: str = os.getenv("SCHOOLHOUSE_BASE_URL", "http://localhost:8091")
database_url: str = os.getenv("DATABASE_URL", "postgresql+psycopg://schoolhouse:changeme@shared-postgres:5432/schoolhouse")
paperless_base_url: str = os.getenv("PAPERLESS_BASE_URL", "https://paperless.example.com")
paperless_api_token: str = os.getenv("PAPERLESS_API_TOKEN", "changeme")
n8n_base_url: str = os.getenv("N8N_BASE_URL", "https://n8n.example.com")
n8n_assignment_webhook: str = os.getenv("SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK", "/webhook/school/intake/upload")
n8n_recording_webhook: str = os.getenv("SCHOOLHOUSE_N8N_RECORDING_WEBHOOK", "/webhook/class/upload")
d2l_scraper_workdir: str = os.getenv("D2L_SCRAPER_WORKDIR", "/workspace/.openclaw-workspace/school")
d2l_scraper_command: str = os.getenv("D2L_SCRAPER_COMMAND", "node d2l_scraper_final.js")
upload_tmp_dir: str = os.getenv("UPLOAD_TMP_DIR", "/data/uploads")
state_dir: str = os.getenv("SCHOOLHOUSE_STATE_DIR", "/data/state")
storage_backend: str = os.getenv("SCHOOLHOUSE_STORAGE_BACKEND", "auto")
max_upload_mb: int = int(os.getenv("MAX_UPLOAD_MB", "1536"))
settings = Settings()

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS schoolhouse_course (
id BIGSERIAL PRIMARY KEY,
d2l_course_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
code TEXT,
semester TEXT,
instructor_name TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_synced_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS schoolhouse_assignment (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES schoolhouse_course(id) ON DELETE CASCADE,
d2l_assignment_key TEXT,
title TEXT NOT NULL,
category TEXT,
due_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'assigned',
grade_text TEXT,
grade_numeric DOUBLE PRECISION,
professor_comments TEXT,
source TEXT NOT NULL DEFAULT 'd2l',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS schoolhouse_submission (
id BIGSERIAL PRIMARY KEY,
assignment_id BIGINT NOT NULL REFERENCES schoolhouse_assignment(id) ON DELETE CASCADE,
version_label TEXT NOT NULL DEFAULT 'v1',
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
checksum_sha256 TEXT,
submitted_at TIMESTAMPTZ,
suggested_metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
confirmed_metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
paperless_document_id BIGINT,
paperless_title TEXT,
paperless_note_last_hash TEXT,
paperless_note_appended_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS schoolhouse_recording (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES schoolhouse_course(id) ON DELETE CASCADE,
assignment_id BIGINT REFERENCES schoolhouse_assignment(id) ON DELETE SET NULL,
session_date DATE NOT NULL,
original_filename TEXT NOT NULL,
mp3_filename TEXT,
duration_seconds INTEGER,
transcription_status TEXT NOT NULL DEFAULT 'queued',
summary_status TEXT NOT NULL DEFAULT 'queued',
transcript_paperless_document_id BIGINT,
summary_paperless_document_id BIGINT,
recording_paperless_document_id BIGINT,
notes TEXT,
recording_metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS schoolhouse_d2l_sync_run (
id BIGSERIAL PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running',
raw_snapshot_path TEXT,
changes_summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
error_text TEXT
);
CREATE TABLE IF NOT EXISTS schoolhouse_assignment_mapping (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES schoolhouse_course(id) ON DELETE CASCADE,
d2l_grade_item_name TEXT NOT NULL,
normalized_key TEXT NOT NULL,
assignment_id BIGINT NOT NULL REFERENCES schoolhouse_assignment(id) ON DELETE CASCADE,
confidence DOUBLE PRECISION,
mapping_source TEXT NOT NULL DEFAULT 'auto',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (course_id, d2l_grade_item_name)
);
CREATE INDEX IF NOT EXISTS idx_schoolhouse_assignment_course_id ON schoolhouse_assignment(course_id);
CREATE INDEX IF NOT EXISTS idx_schoolhouse_submission_assignment_id ON schoolhouse_submission(assignment_id);
CREATE INDEX IF NOT EXISTS idx_schoolhouse_recording_course_id ON schoolhouse_recording(course_id);
CREATE INDEX IF NOT EXISTS idx_schoolhouse_recording_assignment_id ON schoolhouse_recording(assignment_id);

View File

@@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.routes.api_assignments import router as assignments_router
from app.routes.api_dashboard import router as dashboard_router
from app.routes.api_paperless import router as paperless_router
from app.routes.api_recordings import router as recordings_router
from app.routes.api_sync import router as sync_router
from app.routes.ui import router as ui_router
app = FastAPI(title="Doris Schoolhouse")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(ui_router)
app.include_router(dashboard_router)
app.include_router(assignments_router)
app.include_router(recordings_router)
app.include_router(sync_router)
app.include_router(paperless_router)

View File

@@ -0,0 +1,61 @@
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Literal, Optional
AssignmentStatus = Literal["assigned", "in_progress", "submitted", "graded", "late", "missing"]
UploadStatus = Literal["draft", "uploaded", "confirmed", "failed"]
ProcessingStatus = Literal["queued", "processing", "ready", "failed"]
@dataclass
class Course:
d2l_course_id: str
name: str
id: Optional[int] = None
code: Optional[str] = None
semester: Optional[str] = None
active: bool = True
@dataclass
class Assignment:
course_id: int
title: str
id: Optional[int] = None
d2l_assignment_key: Optional[str] = None
category: Optional[str] = None
due_at: Optional[datetime] = None
status: AssignmentStatus = "assigned"
grade_text: Optional[str] = None
grade_numeric: Optional[float] = None
professor_comments: Optional[str] = None
source: Literal["d2l", "manual", "template"] = "d2l"
@dataclass
class Submission:
assignment_id: int
original_filename: str
mime_type: str
id: Optional[int] = None
version_label: str = "v1"
submitted_at: datetime = field(default_factory=datetime.utcnow)
paperless_document_id: Optional[int] = None
paperless_title: Optional[str] = None
status: UploadStatus = "draft"
@dataclass
class Recording:
course_id: int
session_date: date
original_filename: str
id: Optional[int] = None
assignment_id: Optional[int] = None
mp3_filename: Optional[str] = None
duration_seconds: Optional[int] = None
transcription_status: ProcessingStatus = "queued"
summary_status: ProcessingStatus = "queued"
transcript_paperless_document_id: Optional[int] = None
summary_paperless_document_id: Optional[int] = None

View File

@@ -0,0 +1,148 @@
import hashlib
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from app.config import settings
from app.services.d2l import D2LService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
from app.services.webhooks import WebhookService
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
store = JsonStore()
repo = get_repository()
d2l = D2LService()
paperless = PaperlessService()
webhooks = WebhookService()
@router.get("")
async def list_assignments():
courses = {item["id"]: item for item in d2l.get_courses()}
items = repo.list_assignments()
submissions = repo.list_submissions()
submission_counts = {}
for submission in submissions:
submission_counts[submission.get("assignment_id")] = submission_counts.get(submission.get("assignment_id"), 0) + 1
for item in items:
item["submission_count"] = submission_counts.get(item.get("id"), 0)
item["course"] = courses.get(item.get("course_id"), {})
return {"items": items, "backend": repo.backend}
@router.get('/submissions')
async def list_submissions():
return {"items": repo.list_submissions(), "backend": repo.backend}
@router.post("/intake")
async def intake_assignment(
file: UploadFile,
class_id: str = Form(...),
class_name: str = Form(...),
assignment_name: str = Form(...),
version: str = Form("v1"),
submission_kind: str = Form("assignment_turn_in"),
semester: str = Form(""),
course_code: str = Form(""),
paper_kind: str = Form(""),
source: str = Form("doris-schoolhouse"),
telegram_chat_id: str = Form(""),
telegram_message_id: str = Form(""),
notes: str = Form(""),
):
assignments = d2l.get_assignments(class_id)
matching = next((item for item in assignments if item.get("title") == assignment_name), None)
if not matching:
raise HTTPException(status_code=404, detail="Assignment not found in cached D2L data for selected class.")
upload_root = Path(settings.upload_tmp_dir)
upload_root.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename or "upload.bin").name
submission_id = store.new_id("submission")
stored_path = upload_root / f"{submission_id}-{safe_name}"
payload = await file.read()
stored_path.write_bytes(payload)
checksum = hashlib.sha256(payload).hexdigest()
record = {
"id": submission_id,
"assignment_id": matching["id"],
"class_id": class_id,
"class_name": class_name,
"assignment_name": assignment_name,
"version_label": version,
"submission_kind": submission_kind,
"semester": semester or None,
"course_code": course_code or (course.get("code") if course else None),
"paper_kind": paper_kind or None,
"source": source or "doris-schoolhouse",
"telegram_chat_id": telegram_chat_id or None,
"telegram_message_id": telegram_message_id or None,
"notes": notes,
"original_filename": safe_name,
"stored_path": str(stored_path),
"mime_type": file.content_type or "application/octet-stream",
"file_size_bytes": len(payload),
"checksum_sha256": checksum,
"status": "uploaded-local",
"received_at": store.now(),
}
record["paperless_handoff"] = paperless.assignment_intake_payload(record)
created = repo.create_submission(record)
return {"status": "ok", "submission": created, "backend": repo.backend}
@router.post("/{submission_id}/confirm-paperless")
async def confirm_assignment_paperless(submission_id: str):
submissions = repo.list_submissions()
current = next((item for item in submissions if str(item.get("id")) == str(submission_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Submission not found.")
handoff = paperless.assignment_intake_payload(current)
file_path = current.get("stored_path") or current.get("local_file")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=400, detail="Stored upload file is missing.")
fields = {
"class_name": handoff["class_name"],
"assignment_name": handoff["assignment_name"],
"submission_kind": handoff["submission_kind"],
"notes": handoff.get("notes", ""),
"source": handoff.get("source", "doris-schoolhouse"),
}
for optional_key in ("semester", "course_code", "paper_kind", "telegram_chat_id", "telegram_message_id"):
value = handoff.get(optional_key)
if value:
fields[optional_key] = value
response = webhooks.post_multipart(handoff["target_url"], fields, "document", file_path)
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
updated = repo.update_submission(submission_id, {
"status": "paperless-queued" if ok else "paperless-error",
"confirmed_at": store.now(),
"webhook_response": response,
"paperless_document_id": body_obj.get("paperless_document_id") or body_obj.get("document_id"),
"paperless_title": body_obj.get("paperless_title") or body_obj.get("title") or handoff.get("assignment_name"),
})
return {"status": "ok" if ok else "error", "submission": updated or current, "handoff": handoff, "webhook": response}
@router.post("/{assignment_id}/status")
async def update_assignment_status(assignment_id: str, status: str = Form(...)):
assignments = repo.list_assignments()
updated = None
for item in assignments:
if str(item.get("id")) == str(assignment_id):
item["status"] = status
item["updated_at"] = store.now()
updated = item
break
if not updated:
raise HTTPException(status_code=404, detail="Assignment not found.")
repo.replace_assignments(assignments)
return {"status": "ok", "assignment": updated}

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter
from app.services.d2l import D2LService
from app.services.repository import get_repository
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
repo = get_repository()
d2l = D2LService()
@router.get("/summary")
async def dashboard_summary():
courses = d2l.get_courses()
assignments = repo.list_assignments()
submissions = repo.list_submissions()
recordings = repo.list_recordings()
sync_runs = repo.list_sync_runs()
return {
"backend": repo.backend,
"counts": {
"courses": len(courses),
"assignments": len(assignments),
"submissions": len(submissions),
"recordings": len(recordings),
},
"latest_sync": sync_runs[-1] if sync_runs else None,
"recent_assignments": assignments[:8],
"recent_submissions": submissions[:8],
"recent_recordings": recordings[:8],
}

View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.services.diagnostics import DiagnosticsService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
router = APIRouter(prefix="/api", tags=["paperless", "health"])
service = PaperlessService()
repo = get_repository()
store = JsonStore()
diagnostics = DiagnosticsService()
@router.get("/health")
async def health():
return {"ok": True, "service": "doris-schoolhouse", "state_dir": settings.state_dir, "backend": repo.backend}
@router.get("/health/details")
async def health_details():
return diagnostics.collect()
@router.post("/paperless/webhook/intake-enriched")
async def paperless_intake_enriched(submission_id: str, document_id: int, paperless_title: str | None = None):
updated = repo.update_submission(
submission_id,
{
"status": "paperless-linked",
"paperless_document_id": document_id,
"paperless_title": paperless_title,
"paperless_linked_at": store.now(),
},
)
if not updated:
raise HTTPException(status_code=404, detail="Submission not found.")
return {"status": "ok", "submission": updated}
@router.post("/paperless/webhook/recording-complete")
async def paperless_recording_complete(recording_id: str, transcript_document_id: int | None = None, summary_document_id: int | None = None, recording_document_id: int | None = None):
updated = repo.update_recording(
recording_id,
{
"status": "paperless-linked",
"transcript_paperless_document_id": transcript_document_id,
"summary_paperless_document_id": summary_document_id,
"recording_paperless_document_id": recording_document_id,
"transcription_status": "ready" if transcript_document_id else None,
"summary_status": "ready" if summary_document_id else None,
"paperless_linked_at": store.now(),
},
)
if not updated:
raise HTTPException(status_code=404, detail="Recording not found.")
return {"status": "ok", "recording": updated}
@router.post("/paperless/assignments/{assignment_id}/append-grade-note")
async def append_grade_note(assignment_id: str, execute: bool = False):
if not execute:
return service.describe_grade_note_plan(assignment_id)
try:
return service.append_grade_note(assignment_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@@ -0,0 +1,130 @@
import hashlib
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from app.config import settings
from app.services.d2l import D2LService
from app.services.media import MediaService
from app.services.paperless import PaperlessService
from app.services.repository import get_repository
from app.services.store import JsonStore
from app.services.webhooks import WebhookService
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
store = JsonStore()
repo = get_repository()
d2l = D2LService()
paperless = PaperlessService()
media = MediaService()
webhooks = WebhookService()
@router.get("")
async def list_recordings():
return {"items": repo.list_recordings(), "backend": repo.backend}
@router.post("/intake")
async def intake_recording(
file: UploadFile,
class_id: str = Form(...),
class_name: str = Form(...),
session_date: str = Form(...),
assignment_id: str | None = Form(None),
notes: str = Form(""),
):
course = next((item for item in d2l.get_courses() if str(item.get("d2l_course_id") or item.get("id")) == str(class_id)), None)
if not course:
raise HTTPException(status_code=404, detail="Class not found in cached D2L data.")
upload_root = Path(settings.upload_tmp_dir)
upload_root.mkdir(parents=True, exist_ok=True)
recording_id = store.new_id("recording")
safe_name = Path(file.filename or "recording.wav").name
stored_path = upload_root / f"{recording_id}-{safe_name}"
payload = await file.read()
stored_path.write_bytes(payload)
checksum = hashlib.sha256(payload).hexdigest()
mp3_path = media.planned_mp3_path(str(stored_path))
record = {
"id": recording_id,
"class_id": str(course.get("d2l_course_id") or class_id),
"class_name": class_name,
"assignment_id": assignment_id,
"session_date": session_date,
"notes": notes,
"original_filename": safe_name,
"stored_path": str(stored_path),
"mp3_path": mp3_path,
"conversion_command": media.conversion_command(str(stored_path), mp3_path),
"delete_wav_after_success": True,
"mime_type": file.content_type or "audio/wav",
"file_size_bytes": len(payload),
"checksum_sha256": checksum,
"status": "uploaded-local",
"conversion_status": "queued",
"transcription_status": "queued",
"summary_status": "queued",
"received_at": store.now(),
}
record["paperless_handoff"] = paperless.recording_intake_payload(record)
created = repo.create_recording(record)
return {"status": "ok", "recording": created, "backend": repo.backend}
@router.post("/{recording_id}/reprocess")
async def reprocess_recording(recording_id: str):
recordings = repo.list_recordings()
current = next((item for item in recordings if str(item.get("id")) == str(recording_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Recording not found.")
handoff = paperless.recording_intake_payload(current)
file_path = current.get("mp3_path") or current.get("stored_path") or current.get("local_file")
if not file_path or not Path(file_path).exists():
raise HTTPException(status_code=400, detail="Local recording file is missing.")
fields = {
"class_name": handoff["class_name"],
"lecture_title": handoff["lecture_title"],
"notes": handoff.get("notes", ""),
"source": handoff.get("source", "doris-schoolhouse"),
}
response = webhooks.post_multipart(handoff["target_url"], fields, "data", file_path)
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
updated = repo.update_recording(recording_id, {
"status": "requeued" if ok else "requeue-error",
"requeued_at": store.now(),
"webhook_response": response,
"transcription_status": "processing" if ok else "error",
"summary_status": "processing" if ok else "error",
"recording_paperless_document_id": body_obj.get("recording_paperless_document_id") or body_obj.get("document_id"),
})
return {"status": "ok" if ok else "error", "recording": updated or current, "handoff": handoff, "webhook": response}
@router.post("/{recording_id}/convert")
async def convert_recording(recording_id: str):
recordings = repo.list_recordings()
current = next((item for item in recordings if str(item.get("id")) == str(recording_id)), None)
if not current:
raise HTTPException(status_code=404, detail="Recording not found.")
wav_path = current.get("stored_path")
if not wav_path or not Path(wav_path).exists():
raise HTTPException(status_code=400, detail="Source WAV file is missing.")
mp3_path = current.get("mp3_path") or media.planned_mp3_path(wav_path)
result = media.convert_wav_to_mp3(wav_path, mp3_path)
updates = {
"conversion_status": result["status"],
"conversion_result": result,
"updated_at": store.now(),
}
if result["status"] == "ok":
updates["mp3_path"] = mp3_path
updates["delete_source_result"] = media.delete_source_if_requested(wav_path, bool(current.get("delete_wav_after_success")))
updates["status"] = "converted"
updated = repo.update_recording(recording_id, updates)
return {"status": result["status"], "recording": updated or current, "conversion": result}

View File

@@ -0,0 +1,27 @@
from fastapi import APIRouter
from app.services.d2l import D2LService
router = APIRouter(prefix="/api/d2l", tags=["d2l"])
service = D2LService()
@router.get("/courses")
async def get_courses():
return {"items": service.get_courses()}
@router.get("/assignments")
async def get_course_assignments(course_id: str):
return {"items": service.get_assignments(course_id), "course_id": course_id}
@router.get("/grades")
async def get_course_grades(course_id: str):
return service.get_grades(course_id)
@router.post("/sync")
async def sync_d2l():
return service.sync_to_store()

View File

@@ -0,0 +1,24 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request, "title": "Doris Schoolhouse"})
@router.get("/assignments/upload", response_class=HTMLResponse)
async def assignment_upload(request: Request):
return templates.TemplateResponse("assignment_upload.html", {"request": request, "title": "Assignment Upload"})
@router.get("/recordings/upload", response_class=HTMLResponse)
async def recording_upload(request: Request):
return templates.TemplateResponse("recording_upload.html", {"request": request, "title": "Recording Upload"})

View File

@@ -0,0 +1,31 @@
import os
import sys
from pathlib import Path
def main() -> int:
schema_path = Path(__file__).resolve().parent.parent / 'db' / 'schema.sql'
if not schema_path.exists():
print(f'missing schema file: {schema_path}', file=sys.stderr)
return 1
dsn = os.getenv('DATABASE_URL', '')
if not dsn:
print('DATABASE_URL is not set', file=sys.stderr)
return 1
try:
import psycopg
except Exception:
print('psycopg is not installed; cannot apply schema directly', file=sys.stderr)
return 1
dsn = dsn.replace('postgresql+psycopg://', 'postgresql://')
sql = schema_path.read_text()
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(sql)
conn.commit()
print(f'Applied schema from {schema_path}')
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -0,0 +1,27 @@
from pathlib import Path
from app.config import settings
from app.services.d2l import D2LService
from app.services.store import JsonStore
def main() -> None:
Path(settings.upload_tmp_dir).mkdir(parents=True, exist_ok=True)
Path(settings.state_dir).mkdir(parents=True, exist_ok=True)
store = JsonStore()
for name, default in {
'courses': [],
'assignments': [],
'submissions': [],
'recordings': [],
'sync_runs': [],
}.items():
path = Path(settings.state_dir) / f'{name}.json'
if not path.exists():
store.save(name, default)
result = D2LService().sync_to_store()
print(result)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,44 @@
import argparse
import json
from app.services.media import MediaService
from app.services.repository import get_repository
from app.services.store import JsonStore
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('recording_id')
parser.add_argument('--convert-only', action='store_true')
args = parser.parse_args()
repo = get_repository()
store = JsonStore()
media = MediaService()
recordings = repo.list_recordings()
current = next((item for item in recordings if str(item.get('id')) == str(args.recording_id)), None)
if not current:
raise SystemExit(f'recording not found: {args.recording_id}')
wav_path = current.get('stored_path')
mp3_path = current.get('mp3_path') or media.planned_mp3_path(wav_path)
result = media.convert_wav_to_mp3(wav_path, mp3_path)
updates = {
'conversion_status': result['status'],
'conversion_result': result,
'updated_at': store.now(),
}
if result['status'] == 'ok':
updates['mp3_path'] = mp3_path
delete_result = media.delete_source_if_requested(wav_path, bool(current.get('delete_wav_after_success')))
updates['delete_source_result'] = delete_result
if not args.convert_only:
updates['status'] = 'converted'
repo.update_recording(str(current.get('id')), updates)
print(json.dumps({'recording_id': current.get('id'), 'result': result, 'updates': updates}, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,139 @@
import json
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.repository import get_repository
from app.services.store import JsonStore
class D2LService:
def __init__(self) -> None:
self.store = JsonStore()
self.repo = get_repository()
def _snapshot_path(self) -> Path:
candidate_dirs = [
Path(settings.d2l_scraper_workdir),
Path('/home/fizzlepoof/.openclaw/workspace/school'),
]
for workdir in candidate_dirs:
for name in ('d2l_current_snapshot.json', 'd2l_grades.json', 'd2l_scraper_output.json'):
candidate = workdir / name
if candidate.exists():
return candidate
return candidate_dirs[0] / 'd2l_current_snapshot.json'
def load_snapshot(self) -> dict[str, Any]:
path = self._snapshot_path()
if not path.exists():
return {"courses": [], "source_path": str(path), "missing": True}
data = json.loads(path.read_text())
if isinstance(data, dict):
data["source_path"] = str(path)
return data
return {"courses": [], "source_path": str(path), "missing": True}
def _normalize_courses(self, snapshot: dict[str, Any]) -> list[dict[str, Any]]:
items = []
for course in snapshot.get("courses", []):
items.append({
"id": str(course.get("id")),
"d2l_course_id": str(course.get("id")),
"name": course.get("name"),
"code": course.get("code"),
"finalSummary": course.get("finalSummary"),
"gradeItemCount": course.get("gradeItemCount"),
"synced_at": self.store.now(),
})
return items
def _normalize_assignments(self, snapshot: dict[str, Any]) -> list[dict[str, Any]]:
items = []
for course in snapshot.get("courses", []):
course_id = str(course.get("id"))
for grade_item in course.get("gradeItems", []):
if grade_item.get("isCategory"):
continue
title = grade_item.get("name") or "Untitled"
items.append({
"id": f"{course_id}:{title.lower().strip()}",
"course_id": course_id,
"course_name": course.get("name"),
"title": title,
"points": grade_item.get("points"),
"grade": grade_item.get("grade"),
"comments": grade_item.get("comments"),
"source": "d2l-grade-item",
"synced_at": self.store.now(),
})
return items
def sync_to_store(self) -> dict[str, Any]:
snapshot = self.load_snapshot()
courses = self._normalize_courses(snapshot)
assignments = self._normalize_assignments(snapshot)
existing_assignments = {item.get('id'): item for item in self.repo.list_assignments()}
for item in assignments:
prior = existing_assignments.get(item['id']) or {}
item['status'] = prior.get('status', 'assigned')
item['updated_at'] = self.store.now()
item['created_at'] = prior.get('created_at', self.store.now())
self.repo.replace_courses(courses)
self.repo.replace_assignments(assignments)
sync_runs = self.repo.list_sync_runs()
sync_runs.append({
"id": self.store.new_id("sync"),
"snapshot_path": snapshot.get("source_path"),
"course_count": len(courses),
"assignment_count": len(assignments),
"completed_at": self.store.now(),
"status": "completed",
})
self.repo.save_sync_runs(sync_runs)
return {
"status": "ok",
"snapshot_path": snapshot.get("source_path"),
"course_count": len(courses),
"assignment_count": len(assignments),
"backend": self.repo.backend,
}
def get_courses(self) -> list[dict[str, Any]]:
items = self.repo.list_courses()
if items:
return items
self.sync_to_store()
return self.repo.list_courses()
def get_assignments(self, course_id: str) -> list[dict[str, Any]]:
items = self.repo.list_assignments()
if not items:
self.sync_to_store()
items = self.repo.list_assignments()
return [item for item in items if str(item.get("course_id")) == str(course_id)]
def get_grades(self, course_id: str) -> dict[str, Any]:
snapshot = self.load_snapshot()
for course in snapshot.get("courses", []):
if str(course.get("id")) == str(course_id):
return {
"course_id": str(course.get("id")),
"course_name": course.get("name"),
"final": course.get("final"),
"finalSummary": course.get("finalSummary"),
"gradeItems": course.get("gradeItems", []),
}
return {"course_id": course_id, "gradeItems": []}
def describe_sync_plan(self) -> dict:
snapshot = self.load_snapshot()
return {
"status": "stub",
"backend": self.repo.backend,
"scraper_workdir": settings.d2l_scraper_workdir,
"scraper_command": settings.d2l_scraper_command,
"snapshot_path": snapshot.get("source_path"),
"course_count": len(snapshot.get("courses", [])),
"message": "Run existing D2L scraper, normalize courses/assignments/grades, then store into Schoolhouse persistence.",
}

View File

@@ -0,0 +1,23 @@
from pathlib import Path
from app.config import settings
from app.services.media import MediaService
from app.services.repository import get_repository
class DiagnosticsService:
def collect(self) -> dict:
repo = get_repository()
media = MediaService()
return {
'backend': repo.backend,
'state_dir': settings.state_dir,
'state_dir_exists': Path(settings.state_dir).exists(),
'upload_tmp_dir': settings.upload_tmp_dir,
'upload_tmp_dir_exists': Path(settings.upload_tmp_dir).exists(),
'ffmpeg_path': media.ffmpeg_path(),
'can_convert_audio': media.can_convert(),
'n8n_base_url': settings.n8n_base_url,
'paperless_base_url': settings.paperless_base_url,
'd2l_scraper_workdir': settings.d2l_scraper_workdir,
}

View File

@@ -0,0 +1,57 @@
import shutil
import subprocess
from pathlib import Path
class MediaService:
def planned_mp3_path(self, wav_path: str) -> str:
path = Path(wav_path)
return str(path.with_suffix('.mp3'))
def conversion_command(self, wav_path: str, mp3_path: str) -> list[str]:
return [
'ffmpeg',
'-y',
'-i', wav_path,
'-codec:a', 'libmp3lame',
'-q:a', '2',
mp3_path,
]
def ffmpeg_path(self) -> str | None:
return shutil.which('ffmpeg')
def can_convert(self) -> bool:
return self.ffmpeg_path() is not None
def convert_wav_to_mp3(self, wav_path: str, mp3_path: str) -> dict:
if not self.can_convert():
return {
'status': 'blocked',
'reason': 'ffmpeg-not-installed',
'command': self.conversion_command(wav_path, mp3_path),
}
cmd = self.conversion_command(wav_path, mp3_path)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {
'status': 'failed',
'returncode': result.returncode,
'stderr': result.stderr[-4000:],
'stdout': result.stdout[-2000:],
'command': cmd,
}
return {
'status': 'ok',
'mp3_path': mp3_path,
'command': cmd,
}
def delete_source_if_requested(self, wav_path: str, should_delete: bool) -> dict:
if not should_delete:
return {'status': 'skipped', 'reason': 'delete-not-requested'}
path = Path(wav_path)
if not path.exists():
return {'status': 'skipped', 'reason': 'source-missing'}
path.unlink()
return {'status': 'ok', 'deleted': wav_path}

View File

@@ -0,0 +1,109 @@
import hashlib
from urllib.parse import urljoin
from app.config import settings
from app.services.paperless_client import PaperlessClient
from app.services.repository import get_repository
from app.services.store import JsonStore
class PaperlessService:
def __init__(self) -> None:
self.store = JsonStore()
self.repo = get_repository()
self.client = PaperlessClient()
def assignment_intake_payload(self, submission: dict) -> dict:
return {
"target_url": urljoin(settings.n8n_base_url, settings.n8n_assignment_webhook),
"class_name": submission.get("class_name"),
"assignment_name": submission.get("assignment_name"),
"submission_kind": submission.get("submission_kind") or "assignment_turn_in",
"semester": submission.get("semester"),
"course_code": submission.get("course_code"),
"paper_kind": submission.get("paper_kind"),
"notes": submission.get("notes", ""),
"source": submission.get("source") or "doris-schoolhouse",
"telegram_chat_id": submission.get("telegram_chat_id"),
"telegram_message_id": submission.get("telegram_message_id"),
"local_file": submission.get("stored_path"),
"version": submission.get("version_label"),
}
def recording_intake_payload(self, recording: dict) -> dict:
return {
"target_url": urljoin(settings.n8n_base_url, settings.n8n_recording_webhook),
"class_name": recording.get("class_name"),
"lecture_title": f"{recording.get('class_name')} {recording.get('session_date')}",
"notes": recording.get("notes", ""),
"local_file": recording.get("mp3_path") or recording.get("stored_path"),
"source": "doris-schoolhouse",
}
def build_grade_note(self, assignment_id: str) -> str:
submissions = self.repo.list_submissions()
assignments = {item.get("id"): item for item in self.repo.list_assignments()}
assignment = assignments.get(str(assignment_id), {})
linked = [s for s in submissions if str(s.get("assignment_id")) == str(assignment_id)]
note_lines = [
"Grade sync from Doris Schoolhouse",
f"Assignment: {assignment.get('title', 'unknown')}",
]
if assignment.get("grade"):
note_lines.append(f"Grade: {assignment['grade']}")
if assignment.get("points"):
note_lines.append(f"Points: {assignment['points']}")
if assignment.get("comments"):
note_lines.append(f"Professor comments: {assignment['comments']}")
if linked:
note_lines.append(f"Linked submissions: {len(linked)}")
return "\n".join(note_lines)
def describe_grade_note_plan(self, assignment_id: str) -> dict:
submissions = self.repo.list_submissions()
linked = [s for s in submissions if str(s.get("assignment_id")) == str(assignment_id)]
return {
"status": "stub",
"backend": self.repo.backend,
"assignment_id": assignment_id,
"paperless_base_url": settings.paperless_base_url,
"linked_submission_count": len(linked),
"note_preview": self.build_grade_note(assignment_id),
"message": "Resolve linked Paperless submission document, hash latest grade/comment payload, and append a dated Schoolhouse note if changed.",
}
def append_grade_note(self, assignment_id: str) -> dict:
submissions = self.repo.list_submissions()
linked = [s for s in submissions if str(s.get("assignment_id")) == str(assignment_id)]
if not linked:
raise ValueError("No linked submissions for assignment")
target = next((s for s in linked if s.get("paperless_document_id")), linked[0])
document_id = target.get("paperless_document_id")
if not document_id:
raise ValueError("Linked submission has no Paperless document id yet")
note = self.build_grade_note(assignment_id)
note_hash = hashlib.sha256(note.encode('utf-8')).hexdigest()
if target.get("paperless_note_last_hash") == note_hash:
return {
"status": "skipped",
"reason": "note-unchanged",
"assignment_id": assignment_id,
"document_id": document_id,
"note_hash": note_hash,
}
response = self.client.add_note(document_id, note)
self.repo.update_submission(
str(target.get("id")),
{
"paperless_note_last_hash": note_hash,
"paperless_title": target.get("paperless_title"),
"status": target.get("status", "paperless-linked"),
},
)
return {
"status": "ok",
"assignment_id": assignment_id,
"document_id": document_id,
"note_hash": note_hash,
"paperless_response": response,
}

View File

@@ -0,0 +1,31 @@
import json
from urllib import request
from urllib.parse import urljoin
from app.config import settings
class PaperlessClient:
def __init__(self) -> None:
self.base_url = settings.paperless_base_url.rstrip('/') + '/'
self.token = settings.paperless_api_token
def _req(self, method: str, path: str, payload: dict | None = None) -> dict:
url = urljoin(self.base_url, path.lstrip('/'))
body = None if payload is None else json.dumps(payload).encode('utf-8')
headers = {
'Authorization': f'Token {self.token}',
'Accept': 'application/json',
}
if body is not None:
headers['Content-Type'] = 'application/json'
req = request.Request(url, data=body, headers=headers, method=method)
with request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode('utf-8', errors='replace')
return json.loads(raw) if raw else {}
def get_document(self, document_id: int | str) -> dict:
return self._req('GET', f'/api/documents/{document_id}/')
def add_note(self, document_id: int | str, note: str) -> dict:
return self._req('POST', f'/api/documents/{document_id}/notes/', {'note': note})

View File

@@ -0,0 +1,388 @@
import json
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.store import JsonStore
class JsonRepository:
backend = "json"
def __init__(self) -> None:
self.store = JsonStore()
def list_courses(self) -> list[dict[str, Any]]:
return self.store.load("courses", [])
def replace_courses(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.replace_collection("courses", items)
def list_assignments(self) -> list[dict[str, Any]]:
return self.store.load("assignments", [])
def replace_assignments(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.replace_collection("assignments", items)
def list_sync_runs(self) -> list[dict[str, Any]]:
return self.store.load("sync_runs", [])
def save_sync_runs(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.store.save("sync_runs", items)
def list_submissions(self) -> list[dict[str, Any]]:
return self.store.load("submissions", [])
def create_submission(self, item: dict[str, Any]) -> dict[str, Any]:
return self.store.append_collection("submissions", item)
def update_submission(self, submission_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
return self.store.update_collection_item(
"submissions",
lambda item: str(item.get("id")) == str(submission_id),
lambda item: {**item, **updates},
)
def list_recordings(self) -> list[dict[str, Any]]:
return self.store.load("recordings", [])
def create_recording(self, item: dict[str, Any]) -> dict[str, Any]:
return self.store.append_collection("recordings", item)
def update_recording(self, recording_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
return self.store.update_collection_item(
"recordings",
lambda item: str(item.get("id")) == str(recording_id),
lambda item: {**item, **updates},
)
class PostgresRepository:
backend = "postgres"
def __init__(self) -> None:
import psycopg
from psycopg.rows import dict_row
self._psycopg = psycopg
self._dict_row = dict_row
self.dsn = settings.database_url.replace("postgresql+psycopg://", "postgresql://")
def _connect(self):
return self._psycopg.connect(self.dsn, row_factory=self._dict_row)
def _ensure_course(self, cur, item: dict[str, Any]) -> dict[str, Any] | None:
course_id = item.get("class_id") or item.get("course_id")
if not course_id:
return None
cur.execute("SELECT id, d2l_course_id, name FROM schoolhouse_course WHERE d2l_course_id = %s", (course_id,))
row = cur.fetchone()
if row:
return row
course_name = item.get("class_name") or item.get("course_name") or str(course_id)
cur.execute(
"""
INSERT INTO schoolhouse_course (d2l_course_id, name, last_synced_at)
VALUES (%s, %s, NOW())
RETURNING id, d2l_course_id, name
""",
(course_id, course_name),
)
return cur.fetchone()
def _ensure_assignment(self, cur, item: dict[str, Any]) -> dict[str, Any] | None:
course = self._ensure_course(cur, item)
if not course:
return None
assignment_id = item.get("assignment_id")
assignment_name = item.get("assignment_name") or item.get("title")
if assignment_id:
cur.execute(
"SELECT id, title FROM schoolhouse_assignment WHERE course_id = %s AND d2l_assignment_key = %s LIMIT 1",
(course["id"], assignment_id),
)
row = cur.fetchone()
if row:
return row
if assignment_name:
cur.execute(
"SELECT id, title FROM schoolhouse_assignment WHERE course_id = %s AND title = %s LIMIT 1",
(course["id"], assignment_name),
)
row = cur.fetchone()
if row:
return row
cur.execute(
"""
INSERT INTO schoolhouse_assignment (course_id, d2l_assignment_key, title, source, status, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
RETURNING id, title
""",
(course["id"], assignment_id, assignment_name, item.get("source", "schoolhouse-intake"), item.get("status", "assigned")),
)
return cur.fetchone()
return None
def list_courses(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT id::text AS id, d2l_course_id, name, code, semester, last_synced_at FROM schoolhouse_course ORDER BY name")
return list(cur.fetchall())
def replace_courses(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
for item in items:
cur.execute(
"""
INSERT INTO schoolhouse_course (d2l_course_id, name, code, semester, last_synced_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (d2l_course_id)
DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code, semester = EXCLUDED.semester, last_synced_at = NOW()
""",
(item.get("d2l_course_id"), item.get("name"), item.get("code"), item.get("semester")),
)
conn.commit()
return items
def list_assignments(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT a.id::text AS id,
c.d2l_course_id AS course_id,
c.name AS course_name,
a.title,
a.grade_text AS grade,
a.professor_comments AS comments,
a.status,
a.updated_at
FROM schoolhouse_assignment a
JOIN schoolhouse_course c ON c.id = a.course_id
ORDER BY c.name, a.title
"""
)
return list(cur.fetchall())
def replace_assignments(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
for item in items:
cur.execute("SELECT id FROM schoolhouse_course WHERE d2l_course_id = %s", (item.get("course_id"),))
row = cur.fetchone()
if not row:
continue
course_pk = row["id"]
cur.execute(
"""
INSERT INTO schoolhouse_assignment (course_id, d2l_assignment_key, title, grade_text, professor_comments, source, status, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
ON CONFLICT DO NOTHING
""",
(course_pk, item.get("id"), item.get("title"), item.get("grade"), item.get("comments"), item.get("source", "d2l"), item.get("status", "assigned")),
)
cur.execute(
"""
UPDATE schoolhouse_assignment
SET grade_text = %s,
professor_comments = %s,
status = COALESCE(status, %s),
updated_at = NOW()
WHERE course_id = %s AND title = %s
""",
(item.get("grade"), item.get("comments"), item.get("status", "assigned"), course_pk, item.get("title")),
)
conn.commit()
return items
def list_sync_runs(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT id::text AS id, started_at, completed_at, status, raw_snapshot_path AS snapshot_path, changes_summary_json FROM schoolhouse_d2l_sync_run ORDER BY started_at DESC")
return list(cur.fetchall())
def save_sync_runs(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not items:
return items
last = items[-1]
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO schoolhouse_d2l_sync_run (completed_at, status, raw_snapshot_path, changes_summary_json) VALUES (NOW(), %s, %s, %s::jsonb)",
(last.get("status", "completed"), last.get("snapshot_path"), json.dumps(last)),
)
conn.commit()
return items
def list_submissions(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT s.id::text AS id,
a.id::text AS assignment_id,
c.d2l_course_id AS class_id,
c.name AS class_name,
a.title AS assignment_name,
s.version_label,
s.original_filename,
s.mime_type,
s.status,
s.paperless_document_id,
s.paperless_title,
s.paperless_note_last_hash,
s.submitted_at,
s.confirmed_metadata_json,
s.confirmed_metadata_json->>'stored_path' AS stored_path,
s.confirmed_metadata_json->>'local_file' AS local_file
FROM schoolhouse_submission s
JOIN schoolhouse_assignment a ON a.id = s.assignment_id
JOIN schoolhouse_course c ON c.id = a.course_id
ORDER BY s.created_at DESC
"""
)
return list(cur.fetchall())
def create_submission(self, item: dict[str, Any]) -> dict[str, Any]:
with self._connect() as conn, conn.cursor() as cur:
row = self._ensure_assignment(cur, item)
if not row:
raise ValueError("Assignment not found and could not be created in Postgres repository")
cur.execute(
"""
INSERT INTO schoolhouse_submission (
assignment_id, version_label, original_filename, mime_type, checksum_sha256,
submitted_at, suggested_metadata_json, confirmed_metadata_json, status
) VALUES (%s, %s, %s, %s, %s, NOW(), %s::jsonb, %s::jsonb, %s)
RETURNING id::text AS id
""",
(
row["id"], item.get("version_label") or "v1", item.get("original_filename"), item.get("mime_type", "application/octet-stream"), item.get("checksum_sha256"),
json.dumps(item.get("paperless_handoff", {})), json.dumps(item), item.get("status", "uploaded-local"),
),
)
created = cur.fetchone()
conn.commit()
item["id"] = created["id"]
return item
def update_submission(self, submission_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT confirmed_metadata_json FROM schoolhouse_submission WHERE id = %s::bigint", (submission_id,))
current = cur.fetchone()
if not current:
return None
merged_metadata = {**(current.get("confirmed_metadata_json") or {}), **updates}
cur.execute(
"""
UPDATE schoolhouse_submission
SET status = COALESCE(%s, status),
paperless_document_id = COALESCE(%s, paperless_document_id),
paperless_title = COALESCE(%s, paperless_title),
paperless_note_last_hash = COALESCE(%s, paperless_note_last_hash),
paperless_note_appended_at = CASE WHEN %s::text IS NOT NULL THEN NOW() ELSE paperless_note_appended_at END,
confirmed_metadata_json = %s::jsonb,
updated_at = NOW()
WHERE id = %s::bigint
RETURNING id::text AS id, status, paperless_document_id, paperless_title, paperless_note_last_hash
""",
(
updates.get("status"), updates.get("paperless_document_id"), updates.get("paperless_title"), updates.get("paperless_note_last_hash"), updates.get("paperless_note_last_hash"),
json.dumps(merged_metadata), submission_id,
),
)
row = cur.fetchone()
conn.commit()
return {**merged_metadata, **dict(row)} if row else None
def list_recordings(self) -> list[dict[str, Any]]:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT r.id::text AS id,
c.d2l_course_id AS class_id,
c.name AS class_name,
r.assignment_id::text AS assignment_id,
r.course_id::text AS course_id,
r.session_date::text AS session_date,
r.original_filename,
r.mp3_filename,
r.transcription_status,
r.summary_status,
r.recording_paperless_document_id,
r.recording_metadata_json,
r.recording_metadata_json->>'stored_path' AS stored_path,
r.recording_metadata_json->>'mp3_path' AS mp3_path,
r.recording_metadata_json->>'status' AS status,
r.recording_metadata_json->>'conversion_status' AS conversion_status,
r.recording_metadata_json->>'notes' AS notes,
r.recording_metadata_json->>'delete_wav_after_success' AS delete_wav_after_success
FROM schoolhouse_recording r
JOIN schoolhouse_course c ON c.id = r.course_id
ORDER BY r.created_at DESC
"""
)
return list(cur.fetchall())
def create_recording(self, item: dict[str, Any]) -> dict[str, Any]:
with self._connect() as conn, conn.cursor() as cur:
row = self._ensure_course(cur, item)
if not row:
raise ValueError("Course not found and could not be created in Postgres repository")
cur.execute(
"""
INSERT INTO schoolhouse_recording (
course_id, assignment_id, session_date, original_filename, mp3_filename, notes,
transcription_status, summary_status, recording_metadata_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id::text AS id
""",
(
row["id"], item.get("assignment_id") or None, item.get("session_date"), item.get("original_filename"),
Path(item.get("mp3_path")).name if item.get("mp3_path") else None, item.get("notes"), item.get("transcription_status"),
item.get("summary_status"), json.dumps(item),
),
)
created = cur.fetchone()
conn.commit()
item["id"] = created["id"]
return item
def update_recording(self, recording_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
with self._connect() as conn, conn.cursor() as cur:
cur.execute("SELECT recording_metadata_json FROM schoolhouse_recording WHERE id = %s::bigint", (recording_id,))
current = cur.fetchone()
if not current:
return None
merged_metadata = {**(current.get("recording_metadata_json") or {}), **updates}
cur.execute(
"""
UPDATE schoolhouse_recording
SET transcription_status = COALESCE(%s, transcription_status),
summary_status = COALESCE(%s, summary_status),
transcript_paperless_document_id = COALESCE(%s, transcript_paperless_document_id),
summary_paperless_document_id = COALESCE(%s, summary_paperless_document_id),
recording_paperless_document_id = COALESCE(%s, recording_paperless_document_id),
mp3_filename = COALESCE(%s, mp3_filename),
notes = COALESCE(%s, notes),
recording_metadata_json = %s::jsonb,
updated_at = NOW()
WHERE id = %s::bigint
RETURNING id::text AS id, transcription_status, summary_status, recording_paperless_document_id
""",
(
updates.get("transcription_status"), updates.get("summary_status"), updates.get("transcript_paperless_document_id"),
updates.get("summary_paperless_document_id"), updates.get("recording_paperless_document_id"),
Path(updates.get("mp3_path")).name if updates.get("mp3_path") else None,
updates.get("notes"), json.dumps(merged_metadata), recording_id,
),
)
row = cur.fetchone()
conn.commit()
return {**merged_metadata, **dict(row)} if row else None
def get_repository():
backend = getattr(settings, "storage_backend", "auto")
if backend == "json":
return JsonRepository()
if settings.database_url.startswith("postgresql"):
try:
return PostgresRepository()
except Exception:
return JsonRepository()
return JsonRepository()

View File

@@ -0,0 +1,53 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.config import settings
class JsonStore:
def __init__(self) -> None:
self.root = Path(settings.state_dir)
self.root.mkdir(parents=True, exist_ok=True)
def _path(self, name: str) -> Path:
return self.root / f"{name}.json"
def load(self, name: str, default: Any) -> Any:
path = self._path(name)
if not path.exists():
return default
return json.loads(path.read_text())
def save(self, name: str, value: Any) -> Any:
path = self._path(name)
path.write_text(json.dumps(value, indent=2, sort_keys=True))
return value
def replace_collection(self, name: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return self.save(name, items)
def append_collection(self, name: str, item: dict[str, Any]) -> dict[str, Any]:
items = self.load(name, [])
items.append(item)
self.save(name, items)
return item
def update_collection_item(self, name: str, predicate, updater):
items = self.load(name, [])
updated = None
for idx, item in enumerate(items):
if predicate(item):
items[idx] = updater(dict(item))
updated = items[idx]
break
self.save(name, items)
return updated
def now(self) -> str:
return datetime.now(timezone.utc).isoformat()
def new_id(self, prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:12]}"

View File

@@ -0,0 +1,52 @@
import json
import mimetypes
import uuid
from pathlib import Path
from urllib import error, request
class WebhookService:
def _multipart_body(self, fields: dict[str, str], file_field: str, file_path: str) -> tuple[bytes, str]:
boundary = f"----DorisSchoolhouse{uuid.uuid4().hex}"
parts: list[bytes] = []
for key, value in fields.items():
parts.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
str(value).encode(),
b"\r\n",
])
path = Path(file_path)
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
parts.extend([
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
])
return b"".join(parts), boundary
def post_multipart(self, url: str, fields: dict[str, str], file_field: str, file_path: str) -> dict:
body, boundary = self._multipart_body(fields, file_field, file_path)
req = request.Request(
url,
data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
def parse_raw(raw: str):
try:
return json.loads(raw)
except Exception:
return {"raw": raw}
try:
with request.urlopen(req, timeout=120) as response:
raw = response.read().decode("utf-8", errors="replace")
return {"ok": True, "status_code": response.status, "body": parse_raw(raw)}
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return {"ok": False, "status_code": exc.code, "body": parse_raw(raw), "error": str(exc)}

View File

@@ -0,0 +1,60 @@
:root {
color-scheme: dark;
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--line: #334155;
--text: #e5e7eb;
--muted: #94a3b8;
--accent: #38bdf8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: linear-gradient(180deg, #020617, var(--bg));
color: var(--text);
}
.site-header, .page-shell { max-width: 1200px; margin: 0 auto; padding: 24px; }
.site-header { display: flex; justify-content: space-between; gap: 24px; align-items: end; }
.site-header p { color: var(--muted); margin: 6px 0 0; }
nav { display: flex; gap: 16px; flex-wrap: wrap; }
nav a { color: var(--accent); text-decoration: none; }
.hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.card {
background: rgba(17, 24, 39, 0.92);
border: 1px solid var(--line);
border-radius: 16px;
padding: 20px;
}
.form-card { max-width: 760px; }
.stack { display: grid; gap: 14px; }
label { display: grid; gap: 8px; color: var(--muted); }
input, textarea, button {
font: inherit;
border-radius: 10px;
border: 1px solid var(--line);
padding: 12px 14px;
}
input, textarea { background: var(--panel-2); color: var(--text); }
button {
background: var(--accent);
color: #082f49;
font-weight: 700;
cursor: pointer;
}
.muted { color: var(--muted); }
.stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 12px 0 16px; }
.stat-grid div { background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; display: grid; gap: 8px; }
.stat-grid span { font-size: 1.5rem; font-weight: 700; color: var(--text); }
.three-up { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-top: 20px; }
.list-block { display: grid; gap: 10px; }
.list-item { padding: 10px 12px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; }
.list-item strong { display: block; margin-bottom: 6px; }
.list-item small { color: var(--muted); display: block; }

View File

@@ -0,0 +1,127 @@
async function fetchJson(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`Fetch failed: ${url}`);
return await res.json();
}
function option(value, label) {
const el = document.createElement('option');
el.value = value;
el.textContent = label;
return el;
}
function renderList(targetId, items, formatter) {
const target = document.getElementById(targetId);
if (!target) return;
if (!items.length) {
target.textContent = 'Nothing here yet.';
target.classList.add('muted');
return;
}
target.classList.remove('muted');
target.innerHTML = '';
for (const item of items) {
const wrapper = document.createElement('div');
wrapper.className = 'list-item';
wrapper.innerHTML = formatter(item);
target.appendChild(wrapper);
}
}
async function populateCourses() {
const data = await fetchJson('/api/d2l/courses');
const courseSelect = document.getElementById('class-select');
const courseNameInput = document.getElementById('class-name-input');
const recordingSelect = document.getElementById('recording-class-select');
const recordingNameInput = document.getElementById('recording-class-name-input');
const courses = data.items || [];
const byId = new Map(courses.map((course) => [String(course.d2l_course_id || course.id), course]));
if (courseSelect) {
courseSelect.innerHTML = '';
courseSelect.appendChild(option('', 'Choose a class'));
for (const course of courses) {
courseSelect.appendChild(option(course.d2l_course_id || course.id, `${course.name}${course.finalSummary ? ' — ' + course.finalSummary : ''}`));
}
courseSelect.addEventListener('change', async (event) => {
const courseId = event.target.value;
const course = byId.get(String(courseId));
if (courseNameInput) courseNameInput.value = course?.name || '';
const assignmentSelect = document.getElementById('assignment-select');
assignmentSelect.innerHTML = '';
if (!courseId) {
assignmentSelect.appendChild(option('', 'Pick a class first'));
return;
}
assignmentSelect.appendChild(option('', 'Loading assignments…'));
const assignmentData = await fetchJson(`/api/d2l/assignments?course_id=${encodeURIComponent(courseId)}`);
assignmentSelect.innerHTML = '';
assignmentSelect.appendChild(option('', 'Choose an assignment'));
for (const assignment of assignmentData.items || []) {
assignmentSelect.appendChild(option(assignment.title, assignment.title));
}
});
}
if (recordingSelect) {
recordingSelect.innerHTML = '';
recordingSelect.appendChild(option('', 'Choose a class'));
for (const course of courses) {
recordingSelect.appendChild(option(course.d2l_course_id || course.id, course.name));
}
recordingSelect.addEventListener('change', (event) => {
const course = byId.get(String(event.target.value));
if (recordingNameInput) recordingNameInput.value = course?.name || '';
});
}
}
async function loadDashboard() {
const data = await fetchJson('/api/dashboard/summary');
const counts = data.counts || {};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
setText('stat-courses', counts.courses ?? '0');
setText('stat-assignments', counts.assignments ?? '0');
setText('stat-submissions', counts.submissions ?? '0');
setText('stat-recordings', counts.recordings ?? '0');
setText('backend-note', `Storage backend: ${data.backend}`);
if (data.latest_sync) {
setText('sync-status', `Last sync: ${data.latest_sync.completed_at || 'unknown'} · ${data.latest_sync.assignment_count || 0} assignment rows`);
}
renderList('recent-assignments', data.recent_assignments || [], (item) => `<strong>${item.title || 'Untitled'}</strong><small>${item.course_name || ''}</small><small>Status: ${item.status || 'assigned'} · Grade: ${item.grade || '—'}</small>`);
renderList('recent-submissions', data.recent_submissions || [], (item) => `<strong>${item.assignment_name || item.original_filename || 'Submission'}</strong><small>${item.class_name || ''}</small><small>Status: ${item.status || 'unknown'} · File: ${item.original_filename || '—'}</small>`);
renderList('recent-recordings', data.recent_recordings || [], (item) => `<strong>${item.original_filename || 'Recording'}</strong><small>${item.session_date || ''}</small><small>Transcription: ${item.transcription_status || '—'} · Summary: ${item.summary_status || '—'}</small>`);
}
async function wireSyncButton() {
const button = document.getElementById('sync-button');
if (!button) return;
button.addEventListener('click', async () => {
button.disabled = true;
button.textContent = 'Refreshing…';
try {
const result = await fetchJson('/api/d2l/sync', { method: 'POST' });
const el = document.getElementById('sync-status');
if (el) el.textContent = `Last sync: just now · ${result.assignment_count} assignment rows · backend ${result.backend}`;
await loadDashboard();
} catch (error) {
const el = document.getElementById('sync-status');
if (el) el.textContent = `Sync failed: ${error}`;
} finally {
button.disabled = false;
button.textContent = 'Refresh from D2L';
}
});
}
populateCourses().catch((error) => {
console.error('Failed to populate D2L selectors', error);
});
loadDashboard().catch((error) => {
console.error('Failed to load dashboard summary', error);
});
wireSyncButton();

View File

@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<section class="card form-card">
<h2>Assignment Upload</h2>
<form class="stack" method="post" enctype="multipart/form-data" action="/api/assignments/intake" id="assignment-upload-form">
<label>Class
<select name="class_id" id="class-select" required>
<option value="">Loading classes…</option>
</select>
<input type="hidden" name="class_name" id="class-name-input">
</label>
<label>Assignment
<select name="assignment_name" id="assignment-select" required>
<option value="">Pick a class first</option>
</select>
</label>
<label>Version <input name="version" value="v1"></label>
<label>Notes <textarea name="notes" rows="4" placeholder="Anything useful about this turn-in."></textarea></label>
<label>File <input type="file" name="file" required></label>
<button type="submit">Upload for metadata confirmation</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Schoolhouse' }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="site-header">
<div>
<h1>Doris Schoolhouse</h1>
<p>School intake, recordings, D2L sync, and Paperless linkage.</p>
</div>
<nav>
<a href="/">Dashboard</a>
<a href="/assignments/upload">Assignment Upload</a>
<a href="/recordings/upload">Recording Upload</a>
</nav>
</header>
<main class="page-shell">
{% block content %}{% endblock %}
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero">
<div class="card">
<h2>Overview</h2>
<div class="stat-grid" id="summary-stats">
<div><strong>Courses</strong><span id="stat-courses"></span></div>
<div><strong>Assignments</strong><span id="stat-assignments"></span></div>
<div><strong>Submissions</strong><span id="stat-submissions"></span></div>
<div><strong>Recordings</strong><span id="stat-recordings"></span></div>
</div>
<p class="muted" id="backend-note">Loading backend…</p>
<button id="sync-button" type="button">Refresh from D2L</button>
<p class="muted" id="sync-status">No sync run loaded yet.</p>
</div>
<div class="card">
<h2>What this app currently does</h2>
<ul>
<li>Reads current D2L scrape data</li>
<li>Caches course and assignment rows</li>
<li>Stores assignment upload intake records</li>
<li>Stores recording upload intake records</li>
<li>Builds Paperless/n8n handoff payloads</li>
</ul>
</div>
</section>
<section class="three-up">
<div class="card">
<h2>Recent assignments</h2>
<div id="recent-assignments" class="list-block muted">Loading…</div>
</div>
<div class="card">
<h2>Recent submissions</h2>
<div id="recent-submissions" class="list-block muted">Loading…</div>
</div>
<div class="card">
<h2>Recent recordings</h2>
<div id="recent-recordings" class="list-block muted">Loading…</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block content %}
<section class="card form-card">
<h2>Recording Upload</h2>
<form class="stack" method="post" enctype="multipart/form-data" action="/api/recordings/intake">
<label>Class
<select name="class_id" id="recording-class-select" required>
<option value="">Loading classes…</option>
</select>
<input type="hidden" name="class_name" id="recording-class-name-input">
</label>
<label>Session date <input type="date" name="session_date" required></label>
<label>Related assignment ID (optional) <input type="text" name="assignment_id"></label>
<label>Notes <textarea name="notes" rows="4" placeholder="Lecture, lab, exam review, etc."></textarea></label>
<label>WAV file <input type="file" name="file" accept="audio/wav,.wav" required></label>
<button type="submit">Upload and queue conversion</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
SCHEMA_FILE="app/db/schema.sql"
if [ ! -f "$SCHEMA_FILE" ]; then
echo "schema file missing: $SCHEMA_FILE" >&2
exit 1
fi
if [ -n "${DATABASE_URL:-}" ] && command -v python3 >/dev/null 2>&1; then
export PYTHONPATH=.
python3 app/scripts/apply_schema.py
exit $?
fi
cat >&2 <<'MSG'
Cannot apply schema automatically.
Requirements:
- DATABASE_URL must be set
- python3 must be available
- psycopg must be installed for direct apply
Alternative: run the SQL in app/db/schema.sql manually against the schoolhouse database.
MSG
exit 1

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export SCHOOLHOUSE_STORAGE_BACKEND="${SCHOOLHOUSE_STORAGE_BACKEND:-json}"
export SCHOOLHOUSE_STATE_DIR="${SCHOOLHOUSE_STATE_DIR:-/tmp/doris-schoolhouse-state}"
export UPLOAD_TMP_DIR="${UPLOAD_TMP_DIR:-/tmp/doris-schoolhouse-uploads}"
export PYTHONPATH=.
python3 app/scripts/bootstrap_state.py

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
have() { command -v "$1" >/dev/null 2>&1; }
printf 'Doris Schoolhouse environment check\n'
printf '================================\n'
for cmd in python3 ffmpeg psql docker curl; do
if have "$cmd"; then
printf '%-10s %s\n' "$cmd" "$(command -v "$cmd")"
else
printf '%-10s %s\n' "$cmd" "MISSING"
fi
done
python3 - <<'PY'
import importlib.util
mods = ['fastapi', 'uvicorn', 'psycopg']
for mod in mods:
print(f'{mod:10} ', 'installed' if importlib.util.find_spec(mod) else 'MISSING')
PY

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 1 ]; then
echo "usage: $0 <recording_id>" >&2
exit 1
fi
cd "$(dirname "$0")/.."
export SCHOOLHOUSE_STORAGE_BACKEND="${SCHOOLHOUSE_STORAGE_BACKEND:-json}"
export SCHOOLHOUSE_STATE_DIR="${SCHOOLHOUSE_STATE_DIR:-/tmp/doris-schoolhouse-state}"
export UPLOAD_TMP_DIR="${UPLOAD_TMP_DIR:-/tmp/doris-schoolhouse-uploads}"
export PYTHONPATH=.
python3 app/scripts/process_recording.py "$1"

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export SCHOOLHOUSE_STORAGE_BACKEND="${SCHOOLHOUSE_STORAGE_BACKEND:-json}"
export SCHOOLHOUSE_STATE_DIR="${SCHOOLHOUSE_STATE_DIR:-/tmp/doris-schoolhouse-state}"
export UPLOAD_TMP_DIR="${UPLOAD_TMP_DIR:-/tmp/doris-schoolhouse-uploads}"
export PYTHONPATH=.
uvicorn app.main:app --host 0.0.0.0 --port "${SCHOOLHOUSE_PORT:-8091}"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "usage: $0 <file> <class_name> [assignment_name]" >&2
exit 1
fi
FILE="$1"
CLASS_NAME="$2"
ASSIGNMENT_NAME="${3:-Sample Assignment}"
TARGET="${N8N_BASE_URL:-https://n8n.example.com}${SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK:-/webhook/school/intake/upload}"
cat <<MSG
Dry-run assignment handoff preview
Target: $TARGET
Command:
curl -X POST "$TARGET" \\
-F "document=@$FILE" \\
-F "class_name=$CLASS_NAME" \\
-F "assignment_name=$ASSIGNMENT_NAME" \\
-F "submission_kind=assignment_turn_in" \\
-F "source=doris-schoolhouse"
MSG

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "usage: $0 <file> <class_name>" >&2
exit 1
fi
FILE="$1"
CLASS_NAME="$2"
TARGET="${N8N_BASE_URL:-https://n8n.example.com}${SCHOOLHOUSE_N8N_RECORDING_WEBHOOK:-/webhook/class/upload}"
cat <<MSG
Dry-run recording handoff preview
Target: $TARGET
Command:
curl -X POST "$TARGET" \\
-F "data=@$FILE" \\
-F "class_name=$CLASS_NAME" \\
-F "lecture_title=${CLASS_NAME} Recording" \\
-F "source=doris-schoolhouse"
MSG

View File

@@ -0,0 +1,30 @@
services:
doris-schoolhouse:
container_name: doris-schoolhouse
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8091:8091"
env_file:
- .env
environment:
TZ: ${TZ}
SCHOOLHOUSE_HOST: 0.0.0.0
SCHOOLHOUSE_PORT: 8091
networks:
- schoolhouse-net
volumes:
- /opt/doris-schoolhouse/data:/data
- /home/fizzlepoof/.openclaw/workspace:/workspace/.openclaw-workspace:ro
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8091/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
schoolhouse-net:
driver: bridge

View File

@@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
jinja2==3.1.4
python-multipart==0.0.9
pydantic==2.9.2
sqlalchemy==2.0.35
psycopg[binary]==3.2.1
httpx==0.27.2

View File

@@ -1,18 +1,24 @@
services:
kima-hub:
kima:
image: ghcr.io/chevron7locked/kima:latest
container_name: kima
environment:
- TZ=${TZ}
- PUID=${PUID}
- PGID=${PGID}
- KIMA_MUSIC_PATH=/music
- SESSION_SECRET=${SESSION_SECRET:-}
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
- DISABLE_CLAP=${DISABLE_CLAP:-}
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
- FANART_API_KEY=${FANART_API_KEY:-}
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
volumes:
- /mnt/docker-ssd/docker/appdata/kima-hub/config:/app/config
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/app/data
- /mnt/unraid/data/media:/music
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
- /mnt/unraid/data/media/music:/music
ports:
- "3333:3333"
- "3333:3030"
networks:
- pangolin
extra_hosts:

0
meshtastic/scripts/PirateWeatherADV.py Executable file → Normal file
View File

0
meshtastic/scripts/mm_wx.py Executable file → Normal file
View File

6
monitoring/.env.example Normal file
View File

@@ -0,0 +1,6 @@
TZ=America/New_York
# Grafana
GF_ADMIN_USER=admin
GF_ADMIN_PASS=CHANGE_ME
GF_HOST=grafana.paccoco.com

118
monitoring/DEPLOY.md Normal file
View File

@@ -0,0 +1,118 @@
# Phase 6 — Grafana + Prometheus Deployment on PD
> Adapted from HOMELAB_BUILDOUT_PLAN.md Phase 6, changed host from N.O.M.A.D. to PlausibleDeniability.
## 1. Scaffold Directories
SSH into PD (or run locally):
```bash
# Stack directory
sudo mkdir -p /opt/monitoring/provisioning/datasources
sudo mkdir -p /opt/monitoring/provisioning/dashboards
# Prometheus TSDB on tank (sequential writes, grows with retention)
sudo mkdir -p /mnt/tank/docker/appdata/prometheus
sudo chown 65534:65534 /mnt/tank/docker/appdata/prometheus
# Grafana data on tank
sudo mkdir -p /mnt/tank/docker/appdata/grafana
sudo chown 472:472 /mnt/tank/docker/appdata/grafana
```
## 2. Copy Files to PD
Copy everything from this `monitoring/` folder to `/opt/monitoring/` on PD:
```bash
# From your local machine (adjust source path as needed)
scp -r monitoring/* pd:/opt/monitoring/
# Or if working directly on PD, copy files into place:
# docker-compose.yaml → /opt/monitoring/docker-compose.yaml
# prometheus.yml → /opt/monitoring/prometheus.yml
# provisioning/ → /opt/monitoring/provisioning/
# .env.example → /opt/monitoring/.env.example
```
## 3. Create .env
```bash
cd /opt/monitoring
cp .env.example .env
nano .env
# Set GF_ADMIN_PASS=$(openssl rand -hex 16)
```
## 4. Pangolin Configuration
In Pangolin dashboard, create a new resource using PD's existing Newt:
| Field | Value |
|--------|--------------------------|
| Domain | `grafana.paccoco.com` |
| Scheme | `http` |
| Host | `grafana` |
| Port | `3000` |
Since Grafana joins the `pangolin` network, PD's Newt can reach it by container name.
## 5. Validate & Deploy
```bash
cd /opt/monitoring
docker compose --env-file .env config
docker compose --env-file .env up -d
```
## 6. Post-Deploy Verification
```bash
# All three containers running?
docker ps --filter name=prometheus --filter name=grafana --filter name=node-exporter
# Prometheus scraping targets?
curl -s http://10.5.1.6:9090/api/v1/targets | python3 -m json.tool | head -40
# Grafana UI accessible?
curl -s -o /dev/null -w "%{http_code}" http://10.5.1.6:3000/
# Expected: 200 or 302
# Node exporter metrics flowing?
curl -s http://10.5.1.6:9100/metrics | head -10
```
> Remember: use `10.5.1.6` not `localhost` for health checks on TrueNAS Scale.
## 7. Recommended Dashboard Imports
In Grafana → Dashboards → New → Import → Enter ID:
| Dashboard | ID | Purpose |
|------------------------|------|-----------------------------------|
| Node Exporter Full | 1860 | PD system metrics |
| Docker Container Stats | 893 | Container resource usage |
| Netdata via Prometheus | search | PD and Serenity system metrics |
## 8. Grafana → n8n Alert Webhook
In Grafana → Alerting → Contact Points, create:
| Field | Value |
|-------------|--------------------------------------------------|
| Name | `n8n-alerts` |
| Type | Webhook |
| URL | `https://n8n.paccoco.com/webhook/grafana-alert` |
| HTTP Method | POST |
Suggested alert rules:
- ZFS pool utilization > 85%
- Container memory > 90% of limit
- Host CPU sustained > 90% for 5 minutes
- Disk I/O latency spikes
## Notes
- **Storage:** Prometheus TSDB and Grafana data are on tank (plenty of headroom). 90-day retention is configured. Prometheus writes sequentially so tank performs fine here.
- **N.O.M.A.D. scraping:** The N.O.M.A.D. target in prometheus.yml is commented out. Uncomment once Netdata or node-exporter is running on N.O.M.A.D. at 10.5.1.16.
- **Image versions:** Prometheus v3.4.0, Grafana 13.0.1, node-exporter v1.9.0 — verify these are still current before deploying.

236
monitoring/deploy.sh Normal file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# Phase 6 — Grafana + Prometheus Deployment on PD
# Run this script on PlausibleDeniability as root or with sudo
# ============================================================
echo "=== Phase 6: Grafana + Prometheus ==="
echo ""
# ---- 1. Scaffold directories ----
echo "[1/6] Scaffolding directories..."
mkdir -p /mnt/docker-ssd/docker/compose/monitoring/provisioning/datasources
mkdir -p /mnt/docker-ssd/docker/compose/monitoring/provisioning/dashboards
mkdir -p /mnt/tank/docker/appdata/prometheus
chown 65534:65534 /mnt/tank/docker/appdata/prometheus
mkdir -p /mnt/tank/docker/appdata/grafana
chown 472:472 /mnt/tank/docker/appdata/grafana
echo " Done."
# ---- 2. Write docker-compose.yaml ----
echo "[2/6] Writing docker-compose.yaml..."
cat > /mnt/docker-ssd/docker/compose/monitoring/docker-compose.yaml << 'COMPOSE'
name: monitoring
services:
prometheus:
image: prom/prometheus:v3.4.0
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=90d'
- '--web.enable-lifecycle'
volumes:
- /mnt/tank/docker/appdata/prometheus:/prometheus
- /mnt/docker-ssd/docker/compose/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
networks:
- default
grafana:
image: grafana/grafana:13.0.1
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
TZ: ${TZ}
GF_SECURITY_ADMIN_USER: ${GF_ADMIN_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASS}
GF_SERVER_ROOT_URL: https://${GF_HOST}/
volumes:
- /mnt/tank/docker/appdata/grafana:/var/lib/grafana
- /mnt/docker-ssd/docker/compose/monitoring/provisioning:/etc/grafana/provisioning:ro
networks:
- pangolin
- default
node-exporter:
image: prom/node-exporter:v1.9.0
container_name: node-exporter
restart: unless-stopped
ports:
- "9100:9100"
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
networks:
- default
networks:
pangolin:
external: true
COMPOSE
echo " Done."
# ---- 3. Write prometheus.yml ----
echo "[3/6] Writing prometheus.yml..."
cat > /mnt/docker-ssd/docker/compose/monitoring/prometheus.yml << 'PROMCFG'
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# ------- PlausibleDeniability (local) -------
- job_name: 'pd-node'
static_configs:
- targets: ['node-exporter:9100']
labels:
host: 'plausible-deniability'
- job_name: 'pd-netdata'
metrics_path: /api/v1/allmetrics
params:
format: [prometheus]
static_configs:
- targets: ['10.5.1.6:19999']
labels:
host: 'plausible-deniability'
# ------- Serenity -------
- job_name: 'serenity-netdata'
metrics_path: /api/v1/allmetrics
params:
format: [prometheus]
static_configs:
- targets: ['10.5.1.5:19999']
labels:
host: 'serenity'
# ------- N.O.M.A.D. -------
# Uncomment when Netdata or node-exporter is available on N.O.M.A.D.
# - job_name: 'nomad-netdata'
# metrics_path: /api/v1/allmetrics
# params:
# format: [prometheus]
# static_configs:
# - targets: ['10.5.1.16:19999']
# labels:
# host: 'nomad'
PROMCFG
echo " Done."
# ---- 4. Write Grafana provisioning ----
echo "[4/6] Writing Grafana provisioning configs..."
cat > /mnt/docker-ssd/docker/compose/monitoring/provisioning/datasources/prometheus.yml << 'DATASRC'
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
DATASRC
cat > /mnt/docker-ssd/docker/compose/monitoring/provisioning/dashboards/dashboards.yml << 'DASHCFG'
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
DASHCFG
echo " Done."
# ---- 5. Generate .env ----
echo "[5/6] Generating .env..."
GF_PASS=$(openssl rand -hex 16)
cat > /mnt/docker-ssd/docker/compose/monitoring/.env << ENV
TZ=America/New_York
# Grafana
GF_ADMIN_USER=admin
GF_ADMIN_PASS=${GF_PASS}
GF_HOST=grafana.paccoco.com
ENV
echo " Done."
echo ""
echo " Grafana admin password: ${GF_PASS}"
echo " (saved in /mnt/docker-ssd/docker/compose/monitoring/.env)"
echo ""
# ---- 6. Validate and deploy ----
echo "[6/6] Validating and deploying..."
cd /mnt/docker-ssd/docker/compose/monitoring
docker compose --env-file .env config > /dev/null 2>&1
echo " Compose config valid."
docker compose --env-file .env up -d
echo ""
echo "=== Deployment complete ==="
echo ""
echo "Waiting 10 seconds for containers to start..."
sleep 10
# ---- Post-deploy checks ----
echo ""
echo "=== Post-Deploy Verification ==="
echo ""
echo "--- Container status ---"
docker ps --filter name=prometheus --filter name=grafana --filter name=node-exporter --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo "--- Prometheus targets ---"
curl -s http://10.5.1.6:9090/api/v1/targets 2>/dev/null | python3 -m json.tool 2>/dev/null | head -30 || echo "Prometheus not yet responding (may need a moment)"
echo ""
echo "--- Grafana health ---"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://10.5.1.6:3000/ 2>/dev/null || echo "000")
echo "Grafana HTTP status: ${HTTP_CODE}"
echo ""
echo "--- Node exporter ---"
curl -s http://10.5.1.6:9100/metrics 2>/dev/null | head -5 || echo "Node exporter not yet responding"
echo ""
echo "=== All done! ==="
echo ""
echo "Next steps:"
echo " 1. Add Pangolin resource: grafana.paccoco.com -> http://grafana:3000"
echo " 2. Log in at https://grafana.paccoco.com with admin / ${GF_PASS}"
echo " 3. Import dashboards: Node Exporter Full (ID 1860), Docker Stats (ID 893)"
echo " 4. Set up Grafana -> n8n alert webhook at https://n8n.paccoco.com/webhook/grafana-alert"

View File

@@ -0,0 +1,363 @@
{
"updatedAt": "2026-05-07T03:32:35.930Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"id": "GXtkp4xKka90XrXH",
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.0",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "grafana-alert",
"options": {}
},
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
"name": "Grafana Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
},
{
"parameters": {
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
},
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
"name": "Parse Alert Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
224,
0
]
},
{
"parameters": {
"method": "POST",
"url": "http://10.5.1.6:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
"options": {}
},
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
"name": "LiteLLM Summarize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1792,
0
]
},
{
"parameters": {
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
},
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
"name": "Format Notification",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
0
]
},
{
"parameters": {
"message": "={{ $json.message }}",
"additionalFields": {
"priority": "={{ $json.priority }}",
"title": "={{ $json.title }}"
},
"options": {
"contentType": "text/plain"
}
},
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
"name": "Push to Gotify",
"type": "n8n-nodes-base.gotify",
"typeVersion": 1,
"position": [
2224,
0
],
"credentials": {
"gotifyApi": {
"id": "DXdYZDfVZecDTaNU",
"name": "Gotify account"
}
}
},
{
"parameters": {
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
},
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
"name": "Build Fingerprint",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
"options": {}
},
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
"name": "Lookup Dedup",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
672,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
},
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
"name": "Check Should Notify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
896,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "edee2fbd-0b2b-43bf-b542-9d9297d39d66",
"leftValue": "={{ $json.suppress }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equal"
}
}
],
"combinator": "and"
}
},
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
"name": "Should Notify?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1120,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].alertName : \"unknown\" }}', '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].host : \"unknown\" }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
"options": {}
},
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
"name": "Upsert Dedup Record",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1344,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
}
],
"connections": {
"Grafana Webhook": {
"main": [
[
{
"node": "Parse Alert Payload",
"type": "main",
"index": 0
}
]
]
},
"Parse Alert Payload": {
"main": [
[
{
"node": "Build Fingerprint",
"type": "main",
"index": 0
}
]
]
},
"Build Fingerprint": {
"main": [
[
{
"node": "Lookup Dedup",
"type": "main",
"index": 0
}
]
]
},
"Lookup Dedup": {
"main": [
[
{
"node": "Check Should Notify",
"type": "main",
"index": 0
}
]
]
},
"Check Should Notify": {
"main": [
[
{
"node": "Should Notify?",
"type": "main",
"index": 0
}
]
]
},
"Should Notify?": {
"main": [
[
{
"node": "Upsert Dedup Record",
"type": "main",
"index": 0
}
],
[]
]
},
"Upsert Dedup Record": {
"main": [
[
{
"node": "LiteLLM Summarize",
"type": "main",
"index": 0
}
]
]
},
"LiteLLM Summarize": {
"main": [
[
{
"node": "Format Notification",
"type": "main",
"index": 0
}
]
]
},
"Format Notification": {
"main": [
[
{
"node": "Push to Gotify",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"pinData": {},
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"versionCounter": 10,
"triggerCount": 1,
"tags": [
{
"updatedAt": "2026-05-06T22:38:51.678Z",
"createdAt": "2026-05-06T22:38:51.678Z",
"id": "S9FxzVfHLsHmyzyt",
"name": "ai"
},
{
"updatedAt": "2026-05-06T22:38:51.660Z",
"createdAt": "2026-05-06T22:38:51.660Z",
"id": "0ETpkL5jJ5wwgt8k",
"name": "monitoring"
}
],
"shared": [
{
"updatedAt": "2026-05-07T03:32:32.026Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"role": "workflow:owner",
"workflowId": "GXtkp4xKka90XrXH",
"projectId": "dxCRnBdX5uJizCGa",
"project": {
"updatedAt": "2026-05-06T01:10:37.484Z",
"createdAt": "2026-05-06T01:08:07.721Z",
"id": "dxCRnBdX5uJizCGa",
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
}
}
],
"versionMetadata": {
"name": "Version ba3a4eab",
"description": ""
}
}

View File

@@ -0,0 +1,363 @@
{
"updatedAt": "2026-05-07T03:32:35.930Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"id": "GXtkp4xKka90XrXH",
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.1",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "grafana-alert",
"options": {}
},
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
"name": "Grafana Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
},
{
"parameters": {
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
},
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
"name": "Parse Alert Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
224,
0
]
},
{
"parameters": {
"method": "POST",
"url": "http://10.5.1.6:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
"options": {}
},
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
"name": "LiteLLM Summarize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1792,
0
]
},
{
"parameters": {
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
},
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
"name": "Format Notification",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
0
]
},
{
"parameters": {
"message": "={{ $json.message }}",
"additionalFields": {
"priority": "={{ $json.priority }}",
"title": "={{ $json.title }}"
},
"options": {
"contentType": "text/plain"
}
},
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
"name": "Push to Gotify",
"type": "n8n-nodes-base.gotify",
"typeVersion": 1,
"position": [
2224,
0
],
"credentials": {
"gotifyApi": {
"id": "DXdYZDfVZecDTaNU",
"name": "Gotify account"
}
}
},
{
"parameters": {
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
},
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
"name": "Build Fingerprint",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
"options": {}
},
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
"name": "Lookup Dedup",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
672,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
},
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
"name": "Check Should Notify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
896,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
"leftValue": "={{ $json.shouldNotify }}",
"rightValue": "yes",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
"name": "Should Notify?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1120,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].alertName : \"unknown\" }}', '{{ ($json.parsed && $json.parsed[0]) ? $json.parsed[0].host : \"unknown\" }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
"options": {}
},
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
"name": "Upsert Dedup Record",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1344,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
}
],
"connections": {
"Grafana Webhook": {
"main": [
[
{
"node": "Parse Alert Payload",
"type": "main",
"index": 0
}
]
]
},
"Parse Alert Payload": {
"main": [
[
{
"node": "Build Fingerprint",
"type": "main",
"index": 0
}
]
]
},
"Build Fingerprint": {
"main": [
[
{
"node": "Lookup Dedup",
"type": "main",
"index": 0
}
]
]
},
"Lookup Dedup": {
"main": [
[
{
"node": "Check Should Notify",
"type": "main",
"index": 0
}
]
]
},
"Check Should Notify": {
"main": [
[
{
"node": "Should Notify?",
"type": "main",
"index": 0
}
]
]
},
"Should Notify?": {
"main": [
[
{
"node": "Upsert Dedup Record",
"type": "main",
"index": 0
}
],
[]
]
},
"Upsert Dedup Record": {
"main": [
[
{
"node": "LiteLLM Summarize",
"type": "main",
"index": 0
}
]
]
},
"LiteLLM Summarize": {
"main": [
[
{
"node": "Format Notification",
"type": "main",
"index": 0
}
]
]
},
"Format Notification": {
"main": [
[
{
"node": "Push to Gotify",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"pinData": {},
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"versionCounter": 10,
"triggerCount": 1,
"tags": [
{
"updatedAt": "2026-05-06T22:38:51.678Z",
"createdAt": "2026-05-06T22:38:51.678Z",
"id": "S9FxzVfHLsHmyzyt",
"name": "ai"
},
{
"updatedAt": "2026-05-06T22:38:51.660Z",
"createdAt": "2026-05-06T22:38:51.660Z",
"id": "0ETpkL5jJ5wwgt8k",
"name": "monitoring"
}
],
"shared": [
{
"updatedAt": "2026-05-07T03:32:32.026Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"role": "workflow:owner",
"workflowId": "GXtkp4xKka90XrXH",
"projectId": "dxCRnBdX5uJizCGa",
"project": {
"updatedAt": "2026-05-06T01:10:37.484Z",
"createdAt": "2026-05-06T01:08:07.721Z",
"id": "dxCRnBdX5uJizCGa",
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
}
}
],
"versionMetadata": {
"name": "Version ba3a4eab",
"description": ""
}
}

View File

@@ -0,0 +1,387 @@
{
"updatedAt": "2026-05-07T03:32:35.930Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"id": "GXtkp4xKka90XrXH",
"name": "Grafana Alert \u2192 AI \u2192 Gotify v1.2",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "grafana-alert",
"options": {}
},
"id": "a6e7dfb1-8e9b-4048-b028-4383c30a7e0d",
"name": "Grafana Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "0492788e-db4e-4d15-9572-9d6c3efc80c3"
},
{
"parameters": {
"jsCode": "// Parse Grafana alert payload into a clean summary for the LLM\nconst alerts = $input.first().json.body?.alerts || $input.first().json.alerts || [];\n\nconst parsed = alerts.map(a => ({\n status: a.status || 'unknown',\n alertName: a.labels?.alertname || 'Unnamed Alert',\n host: a.labels?.host || a.labels?.instance || 'unknown host',\n severity: a.labels?.severity || 'warning',\n summary: a.annotations?.summary || '',\n description: a.annotations?.description || '',\n value: a.valueString || '',\n startsAt: a.startsAt || '',\n endsAt: a.endsAt || ''\n}));\n\nconst alertText = parsed.map(a => \n `[${a.status.toUpperCase()}] ${a.alertName} on ${a.host}\\nSeverity: ${a.severity}\\nSummary: ${a.summary}\\nDescription: ${a.description}\\nValue: ${a.value}\\nStarted: ${a.startsAt}`\n).join('\\n---\\n');\n\nreturn [{\n json: {\n alertText,\n alertCount: parsed.length,\n hasResolved: parsed.some(a => a.status === 'resolved'),\n hasFiring: parsed.some(a => a.status === 'firing'),\n parsed\n }\n}];"
},
"id": "ea216058-69db-42cc-88d0-8f67e1b8951b",
"name": "Parse Alert Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
224,
0
]
},
{
"parameters": {
"method": "POST",
"url": "http://10.5.1.6:4000/v1/chat/completions",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "Bearer sk-REPLACE_WITH_LITELLM_KEY"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are a concise homelab monitoring assistant. Summarize infrastructure alerts in 2-3 sentences. Include: what happened, which host, severity, and a suggested action. Keep it brief \u2014 this goes to a phone notification.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Summarize this Grafana alert:\\n\\n\" + $('Parse Alert Payload').first().json.alertText\n }\n ],\n \"max_tokens\": 200,\n \"temperature\": 0.3\n }\n}}",
"options": {}
},
"id": "8ef1aa9d-d5b1-41ea-abc6-703b6491ea33",
"name": "LiteLLM Summarize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1792,
0
]
},
{
"parameters": {
"jsCode": "const response = $input.first().json;\nconst summary = response.choices?.[0]?.message?.content || 'Could not generate summary';\nconst alertData = $('Parse Alert Payload').first().json;\n\nconst title = alertData.hasFiring \n ? `\ud83d\udd25 Alert Firing (${alertData.alertCount})` \n : `\u2705 Alert Resolved (${alertData.alertCount})`;\n\nconst priority = alertData.hasFiring ? 8 : 2;\n\nreturn [{\n json: {\n title,\n message: summary,\n priority\n }\n}];"
},
"id": "2e969bca-8458-4477-a358-664fd1c50c4f",
"name": "Format Notification",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2016,
0
]
},
{
"parameters": {
"message": "={{ $json.message }}",
"additionalFields": {
"priority": "={{ $json.priority }}",
"title": "={{ $json.title }}"
},
"options": {
"contentType": "text/plain"
}
},
"id": "65c2b53a-6e03-4460-9aa6-5d967d244de0",
"name": "Push to Gotify",
"type": "n8n-nodes-base.gotify",
"typeVersion": 1,
"position": [
2224,
0
],
"credentials": {
"gotifyApi": {
"id": "DXdYZDfVZecDTaNU",
"name": "Gotify account"
}
}
},
{
"parameters": {
"jsCode": "const parsed = $('Parse Alert Payload').first().json;\nconst now = new Date();\nconst hour = now.getHours();\nconst fingerprint = (parsed.parsed || [])\n .map(a => `${a.alertName}:${a.host}`)\n .sort().join('|') || 'unknown';\nreturn [{ json: { ...parsed, fingerprint, isNightTime: hour >= 0 && hour < 7 } }];\n"
},
"id": "df26d9ca-c72c-4dee-9327-14a0f5fcf3a0",
"name": "Build Fingerprint",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT fingerprint, last_seen FROM grafana_alert_dedup WHERE fingerprint = '{{ $json.fingerprint }}' AND last_seen > NOW() - INTERVAL '30 minutes' LIMIT 1;",
"options": {}
},
"id": "3fd78a53-7885-4739-a4b7-b441a27e993e",
"name": "Lookup Dedup",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
672,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const alertData = $('Build Fingerprint').first().json;\nconst rows = $input.all().filter(r => r.json && r.json.fingerprint);\nconst alreadySeen = rows.length > 0;\nconst isLowPriority = !alertData.hasFiring;\nconst suppressNight = alertData.isNightTime && isLowPriority;\nconst suppress = alreadySeen || suppressNight;\nreturn [{ json: { ...alertData, suppress, shouldNotify: suppress ? 'no' : 'yes', suppressReason: alreadySeen ? 'dedup' : suppressNight ? 'night_suppress' : null } }];\n"
},
"id": "c34b4b0e-1a47-4624-b1c4-9ef6b5cdeb6c",
"name": "Check Should Notify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
896,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cc41d77c-b659-4c6f-8e71-96a13dced242",
"leftValue": "={{ $json.shouldNotify }}",
"rightValue": "yes",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "29929dc8-17f6-4cc8-90c2-c501eaba8be5",
"name": "Should Notify?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1120,
0
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO grafana_alert_dedup (fingerprint, last_seen, alert_name, host) VALUES ('{{ $json.fingerprint }}', NOW(), '{{ $json.dedupAlertName }}', '{{ $json.dedupHost }}') ON CONFLICT (fingerprint) DO UPDATE SET last_seen = NOW();",
"options": {}
},
"id": "25a14a6a-db17-49ca-a6d2-6ed2f97d2889",
"name": "Upsert Dedup Record",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1568,
0
],
"credentials": {
"postgres": {
"id": "n9svoXemqSZoNNUB",
"name": "Postgres account"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "const d = $input.first().json;\nconst first = (d.parsed || [])[0] || {};\nreturn [{ json: {\n fingerprint: d.fingerprint,\n dedupAlertName: first.alertName || 'unknown',\n dedupHost: first.host || 'unknown'\n} }];"
},
"id": "54705c76-512d-4479-889f-dc0a3c24d1b3",
"name": "Prepare Dedup Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
0
]
}
],
"connections": {
"Grafana Webhook": {
"main": [
[
{
"node": "Parse Alert Payload",
"type": "main",
"index": 0
}
]
]
},
"Parse Alert Payload": {
"main": [
[
{
"node": "Build Fingerprint",
"type": "main",
"index": 0
}
]
]
},
"Build Fingerprint": {
"main": [
[
{
"node": "Lookup Dedup",
"type": "main",
"index": 0
}
]
]
},
"Lookup Dedup": {
"main": [
[
{
"node": "Check Should Notify",
"type": "main",
"index": 0
}
]
]
},
"Check Should Notify": {
"main": [
[
{
"node": "Should Notify?",
"type": "main",
"index": 0
}
]
]
},
"Should Notify?": {
"main": [
[
{
"node": "Prepare Dedup Data",
"type": "main",
"index": 0
}
],
[]
]
},
"Upsert Dedup Record": {
"main": [
[
{
"node": "LiteLLM Summarize",
"type": "main",
"index": 0
}
]
]
},
"LiteLLM Summarize": {
"main": [
[
{
"node": "Format Notification",
"type": "main",
"index": 0
}
]
]
},
"Format Notification": {
"main": [
[
{
"node": "Push to Gotify",
"type": "main",
"index": 0
}
]
]
},
"Prepare Dedup Data": {
"main": [
[
{
"node": "Upsert Dedup Record",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"pinData": {},
"versionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"activeVersionId": "ba3a4eab-60b8-4b1c-ac9f-07a0fdcec9c0",
"versionCounter": 10,
"triggerCount": 1,
"tags": [
{
"updatedAt": "2026-05-06T22:38:51.678Z",
"createdAt": "2026-05-06T22:38:51.678Z",
"id": "S9FxzVfHLsHmyzyt",
"name": "ai"
},
{
"updatedAt": "2026-05-06T22:38:51.660Z",
"createdAt": "2026-05-06T22:38:51.660Z",
"id": "0ETpkL5jJ5wwgt8k",
"name": "monitoring"
}
],
"shared": [
{
"updatedAt": "2026-05-07T03:32:32.026Z",
"createdAt": "2026-05-07T03:32:32.026Z",
"role": "workflow:owner",
"workflowId": "GXtkp4xKka90XrXH",
"projectId": "dxCRnBdX5uJizCGa",
"project": {
"updatedAt": "2026-05-06T01:10:37.484Z",
"createdAt": "2026-05-06T01:08:07.721Z",
"id": "dxCRnBdX5uJizCGa",
"name": "Wilfred Fizzlepoof <admin@paccoco.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "47b2227f-2fc2-4a08-bd35-ea157b92df0d"
}
}
],
"versionMetadata": {
"name": "Version ba3a4eab",
"description": ""
}
}

Some files were not shown because too many files have changed in this diff Show More