Compare commits
52 Commits
main
...
5c889f108e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c889f108e | ||
|
|
0e7dd21c66 | ||
|
|
aace6e710d | ||
|
|
1c5cce5535 | ||
|
|
bd065445cd | ||
|
|
1ad5035960 | ||
|
|
e5de7c532d | ||
|
|
6278191614 | ||
|
|
66c97ae6b5 | ||
|
|
ed1e0110f2 | ||
|
|
cb8d6a030b | ||
|
|
5cc5b5f569 | ||
|
|
4d78dd4524 | ||
|
|
f7001d5130 | ||
|
|
ff2e13a16f | ||
|
|
5783963816 | ||
|
|
837d6e5866 | ||
|
|
458989e6cd | ||
|
|
79b546c70e | ||
|
|
1b61f7a669 | ||
|
|
88ea84b89a | ||
|
|
72724f8e2c | ||
|
|
d7670fde4b | ||
|
|
84b87baf2c | ||
|
|
80842f95a7 | ||
|
|
e567b97df8 | ||
|
|
bf5a138d1c | ||
|
|
0a398eea9a | ||
|
|
a5c0c0b9ad | ||
|
|
f6482abcd6 | ||
|
|
3269cc1fc5 | ||
|
|
a04b2eecd7 | ||
|
|
1b4838504b | ||
|
|
cf5474369d | ||
|
|
5ed02928ec | ||
|
|
7fe24d1956 | ||
|
|
30e01a9a09 | ||
|
|
e40fd48547 | ||
|
|
faf92cfbf7 | ||
| 1a6a74a067 | |||
|
|
2e0bd6d49a | ||
|
|
bc3722732a | ||
| 35077b1f7c | |||
| f3f1c346ac | |||
|
|
87dbd3e6ea | ||
|
|
96f1c69fd9 | ||
|
|
9aef43ee10 | ||
|
|
11904f39b2 | ||
|
|
35019e08cc | ||
|
|
ca3de65e34 | ||
|
|
6df8f80d24 | ||
|
|
8e8b54d176 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@ __pycache__/
|
|||||||
# Temp scripts
|
# Temp scripts
|
||||||
update-homelab-docs.py
|
update-homelab-docs.py
|
||||||
|
|
||||||
|
# Generated stack config
|
||||||
|
pihole/keepalived.conf
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Docker Compose stacks for PlausibleDeniability (TrueNAS Scale), plus documentati
|
|||||||
| Server | Role | OS | IP |
|
| Server | Role | OS | IP |
|
||||||
|--------|------|----|----|
|
|--------|------|----|----|
|
||||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
| 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 |
|
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||||
|
|
||||||
## Repo Layout
|
## 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
|
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
|
||||||
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
|
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
|
||||||
photos/ # immich server + machine learning
|
photos/ # immich server + machine learning
|
||||||
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges
|
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
|
||||||
dev/ # gitea, rackpeek
|
dev/ # gitea, rackpeek
|
||||||
ai/ # ollama, openwebui
|
ai/ # ollama, openwebui
|
||||||
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ OPENWEBUI_DB_PASS=changeme
|
|||||||
|
|
||||||
# Scriberr — Hugging Face token for model downloads
|
# Scriberr — Hugging Face token for model downloads
|
||||||
HF_TOKEN=changeme
|
HF_TOKEN=changeme
|
||||||
|
|
||||||
|
# OpenClaw — generate with: openssl rand -hex 32
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=changeme
|
||||||
|
|||||||
@@ -105,26 +105,3 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "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]
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ model_list:
|
|||||||
- model_name: "medium"
|
- model_name: "medium"
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: "ollama/qwen2.5:14b"
|
model: "ollama/qwen2.5:14b"
|
||||||
api_base: "http://host.docker.internal:11434"
|
api_base: "http://ollama:11434"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
stream_timeout: 120
|
stream_timeout: 120
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ model_list:
|
|||||||
- model_name: "ollama/qwen2.5:14b"
|
- model_name: "ollama/qwen2.5:14b"
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: "ollama/qwen2.5:14b"
|
model: "ollama/qwen2.5:14b"
|
||||||
api_base: "http://host.docker.internal:11434"
|
api_base: "http://ollama:11434"
|
||||||
|
|
||||||
- model_name: "ollama/deepseek-r1"
|
- model_name: "ollama/deepseek-r1"
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|||||||
@@ -3,3 +3,35 @@ TZ=America/Chicago
|
|||||||
N8N_HOST=n8n.paccoco.com
|
N8N_HOST=n8n.paccoco.com
|
||||||
N8N_DB_PASS=CHANGE_ME
|
N8N_DB_PASS=CHANGE_ME
|
||||||
N8N_ENCRYPTION_KEY=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
104
automation/README.md
Normal 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
139
automation/bin/karakeep_api.py
Executable 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())
|
||||||
475
automation/bin/kitchenowl_recipe_import.py
Executable file
475
automation/bin/kitchenowl_recipe_import.py
Executable 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
73
automation/bin/pd_backup_lib.sh
Executable 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)"
|
||||||
|
}
|
||||||
22
automation/bin/pd_backup_postgres.sh
Executable file
22
automation/bin/pd_backup_postgres.sh
Executable 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"
|
||||||
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable 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"
|
||||||
9
automation/bin/run_pd_backups.sh
Executable file
9
automation/bin/run_pd_backups.sh
Executable 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."
|
||||||
@@ -38,6 +38,20 @@ services:
|
|||||||
N8N_EMAIL_MODE: smtp
|
N8N_EMAIL_MODE: smtp
|
||||||
EXECUTIONS_DATA_PRUNE: "true"
|
EXECUTIONS_DATA_PRUNE: "true"
|
||||||
EXECUTIONS_DATA_MAX_AGE: 336
|
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:
|
volumes:
|
||||||
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
- /mnt/tank/docker/appdata/n8n:/home/node/.n8n
|
||||||
- /mnt/data/class-recordings:/data/class-recordings
|
- /mnt/data/class-recordings:/data/class-recordings
|
||||||
|
|||||||
0
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable file → Normal file
0
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable file → Normal file
@@ -39,6 +39,25 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
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:
|
rackpeek:
|
||||||
container_name: ix-rackpeek-rackpeek-1
|
container_name: ix-rackpeek-rackpeek-1
|
||||||
image: aptacode/rackpeek:latest
|
image: aptacode/rackpeek:latest
|
||||||
|
|||||||
@@ -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
|
- **Homepage** (3300) — dashboard / service index
|
||||||
- **Uptime Kuma** (3001) — monitoring and alerting
|
- **Uptime Kuma** (3001) — monitoring and alerting
|
||||||
- **Gotify** (8443) — push notifications
|
- **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
|
- **Dockhand** (3230) — Docker management UI
|
||||||
|
|
||||||
### AI Layer
|
### AI Layer
|
||||||
@@ -41,7 +41,7 @@ Full homelab stack as of 2026-05-09. All 6 expansion phases are complete and dep
|
|||||||
|
|
||||||
### Productivity Layer
|
### Productivity Layer
|
||||||
- **Immich** (2283) — photo management; library on Serenity NFS
|
- **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
|
- **Karakeep** (3100) — bookmarks / read-later
|
||||||
- **n8n** (5678) — workflow automation
|
- **n8n** (5678) — workflow automation
|
||||||
- **KitchenOwl** (8086), **DoneTick** (2021) — household and task management
|
- **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
|
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)
|
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
|
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`)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- **Other VLANs:** Cameras, Guest, IoT
|
- **Other VLANs:** Cameras, Guest, IoT
|
||||||
- **Serenity IP:** 10.5.1.5
|
- **Serenity IP:** 10.5.1.5
|
||||||
- **N.O.M.A.D. IP:** 10.5.1.16
|
- **N.O.M.A.D. IP:** 10.5.1.16
|
||||||
- **PlausibleDeniability:** DHCP on LAN
|
- **PlausibleDeniability:** 10.5.1.6 (static on LAN)
|
||||||
|
|
||||||
## Remote Access
|
## Remote Access
|
||||||
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
|
- **Pangolin VPS + Newt:** Services exposed as subdomains via reverse proxy. Newt agents run on PD and N.O.M.A.D.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
All homelab services, their hosts, ports, and current status.
|
All homelab services, their hosts, ports, and current status.
|
||||||
|
|
||||||
*Last updated: 2026-05-09*
|
*Last updated: 2026-05-10*
|
||||||
|
|
||||||
## Shared Databases (PD)
|
## Shared Databases (PD)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ All homelab services, their hosts, ports, and current status.
|
|||||||
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
|
| Dockhand | 3230 | http://pd:3230 | ✅ Active |
|
||||||
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
|
| Uptime Kuma | 3001 | http://pd:3001 | ✅ Active |
|
||||||
| Gotify | 8443 | https://pd:8443 | ✅ 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 | 8087 | http://pd:8087 | ✅ Active |
|
||||||
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
|
| Shlink Web Client | 8088 | http://pd:8088 | ✅ Active |
|
||||||
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
|
| RackPeek | 8283 | http://pd:8283 | ✅ Active |
|
||||||
@@ -32,8 +32,10 @@ All homelab services, their hosts, ports, and current status.
|
|||||||
| LiteLLM | PD | 4000 | ✅ Active |
|
| LiteLLM | PD | 4000 | ✅ Active |
|
||||||
| OpenWebUI | PD | 8282 | ✅ Active |
|
| OpenWebUI | PD | 8282 | ✅ Active |
|
||||||
| Qdrant | PD | 6333 (HTTP) / 6334 (gRPC) | ✅ 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 |
|
| SearXNG | PD | 8888 | ✅ Active |
|
||||||
|
| OpenClaw | PD | 18789 | ✅ Active |
|
||||||
| Ollama — light tier | N.O.M.A.D. (10.5.1.16) | 11434 | ✅ 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 |
|
| Ollama — heavy tier | Rocinante (10.5.1.112) | 11434 | ✅ Active |
|
||||||
| Reranker (TEI) | Serenity (10.5.1.5) | 9787 | ✅ 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 |
|
| Service | Port | Status |
|
||||||
|---------|------|--------|
|
|---------|------|--------|
|
||||||
| Immich | 2283 | ✅ Active |
|
| Immich | 2283 | ✅ Active |
|
||||||
| Paperless-NGX | 8083 | ✅ Active |
|
| Paperless-NGX | 8083 | ✅ Active; DOCX/Office ingestion via internal Tika + Gotenberg sidecars |
|
||||||
| Karakeep | 3100 | ✅ Active |
|
| Karakeep | 3100 | ✅ Active |
|
||||||
| n8n | 5678 | ✅ Active |
|
| n8n | 5678 | ✅ Active |
|
||||||
| KitchenOwl | 8086 | ✅ Active |
|
| KitchenOwl | 8086 | ✅ Active |
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ Back up at minimum:
|
|||||||
|
|
||||||
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
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
|
## .env File Backup Options
|
||||||
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
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)
|
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
||||||
|
|||||||
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal file
137
docs/operations/KITCHENOWL_RECIPE_IMPORT.md
Normal 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.
|
||||||
49
docs/operations/PAPERLESS.md
Normal file
49
docs/operations/PAPERLESS.md
Normal 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'
|
||||||
|
```
|
||||||
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal 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.
|
||||||
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal file
130
docs/operations/SCHOOL_PAPERLESS_INTAKE.md
Normal 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.
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
# Secrets Management
|
# 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
|
## Principles
|
||||||
- `.env` files are gitignored and never committed to any public repo
|
|
||||||
- `.env.example` files are committed with placeholder values
|
- `.env` files are gitignored in the main repo — never committed there
|
||||||
- Real `.env` files live only on the host at the stack directory
|
- `.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
|
- Secrets are generated with standard tools, never reused across services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Generating Secrets
|
## Generating Secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,46 +33,182 @@ openssl rand -hex 32
|
|||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
## .env File Locations
|
---
|
||||||
|
|
||||||
|
## Live .env File Locations
|
||||||
|
|
||||||
All `.env` files live alongside their `docker-compose.yaml`:
|
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
|
```bash
|
||||||
# In a separate directory, not the main stacks repo
|
bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Long term: Restic + offsite
|
What it does:
|
||||||
Include the compose directory (with .env files) in a restic backup:
|
- 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
|
```bash
|
||||||
restic -r <repo> backup /mnt/docker-ssd/docker/compose --exclude="*.example"
|
export PATH="/mnt/docker-ssd/bin:$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Best long term: Vaultwarden
|
### Re-installing after a full rebuild
|
||||||
Once deployed, store each service's .env as a secure note in Vaultwarden. Single source of truth, accessible from anywhere.
|
|
||||||
|
**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
|
## Known Credentials Locations
|
||||||
|
|
||||||
| Service | Where stored |
|
| Service | File |
|
||||||
|---------|-------------|
|
|---------|------|
|
||||||
| shared-postgres | `databases/.env` |
|
| shared-postgres | `databases/.env` |
|
||||||
| shared-mariadb | `databases/.env` |
|
| shared-mariadb | `databases/.env` |
|
||||||
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
|
| OpenWebUI DB | `ai/.env` — user: openwebui, db: openwebui |
|
||||||
| Donetick | `home/.env` + `selfhosted.yaml` |
|
| Donetick | `home/.env` + `selfhosted.yaml` |
|
||||||
| Meshmonitor | `meshtastic/.env` |
|
| Meshmonitor | `meshtastic/.env` |
|
||||||
| Gitea | `dev/.env` |
|
| Gitea | `dev/.env` |
|
||||||
|
| LiteLLM | `ai/.env` |
|
||||||
|
| Paperless | `documents/.env` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Reminders
|
## 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 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)
|
- Regenerate N.O.M.A.D. Newt secret (was exposed in chat — TODO)
|
||||||
- Disable signups in OpenWebUI after creating initial account
|
- Disable signups in OpenWebUI after creating initial account
|
||||||
|
- The Gitea token in `setup-secrets-repo.sh` should be rotated after initial setup
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
# Homelab Stack Standards
|
# Homelab Stack Standards
|
||||||
|
|
||||||
## Repository Root
|
## 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:
|
Remotes:
|
||||||
- GitHub: `git@github.com:Paccoco/truenas-stacks.git`
|
- 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
|
## Stack Layout
|
||||||
Each production stack lives in a subdirectory of the repo root.
|
Each production stack lives in a subdirectory of the repo root.
|
||||||
@@ -16,6 +21,24 @@ Each stack contains:
|
|||||||
- `.env.example` — committed with placeholder values
|
- `.env.example` — committed with placeholder values
|
||||||
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
- 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
|
## Deployment Order
|
||||||
1. `databases/` — must be up before anything else
|
1. `databases/` — must be up before anything else
|
||||||
2. `infrastructure/` — newt creates the pangolin network
|
2. `infrastructure/` — newt creates the pangolin network
|
||||||
@@ -25,8 +48,13 @@ Each stack contains:
|
|||||||
|
|
||||||
Validate before deploying:
|
Validate before deploying:
|
||||||
```bash
|
```bash
|
||||||
|
# PD-style shared compose root
|
||||||
cd /mnt/docker-ssd/docker/compose/<stack>
|
cd /mnt/docker-ssd/docker/compose/<stack>
|
||||||
docker compose --env-file .env config
|
docker compose --env-file .env config
|
||||||
|
|
||||||
|
# NOMAD-style service root
|
||||||
|
cd /opt/<service>
|
||||||
|
docker compose --env-file .env config
|
||||||
```
|
```
|
||||||
|
|
||||||
Deploy:
|
Deploy:
|
||||||
@@ -116,7 +144,7 @@ After changing a healthcheck, must `down` then `up` (not just restart).
|
|||||||
cd /mnt/docker-ssd/docker/compose
|
cd /mnt/docker-ssd/docker/compose
|
||||||
git add .
|
git add .
|
||||||
git commit -m "description"
|
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.
|
All `.env` files are gitignored. Only compose files, `.env.example`, and init scripts are committed.
|
||||||
|
|||||||
@@ -1,91 +1,125 @@
|
|||||||
# Kima-Hub Deployment Plan
|
# Kima-Hub Deployment / Repair Reference
|
||||||
|
|
||||||
**Host:** PlausibleDeniability (PD)
|
**Host:** PlausibleDeniability (PD)
|
||||||
**Port:** 3333
|
**Stack dir in repo:** `media/kima-hub/`
|
||||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
|
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
|
||||||
**Status:** Pending
|
**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)
|
## Canonical live `.env` shape
|
||||||
- [ ] 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`
|
|
||||||
|
|
||||||
## 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima-hub
|
container_name: kima
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3333:3030"
|
|
||||||
volumes:
|
|
||||||
- /mnt/tank/docker/appdata/kima-hub:/data
|
|
||||||
- /mnt/unraid/data/media/music:/music:ro
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
healthcheck:
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
interval: 30s
|
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||||
timeout: 10s
|
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||||
retries: 3
|
- 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:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pangolin:
|
pangolin:
|
||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
|
## Validate before deploy
|
||||||
> **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/docker-ssd/docker/compose/media-extra
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
sudo docker compose --env-file .env config # validate
|
sudo docker compose --env-file .env config
|
||||||
sudo docker compose --env-file .env up -d
|
|
||||||
sudo docker logs kima-hub --tail 30
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Post-Deploy
|
## Deploy / repair
|
||||||
|
|
||||||
1. Open `http://10.5.1.6:3333` and create your account
|
```bash
|
||||||
2. Point Kima at your music directory (`/music`)
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
|
sudo docker compose --env-file .env up -d
|
||||||
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
|
sudo docker compose --env-file .env ps
|
||||||
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
|
sudo docker logs --tail 60 kima
|
||||||
|
```
|
||||||
|
|
||||||
## Pangolin Resource
|
## Post-deploy checks
|
||||||
|
|
||||||
Add in Pangolin dashboard:
|
1. Open `http://10.5.1.6:3333`
|
||||||
- **Subdomain:** `kima.paccoco.com`
|
2. Confirm the app starts and initial setup loads
|
||||||
- **Target:** `http://10.5.1.6:3333`
|
3. Confirm `/music` is visible to Kima
|
||||||
- **Auth:** Enable (personal use only)
|
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 |
|
## Optional integrations / secrets
|
||||||
|------|------|--------|
|
|
||||||
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
|
Not required just to boot:
|
||||||
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
|
|
||||||
|
- **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
|
## Notes
|
||||||
|
|
||||||
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
|
- 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.
|
||||||
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
|
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
|
||||||
- Kima-Hub has no GPU acceleration — pure CPU transcoding
|
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Pi-hole Deployment Plan
|
# 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
|
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
│ 10.5.1.53/24 │
|
│ 10.5.1.53/24 │
|
||||||
└──────┬──────────────┬──────────────────┘
|
└──────┬──────────────┬──────────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
PD (MASTER) Serenity (BACKUP1) RPi4 (BACKUP2)
|
PD (MASTER) N.O.M.A.D. (BACKUP1) RPi4 (BACKUP2)
|
||||||
10.5.1.6 10.5.1.5 10.5.1.X
|
10.5.1.6 10.5.1.16 10.5.1.X
|
||||||
priority 100 priority 90 priority 80
|
priority 100 priority 90 priority 80
|
||||||
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
||||||
Unbound :5335 Unbound :5335 Unbound :5335
|
Unbound :5335 Unbound :5335 Unbound :5335
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
- **Unbound** on each node acts as the local recursive resolver (replaces Cloudflare/Quad9)
|
- **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`)
|
- **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
|
- **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.
|
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)
|
## 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`
|
**Web UI port:** `8953`
|
||||||
|
|
||||||
### Pre-Deploy
|
### Pre-Deploy
|
||||||
@@ -210,23 +212,26 @@ LAN_INTERFACE=eno1
|
|||||||
|
|
||||||
### Deploy
|
### Deploy
|
||||||
|
|
||||||
|
Preferred PD-first flow now that the repo-side stack exists:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/docker-ssd/docker/compose/pihole
|
cd /mnt/docker-ssd/docker/compose/pihole
|
||||||
# Copy unbound.conf to appdata
|
cp .env.example .env
|
||||||
sudo cp unbound.conf /mnt/tank/docker/appdata/unbound-primary/
|
nano .env
|
||||||
sudo docker compose --env-file .env config
|
/usr/bin/bash bin/prepare_pd.sh
|
||||||
sudo docker compose --env-file .env up -d
|
/usr/bin/bash bin/prepare_pd.sh --up
|
||||||
sudo docker logs pihole --tail 20
|
sudo docker logs pihole-primary --tail 20
|
||||||
sudo docker logs unbound --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`
|
**Web UI port:** `8954`
|
||||||
|
|
||||||
### docker-compose.yaml
|
### docker-compose.yaml
|
||||||
@@ -241,7 +246,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/unbound-pihole:/opt/unbound/etc/unbound/
|
- /opt/pihole-nomad/data/unbound:/opt/unbound/etc/unbound/
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -250,7 +255,7 @@ services:
|
|||||||
|
|
||||||
pihole:
|
pihole:
|
||||||
image: pihole/pihole:latest
|
image: pihole/pihole:latest
|
||||||
container_name: pihole-secondary
|
container_name: pihole-nomad
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -259,11 +264,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ}
|
TZ: ${TZ}
|
||||||
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
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_dns_upstreams: "127.0.0.1#5335"
|
||||||
FTLCONF_webserver_port: "8954"
|
FTLCONF_webserver_port: "8954"
|
||||||
|
FTLCONF_ntp_ipv4_active: "false"
|
||||||
|
FTLCONF_ntp_ipv6_active: "false"
|
||||||
|
FTLCONF_ntp_sync_active: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/pihole-secondary/etc-pihole:/etc/pihole
|
- /opt/pihole-nomad/data/pihole/etc-pihole:/etc/pihole
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_NICE
|
- SYS_NICE
|
||||||
|
|
||||||
@@ -280,16 +289,30 @@ services:
|
|||||||
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
- ./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 {
|
global_defs {
|
||||||
router_id pihole_serenity
|
router_id pihole_nomad
|
||||||
}
|
}
|
||||||
|
|
||||||
vrrp_instance VI_DNS {
|
vrrp_instance VI_DNS {
|
||||||
state BACKUP
|
state BACKUP
|
||||||
interface eth0
|
interface enp5s0
|
||||||
virtual_router_id 53
|
virtual_router_id 53
|
||||||
priority 90
|
priority 90
|
||||||
advert_int 1
|
advert_int 1
|
||||||
@@ -411,7 +434,7 @@ sudo systemctl start keepalived
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
|
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"
|
RUN_GRAVITY: "true"
|
||||||
FULL_SYNC: "false"
|
FULL_SYNC: "false"
|
||||||
CRON: "0 3 * * *"
|
CRON: "0 3 * * *"
|
||||||
@@ -423,7 +446,7 @@ sudo systemctl start keepalived
|
|||||||
> - `REPLICAS` — comma-separated list of secondary instances
|
> - `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`
|
> - `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
|
> - 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
|
### What Nebula Sync copies
|
||||||
- Gravity database (blocklists, allowlists, group assignments)
|
- 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
|
## Post-Deploy
|
||||||
|
|
||||||
### Verify VIP is working
|
### 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
|
# On PD, bring down keepalived
|
||||||
sudo docker stop keepalived
|
sudo docker stop keepalived
|
||||||
# Wait ~3 seconds, then check from another machine:
|
# 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
|
# Restore
|
||||||
sudo docker start keepalived
|
sudo docker start keepalived
|
||||||
```
|
```
|
||||||
@@ -488,7 +517,6 @@ Add in Pi-hole (primary) → Local DNS → DNS Records:
|
|||||||
| Domain | IP |
|
| Domain | IP |
|
||||||
|--------|----|
|
|--------|----|
|
||||||
| pd.lan | 10.5.1.6 |
|
| pd.lan | 10.5.1.6 |
|
||||||
| serenity.lan | 10.5.1.5 |
|
|
||||||
| nomad.lan | 10.5.1.16 |
|
| nomad.lan | 10.5.1.16 |
|
||||||
| rocinante.lan | 10.5.1.112 |
|
| rocinante.lan | 10.5.1.112 |
|
||||||
| rpi4.lan | 10.5.1.X |
|
| rpi4.lan | 10.5.1.X |
|
||||||
@@ -500,7 +528,9 @@ Nebula Sync will propagate these to all replicas.
|
|||||||
| Subdomain | Target |
|
| Subdomain | Target |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| `pihole.paccoco.com` | `http://10.5.1.6:8953` (primary) |
|
| `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.
|
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/pihole-primary` | tank | Low write frequency |
|
||||||
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
|
| 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 |
|
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal file
82
docs/planning/SCHOOL_INTAKE_PIPELINE.md
Normal 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`)
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
# Homelab TODO
|
# Homelab TODO
|
||||||
|
|
||||||
## Security (do ASAP)
|
## Security (do ASAP)
|
||||||
- [ ] Regenerate Wings token on N.O.M.A.D. (was exposed in chat)
|
- [x] Regenerate Wings token on N.O.M.A.D. (was exposed in chat) — completed 2026-05-15
|
||||||
- [ ] Regenerate N.O.M.A.D. Newt secret (was exposed in chat)
|
- [x] Regenerate N.O.M.A.D. Newt secret (was exposed in chat) — completed 2026-05-15
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
- [ ] Deploy Pi-hole 3-node HA cluster (PD + Serenity + RPi4) — see docs/planning/DEPLOY_PIHOLE.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
|
||||||
- RPi4 IP still TBD — fill in before deploying
|
- PD primary live at `/mnt/docker-ssd/docker/compose/pihole`
|
||||||
- [ ] Deploy Kima-Hub — see docs/planning/DEPLOY_KIMA_HUB.md
|
- 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)
|
- [ ] Set up off-site backup for vital data (appdata, databases, config repo, photos)
|
||||||
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity
|
- [ ] Remove dead Unraid-Cloudflared-Tunnel container from Serenity
|
||||||
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
|
- [ ] Serenity malcolm pool capacity planning (heavily utilized)
|
||||||
- [ ] Add Pangolin resource for `ai.paccoco.com` → OpenWebUI (port 8282)
|
- [x] Add Pangolin resource for `openwebui.paccoco.com` → OpenWebUI (port 8282) — completed 2026-05-15
|
||||||
- [ ] Add Cloudflare DNS A record for `ai.paccoco.com`
|
- [x] Add Cloudflare DNS record for `openwebui.paccoco.com` — completed 2026-05-15
|
||||||
- [ ] Install fresh editor on PD
|
- [ ] 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
|
## AI Stack
|
||||||
- [ ] Pull `qwen2.5:14b` on PD ollama: `sudo docker exec -it ollama ollama pull qwen2.5:14b`
|
- [x] Pull `qwen2.5:14b` on PD ollama — completed 2026-05-15
|
||||||
- [ ] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md
|
- [x] Create OpenWebUI account and set system prompt from HOMELAB_AI_CONTEXT.md — completed 2026-05-15
|
||||||
- [ ] Disable signups in OpenWebUI after creating account
|
- [x] Disable signups in OpenWebUI after creating account — completed 2026-05-15
|
||||||
- [ ] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections)
|
- [x] Connect Rocinante Ollama to OpenWebUI (Admin → Settings → Connections) — completed 2026-05-15
|
||||||
- [ ] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted
|
- [x] Confirm `OLLAMA_HOST=0.0.0.0` set on Rocinante and Ollama service restarted — completed 2026-05-15
|
||||||
|
|
||||||
## Healthchecks
|
## Healthchecks
|
||||||
- [ ] Add healthcheck to ix-plex-plex-1
|
- [ ] Add healthcheck to ix-plex-plex-1
|
||||||
@@ -32,15 +34,23 @@
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- [ ] Document meshtastic stack fully (see docs/reference/MESHTASTIC.md)
|
- [ ] 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
|
## n8n Workflows
|
||||||
- [ ] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra
|
- [x] Create Gotify apps and n8n credentials: Reminders, n8n Health, Uptime / Infra — completed 2026-05-15
|
||||||
- [ ] Add env vars to n8n: `PAPERLESS_API_TOKEN`, `PAPERLESS_TAG_TRANSCRIPTION`, `PAPERLESS_TAG_LECTURE_NOTES`, `N8N_API_KEY`
|
- [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
|
||||||
- [ ] Set up `PD SSH` credential in n8n for workflow 16 (see n8n-workflows/README.md)
|
- [x] Set up `PD SSH` credential in n8n for workflow 16 — completed 2026-05-15
|
||||||
- [ ] Add sudoers NOPASSWD rule for n8n watchdog (see n8n-workflows/README.md)
|
- [x] Add sudoers NOPASSWD rule for n8n watchdog — completed 2026-05-15
|
||||||
- [ ] Import and activate upgraded workflows: 01, 04, 08
|
- [x] Import and activate upgraded workflows: 04, 08 (01 done — v1.4 active and tested) — completed 2026-05-15
|
||||||
- [ ] Import and activate new workflows: 14, 15 (16 already active)
|
- [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 42–49 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
|
## Completed
|
||||||
- [x] Deploy Gotify (Phase 1) — 2026-05-05
|
- [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 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 Pi-hole 3-node HA deployment plan — 2026-05-09
|
||||||
- [x] Write Kima-Hub 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
|
||||||
|
|||||||
391
docs/planning/doris-schoolhouse-technical-spec.md
Normal file
391
docs/planning/doris-schoolhouse-technical-spec.md
Normal 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
|
||||||
@@ -35,7 +35,7 @@ See the individual docs in this repo for detailed information:
|
|||||||
| Server | Role | OS | IP |
|
| Server | Role | OS | IP |
|
||||||
|--------|------|----|----|
|
|--------|------|----|----|
|
||||||
| Serenity | NAS / Media ingestion | Unraid 7.2.4 | 10.5.1.5 |
|
| 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 |
|
| N.O.M.A.D. | Offline knowledge + game servers | Ubuntu 25.10 | 10.5.1.16 |
|
||||||
|
|
||||||
### Deployment Pattern (PD)
|
### Deployment Pattern (PD)
|
||||||
|
|||||||
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal file
47
docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql
Normal 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);
|
||||||
@@ -21,10 +21,215 @@ Offline survival/knowledge server and game server host.
|
|||||||
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
|
| Seagate SSD (sdd) | 223.6GB | `/mnt/ssd-1` | Fast scratch |
|
||||||
|
|
||||||
## Project N.O.M.A.D.
|
## Project N.O.M.A.D.
|
||||||
- 10 containers running via `start_nomad.sh` / `stop_nomad.sh`
|
- Core compose project lives at `/opt/project-nomad` → symlinked from `/mnt/hdd-1/project-nomad`
|
||||||
- `/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`
|
- Docker data-root: `/mnt/hdd-2/docker`
|
||||||
- Based on Crosstalk Solutions offline survival/knowledge server
|
- 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 audit’s 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)
|
## Pelican Panel (Game Server Management)
|
||||||
- **URL:** `https://panel.paccoco.com`
|
- **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)
|
- **Docker network:** `pelican_nw` (172.20.0.0/16)
|
||||||
|
|
||||||
## Active Game Servers
|
## 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
|
## Networking Notes
|
||||||
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback
|
- `panel.paccoco.com` → `127.0.0.1` in `/etc/hosts` for NAT loopback
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ Primary Docker host running all production workloads.
|
|||||||
RTX 2080 Ti used by:
|
RTX 2080 Ti used by:
|
||||||
- Plex (hardware transcoding)
|
- Plex (hardware transcoding)
|
||||||
- immich-ml (CUDA face recognition)
|
- immich-ml (CUDA face recognition)
|
||||||
- Whisper (CUDA speech-to-text, port 8786)
|
|
||||||
- Ollama light-tier inference (qwen2.5:14b and smaller)
|
- 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:
|
Recommended models for 11GB VRAM:
|
||||||
| Model | VRAM | Best for |
|
| Model | VRAM | Best for |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ Per-service gotchas that aren't bugs but will bite you if you forget them.
|
|||||||
## PlausibleDeniability
|
## PlausibleDeniability
|
||||||
|
|
||||||
### n8n workflow 16 (NFS Watchdog)
|
### 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:
|
- 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`
|
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)
|
- SSH credential in n8n must use key-based auth (see n8n-workflows/README.md for keygen steps)
|
||||||
|
|
||||||
### immich-ml
|
### immich-ml
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ PAPERLESS_DBUSER=paperless
|
|||||||
PAPERLESS_DBPASS=changeme
|
PAPERLESS_DBPASS=changeme
|
||||||
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
|
PAPERLESS_SECRET_KEY=generate_with_openssl_rand_hex_32
|
||||||
PAPERLESS_REDIS=redis://:URL_ENCODED_REDIS_PASSWORD@shared-redis:6379
|
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
|
# Karakeep
|
||||||
|
|||||||
39
documents/README.md
Normal file
39
documents/README.md
Normal 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.
|
||||||
@@ -2,7 +2,7 @@ name: documents
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
paperless:
|
paperless:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
|
||||||
container_name: paperless
|
container_name: paperless
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -24,6 +24,12 @@ services:
|
|||||||
PAPERLESS_TIME_ZONE: America/Chicago
|
PAPERLESS_TIME_ZONE: America/Chicago
|
||||||
PAPERLESS_OCR_LANGUAGE: eng
|
PAPERLESS_OCR_LANGUAGE: eng
|
||||||
PAPERLESS_URL: https://paperless.paccoco.com
|
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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8000/ || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -31,9 +37,26 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
|
- paperless-internal
|
||||||
- ix-databases_shared-databases
|
- ix-databases_shared-databases
|
||||||
- pangolin
|
- 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:
|
karakeep:
|
||||||
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
|
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
|
||||||
container_name: karakeep
|
container_name: karakeep
|
||||||
@@ -50,7 +73,9 @@ services:
|
|||||||
NEXTAUTH_SECRET: ${KARAKEEP_NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${KARAKEEP_NEXTAUTH_SECRET}
|
||||||
MEILI_ADDR: http://karakeep-meilisearch:7700
|
MEILI_ADDR: http://karakeep-meilisearch:7700
|
||||||
MEILI_MASTER_KEY: ${KARAKEEP_MEILI_MASTER_KEY}
|
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}
|
DISABLE_SIGNUPS: ${KARAKEEP_DISABLE_SIGNUPS}
|
||||||
DB_WAL_MODE: ${KARAKEEP_DB_WAL_MODE}
|
DB_WAL_MODE: ${KARAKEEP_DB_WAL_MODE}
|
||||||
LOG_LEVEL: ${KARAKEEP_LOG_LEVEL}
|
LOG_LEVEL: ${KARAKEEP_LOG_LEVEL}
|
||||||
@@ -79,7 +104,8 @@ services:
|
|||||||
- --remote-debugging-port=9222
|
- --remote-debugging-port=9222
|
||||||
- --hide-scrollbars
|
- --hide-scrollbars
|
||||||
networks:
|
networks:
|
||||||
- karakeep
|
karakeep:
|
||||||
|
ipv4_address: 172.16.16.3
|
||||||
|
|
||||||
karakeep-meilisearch:
|
karakeep-meilisearch:
|
||||||
image: getmeili/meilisearch:v1.41.0
|
image: getmeili/meilisearch:v1.41.0
|
||||||
@@ -95,9 +121,18 @@ services:
|
|||||||
- karakeep
|
- karakeep
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
paperless-internal:
|
||||||
|
name: documents_paperless_internal
|
||||||
ix-databases_shared-databases:
|
ix-databases_shared-databases:
|
||||||
external: true
|
external: true
|
||||||
pangolin:
|
pangolin:
|
||||||
external: true
|
external: true
|
||||||
karakeep:
|
karakeep:
|
||||||
name: documents_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
13
home/doris-dashboard/.gitignore
vendored
Normal 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
|
||||||
255
home/doris-dashboard/README.md
Normal file
255
home/doris-dashboard/README.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# Doris Dashboard
|
||||||
|
|
||||||
|
Local static homepage for John’s 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 NOMAD’s 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.
|
||||||
10
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable file
10
home/doris-dashboard/bin/cron_generate_dashboard.sh
Executable 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
|
||||||
106
home/doris-dashboard/bin/dashboard_server.py
Executable file
106
home/doris-dashboard/bin/dashboard_server.py
Executable 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()
|
||||||
599
home/doris-dashboard/bin/generate_dashboard.py
Executable file
599
home/doris-dashboard/bin/generate_dashboard.py
Executable 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}'
|
||||||
|
'¤t=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 story’s 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 — I’ll look for more <strong>${topic}</strong> like this, and I’ve 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())
|
||||||
1
home/doris-dashboard/data/calendar.json
Normal file
1
home/doris-dashboard/data/calendar.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
12
home/doris-dashboard/data/notes.json
Normal file
12
home/doris-dashboard/data/notes.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
32
home/doris-dashboard/data/paperless_review.example.json
Normal file
32
home/doris-dashboard/data/paperless_review.example.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
19
home/doris-dashboard/doris-dashboard.service
Normal file
19
home/doris-dashboard/doris-dashboard.service
Normal 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
|
||||||
13
home/doris-dashboard/public/style.css
Normal file
13
home/doris-dashboard/public/style.css
Normal 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}}
|
||||||
23
home/doris-schoolhouse/.env.example
Normal file
23
home/doris-schoolhouse/.env.example
Normal 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
6
home/doris-schoolhouse/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
/data/
|
||||||
|
/uploads/
|
||||||
|
.venv/
|
||||||
68
home/doris-schoolhouse/DEPLOYMENT.md
Normal file
68
home/doris-schoolhouse/DEPLOYMENT.md
Normal 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
|
||||||
22
home/doris-schoolhouse/Dockerfile
Normal file
22
home/doris-schoolhouse/Dockerfile
Normal 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"]
|
||||||
204
home/doris-schoolhouse/README.md
Normal file
204
home/doris-schoolhouse/README.md
Normal 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
|
||||||
24
home/doris-schoolhouse/app/config.py
Normal file
24
home/doris-schoolhouse/app/config.py
Normal 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()
|
||||||
92
home/doris-schoolhouse/app/db/schema.sql
Normal file
92
home/doris-schoolhouse/app/db/schema.sql
Normal 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);
|
||||||
20
home/doris-schoolhouse/app/main.py
Normal file
20
home/doris-schoolhouse/app/main.py
Normal 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)
|
||||||
61
home/doris-schoolhouse/app/models.py
Normal file
61
home/doris-schoolhouse/app/models.py
Normal 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
|
||||||
0
home/doris-schoolhouse/app/routes/__init__.py
Normal file
0
home/doris-schoolhouse/app/routes/__init__.py
Normal file
148
home/doris-schoolhouse/app/routes/api_assignments.py
Normal file
148
home/doris-schoolhouse/app/routes/api_assignments.py
Normal 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}
|
||||||
31
home/doris-schoolhouse/app/routes/api_dashboard.py
Normal file
31
home/doris-schoolhouse/app/routes/api_dashboard.py
Normal 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],
|
||||||
|
}
|
||||||
69
home/doris-schoolhouse/app/routes/api_paperless.py
Normal file
69
home/doris-schoolhouse/app/routes/api_paperless.py
Normal 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
|
||||||
130
home/doris-schoolhouse/app/routes/api_recordings.py
Normal file
130
home/doris-schoolhouse/app/routes/api_recordings.py
Normal 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}
|
||||||
27
home/doris-schoolhouse/app/routes/api_sync.py
Normal file
27
home/doris-schoolhouse/app/routes/api_sync.py
Normal 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()
|
||||||
24
home/doris-schoolhouse/app/routes/ui.py
Normal file
24
home/doris-schoolhouse/app/routes/ui.py
Normal 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"})
|
||||||
31
home/doris-schoolhouse/app/scripts/apply_schema.py
Normal file
31
home/doris-schoolhouse/app/scripts/apply_schema.py
Normal 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())
|
||||||
27
home/doris-schoolhouse/app/scripts/bootstrap_state.py
Normal file
27
home/doris-schoolhouse/app/scripts/bootstrap_state.py
Normal 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()
|
||||||
44
home/doris-schoolhouse/app/scripts/process_recording.py
Normal file
44
home/doris-schoolhouse/app/scripts/process_recording.py
Normal 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()
|
||||||
139
home/doris-schoolhouse/app/services/d2l.py
Normal file
139
home/doris-schoolhouse/app/services/d2l.py
Normal 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.",
|
||||||
|
}
|
||||||
23
home/doris-schoolhouse/app/services/diagnostics.py
Normal file
23
home/doris-schoolhouse/app/services/diagnostics.py
Normal 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,
|
||||||
|
}
|
||||||
57
home/doris-schoolhouse/app/services/media.py
Normal file
57
home/doris-schoolhouse/app/services/media.py
Normal 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}
|
||||||
109
home/doris-schoolhouse/app/services/paperless.py
Normal file
109
home/doris-schoolhouse/app/services/paperless.py
Normal 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,
|
||||||
|
}
|
||||||
31
home/doris-schoolhouse/app/services/paperless_client.py
Normal file
31
home/doris-schoolhouse/app/services/paperless_client.py
Normal 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})
|
||||||
388
home/doris-schoolhouse/app/services/repository.py
Normal file
388
home/doris-schoolhouse/app/services/repository.py
Normal 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()
|
||||||
53
home/doris-schoolhouse/app/services/store.py
Normal file
53
home/doris-schoolhouse/app/services/store.py
Normal 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]}"
|
||||||
52
home/doris-schoolhouse/app/services/webhooks.py
Normal file
52
home/doris-schoolhouse/app/services/webhooks.py
Normal 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)}
|
||||||
60
home/doris-schoolhouse/app/static/app.css
Normal file
60
home/doris-schoolhouse/app/static/app.css
Normal 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; }
|
||||||
127
home/doris-schoolhouse/app/static/app.js
Normal file
127
home/doris-schoolhouse/app/static/app.js
Normal 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();
|
||||||
23
home/doris-schoolhouse/app/templates/assignment_upload.html
Normal file
23
home/doris-schoolhouse/app/templates/assignment_upload.html
Normal 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 %}
|
||||||
26
home/doris-schoolhouse/app/templates/base.html
Normal file
26
home/doris-schoolhouse/app/templates/base.html
Normal 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>
|
||||||
42
home/doris-schoolhouse/app/templates/dashboard.html
Normal file
42
home/doris-schoolhouse/app/templates/dashboard.html
Normal 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 %}
|
||||||
19
home/doris-schoolhouse/app/templates/recording_upload.html
Normal file
19
home/doris-schoolhouse/app/templates/recording_upload.html
Normal 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 %}
|
||||||
24
home/doris-schoolhouse/bin/apply-postgres-schema.sh
Executable file
24
home/doris-schoolhouse/bin/apply-postgres-schema.sh
Executable 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
|
||||||
8
home/doris-schoolhouse/bin/bootstrap-local.sh
Executable file
8
home/doris-schoolhouse/bin/bootstrap-local.sh
Executable 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
|
||||||
22
home/doris-schoolhouse/bin/check-env.sh
Executable file
22
home/doris-schoolhouse/bin/check-env.sh
Executable 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
|
||||||
12
home/doris-schoolhouse/bin/process-recording.sh
Executable file
12
home/doris-schoolhouse/bin/process-recording.sh
Executable 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"
|
||||||
8
home/doris-schoolhouse/bin/run-local.sh
Executable file
8
home/doris-schoolhouse/bin/run-local.sh
Executable 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}"
|
||||||
21
home/doris-schoolhouse/bin/test-assignment-handoff.sh
Executable file
21
home/doris-schoolhouse/bin/test-assignment-handoff.sh
Executable 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
|
||||||
19
home/doris-schoolhouse/bin/test-recording-handoff.sh
Executable file
19
home/doris-schoolhouse/bin/test-recording-handoff.sh
Executable 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
|
||||||
30
home/doris-schoolhouse/docker-compose.yaml
Normal file
30
home/doris-schoolhouse/docker-compose.yaml
Normal 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
|
||||||
8
home/doris-schoolhouse/requirements.txt
Normal file
8
home/doris-schoolhouse/requirements.txt
Normal 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
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: ghcr.io/chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima
|
container_name: kima
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
- PUID=${PUID}
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
- PGID=${PGID}
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
- KIMA_MUSIC_PATH=/music
|
- 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:
|
volumes:
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/config:/app/config
|
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/app/data
|
- /mnt/unraid/data/media/music:/music
|
||||||
- /mnt/unraid/data/media:/music
|
|
||||||
ports:
|
ports:
|
||||||
- "3333:3333"
|
- "3333:3030"
|
||||||
networks:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
0
meshtastic/scripts/PirateWeatherADV.py
Executable file → Normal file
0
meshtastic/scripts/PirateWeatherADV.py
Executable file → Normal file
0
meshtastic/scripts/mm_wx.py
Executable file → Normal file
0
meshtastic/scripts/mm_wx.py
Executable file → Normal file
6
monitoring/.env.example
Normal file
6
monitoring/.env.example
Normal 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
118
monitoring/DEPLOY.md
Normal 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
236
monitoring/deploy.sh
Normal 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"
|
||||||
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.0.json
Normal file
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.0.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.1.json
Normal file
363
n8n-workflows/01-grafana-alert-ai-gotify-v1.1.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.2.json
Normal file
387
n8n-workflows/01-grafana-alert-ai-gotify-v1.2.json
Normal 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
Reference in New Issue
Block a user