start: private new-tab dashboard at /start (tasks, notifications, backups/timers, server + containers, service status, trackers, editable links, / search)
All checks were successful
Deploy production / deploy (push) Successful in 2m39s

- home schema: home.links (seeded, owner-editable) and home.snapshots (written by a host collector)
- deploy/home-collector: stdlib Python + systemd user timer (60s) that snapshots /proc stats,
  docker containers, systemd timers, service HTTP checks and ntfy topics via a limited role
- extras/newtab-extension: MV3 new-tab override, zipped to static/newtab-extension.zip
- owner-only pages skip Google Analytics; /trackers/health#body anchor for weigh-ins
This commit is contained in:
2026-10-24 15:21:29 +00:00
parent 61ae72e50b
commit 2bd668bf5e
27 changed files with 18942 additions and 4 deletions

View File

@@ -0,0 +1,31 @@
# home-collector
Feeds the private dashboard at `/start` (also the browser new-tab page). The app container has
no Docker socket and must not get one, so a small stdlib-only Python script runs on the host
every minute (systemd **user** timer `home-collector.timer`) and upserts two rows into
`home.snapshots`:
| key | contents |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host` | CPU %, load, uptime, RAM, swap, disk; every Docker container (state, health, restart count, CPU/mem via `sudo docker`); systemd timers (`stan-backup*`, `postgres-backup*`, `disk-guard-*`, `house-scrape`, `b2-cost`, `groceries-import`, `money-sync`, `ned-collector-*`) with last/next run and result; HTTP up/down + latency for each self-hosted service; latest `~/backups-cost/daily/*.json` |
| `ntfy` | the last 40 messages (72 h) from the ntfy topics in `~/.config/disk-guard.env` (`NTFY_URL`) and `~/.config/house-scrape.env` (`HOUSE_NTFY_URL`), plus `NTFY_EXTRA`. Only a label is stored, never the topic name. |
The page reads both rows in the same parallel query batch as tasks and links, so a slow
upstream never delays it; a snapshot older than 3 minutes is shown as stale.
## Setup
1. A login role that may only upsert snapshots (created once, cluster superuser):
`CREATE ROLE home_collector LOGIN CONNECTION LIMIT 3 PASSWORD '…'; GRANT CONNECT ON DATABASE personal TO home_collector;`
The `home` migration grants `USAGE` on the schema and `SELECT, INSERT, UPDATE` on
`home.snapshots` when the role exists (re-run those two GRANTs by hand if the role is created later).
2. `~/.config/home-collector.env` (mode 600):
```
HOME_DB_URL=postgresql://home_collector:…@127.0.0.1:5440/personal
# optional extra topics, e.g. the backup alerts from /etc/stan-backup:
# NTFY_EXTRA=backups=https://ntfy.sh/<topic>
```
3. `bash deploy/home-collector/install.sh` (copies the script to `~/.local/bin/home-collector`
and enables the timer). Re-run after editing the script, e.g. to add a service to `SERVICES`.
Debug: `python3 ~/.local/bin/home-collector --print`, `journalctl --user -u home-collector -n 20`.

View File

@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Host-side collector for the /start dashboard on stanrunge.dev.
Runs every minute as a systemd user timer on the server (home-collector.timer) and upserts
two rows into home.snapshots with the limited `home_collector` role:
host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts,
CPU/mem), systemd timer results (backups, disk-guard, scrapers) and service checks
ntfy the most recent messages from the owner's ntfy topics
The app container has no Docker socket and never gets one; this script uses the host's
passwordless `sudo docker`. ntfy topic names stay on the host: only a label is stored.
Config: ~/.config/home-collector.env (mode 600)
HOME_DB_URL=postgres://home_collector:...@127.0.0.1:5440/<db>
NTFY_EXTRA=label=https://ntfy.sh/topic,... (optional extra topics, e.g. backups)
Topics are also read from ~/.config/disk-guard.env (NTFY_URL) and
~/.config/house-scrape.env (HOUSE_NTFY_URL).
Stdlib only. `--print` prints the snapshot instead of writing it; HOME_DB_URL in the
environment overrides the env file (e.g. to fill the dev database).
"""
import concurrent.futures as cf
import json
import os
import secrets
import shutil
import socket
import ssl
import subprocess
import sys
import time
import urllib.request
HOME = os.path.expanduser('~')
CFG = os.path.join(HOME, '.config')
TS = 'stan-vm.tail665403.ts.net'
# (name, link shown on the dashboard, url checked from the host or None = link only)
SERVICES = [
('Website', 'https://stanrunge.dev', 'http://127.0.0.1:3000/login'),
('Gitea', 'https://git.stanrunge.dev', 'http://127.0.0.1:3010/'),
('Immich', 'https://photos.stanrunge.dev', 'http://127.0.0.1:2283/'),
('Umami', 'https://analytics.stanrunge.dev', 'http://127.0.0.1:3110/'),
('pgAdmin', 'https://pgadmin.stanrunge.dev', 'http://127.0.0.1:5050/misc/ping'),
('Vash esports', 'https://esports.vash.software', 'http://127.0.0.1:3100/'),
('Vash staging', 'https://esports-staging.vash.software', 'http://127.0.0.1:3101/'),
('Dozzle logs', 'https://logs.vash.software', 'http://127.0.0.1:3111/'),
('Beeriokart', 'https://beeriokart.stanrunge.dev', 'http://127.0.0.1:3003/'),
('Stuhee', 'https://stuhee.stanrunge.dev', 'http://127.0.0.1:3004/'),
('Dev site', 'https://dev.stanrunge.dev', 'http://127.0.0.1:3002/'),
('Webmin', f'https://{TS}:10000', 'https://100.83.57.113:10000/'),
('Netdata', f'http://{TS}:19999', 'http://127.0.0.1:19999/api/v1/info'),
('Portainer', f'https://{TS}:9443', 'https://100.83.57.113:9443/'),
('ntfy', 'https://ntfy.sh/app', 'https://ntfy.sh/v1/health'),
]
# (label, unit, scope)
TIMERS = [
('Backup (restic → B2)', 'stan-backup', 'system'),
('Backup maintenance', 'stan-backup-maintain', 'system'),
('Postgres dump', 'postgres-backup', 'system'),
('Postgres dump prune', 'postgres-backup-prune', 'system'),
('Disk guard check', 'disk-guard-check', 'user'),
('Disk guard cleanup', 'disk-guard-cleanup', 'user'),
('House scrape', 'house-scrape', 'user'),
('B2 cost', 'b2-cost', 'user'),
('Groceries import', 'groceries-import', 'user'),
('Money sync', 'money-sync', 'user'),
('Energy (NED) hourly', 'ned-collector-hourly', 'user'),
('Energy (NED) daily', 'ned-collector-daily', 'user'),
]
def read_env(path):
out = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
out[k.strip()] = v.strip().strip('"').strip("'")
except OSError:
pass
return out
def run(cmd, timeout=15):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout if r.returncode == 0 else ''
except Exception:
return ''
def cpu_times():
with open('/proc/stat') as f:
parts = [int(x) for x in f.readline().split()[1:]]
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
return idle, sum(parts[:8])
def meminfo():
m = {}
with open('/proc/meminfo') as f:
for line in f:
k, v = line.split(':', 1)
m[k] = int(v.split()[0]) * 1024
return m
def system_stats():
i1, t1 = cpu_times()
time.sleep(1)
i2, t2 = cpu_times()
cpu = 100.0 * (1 - (i2 - i1) / max(1, t2 - t1))
m = meminfo()
with open('/proc/uptime') as f:
uptime = float(f.read().split()[0])
load = list(os.getloadavg())
disks = []
for mount in ('/',):
u = shutil.disk_usage(mount)
disks.append({'mount': mount, 'total': u.total, 'used': u.used, 'free': u.free,
'pct': round(100 * u.used / u.total, 1)})
return {
'hostname': socket.gethostname(),
'cpus': os.cpu_count(),
'cpuPct': round(cpu, 1),
'load': [round(x, 2) for x in load],
'uptimeS': int(uptime),
'mem': {'total': m['MemTotal'], 'available': m['MemAvailable'],
'used': m['MemTotal'] - m['MemAvailable']},
'swap': {'total': m.get('SwapTotal', 0), 'used': m.get('SwapTotal', 0) - m.get('SwapFree', 0)},
'disks': disks,
}
def containers():
ids = run(['sudo', '-n', 'docker', 'ps', '-aq']).split()
if not ids:
return []
try:
info = json.loads(run(['sudo', '-n', 'docker', 'inspect', *ids]) or '[]')
except ValueError:
info = []
stats = {}
for line in run(['sudo', '-n', 'docker', 'stats', '--no-stream', '--format', '{{json .}}'], 20).splitlines():
try:
s = json.loads(line)
stats[s['Name']] = {'cpu': s.get('CPUPerc'), 'mem': s.get('MemUsage', '').split(' / ')[0]}
except (ValueError, KeyError):
pass
out = []
for c in info:
st = c.get('State', {})
name = c.get('Name', '').lstrip('/')
out.append({
'name': name,
'image': c.get('Config', {}).get('Image', '').split('@')[0][:80],
'state': st.get('Status'),
'health': (st.get('Health') or {}).get('Status'),
'restarts': c.get('RestartCount', 0),
'exitCode': st.get('ExitCode'),
'startedAt': st.get('StartedAt'),
'cpu': stats.get(name, {}).get('cpu'),
'mem': stats.get(name, {}).get('mem'),
})
out.sort(key=lambda c: c['name'])
return out
def unit_props(unit, scope, props):
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + [
'show', unit, '-p', ','.join(props)]
d = {}
for line in run(cmd).splitlines():
if '=' in line:
k, v = line.split('=', 1)
d[k] = v
return d
def list_timers(scope):
cmd = ['systemctl'] + (['--user'] if scope == 'user' else []) + ['list-timers', '--all', '-o', 'json']
try:
return {t['unit']: t for t in json.loads(run(cmd) or '[]')}
except (ValueError, KeyError, TypeError):
return {}
def sec(usec):
try:
n = int(usec or 0)
return n // 1_000_000 if n > 0 else None
except (ValueError, TypeError):
return None
def timers():
lists = {'user': list_timers('user'), 'system': list_timers('system')}
out = []
for label, unit, scope in TIMERS + [('Home collector', 'home-collector', 'user')]:
t = lists[scope].get(unit + '.timer')
if not t:
continue
s = unit_props(unit + '.service', scope, ['ActiveState', 'Result', 'ExecMainStatus'])
out.append({
'label': label, 'unit': unit, 'scope': scope,
'last': sec(t.get('last')),
'next': sec(t.get('next')),
'running': s.get('ActiveState') in ('activating', 'active', 'deactivating'),
'result': s.get('Result'),
'exitStatus': int(s.get('ExecMainStatus') or 0),
})
return out
_insecure = ssl.create_default_context()
_insecure.check_hostname = False
_insecure.verify_mode = ssl.CERT_NONE
def check(service):
name, link, url = service
res = {'name': name, 'url': link, 'ok': None, 'code': None, 'ms': None}
if not url:
return res
start = time.monotonic()
try:
req = urllib.request.Request(url, headers={'User-Agent': 'home-collector'})
with urllib.request.urlopen(req, timeout=5, context=_insecure) as r:
r.read(256)
res['code'] = r.status
except urllib.error.HTTPError as e:
res['code'] = e.code
except Exception:
res['code'] = 0
res['ms'] = round((time.monotonic() - start) * 1000)
res['ok'] = bool(res['code']) and res['code'] < 500
return res
def services():
with cf.ThreadPoolExecutor(8) as ex:
return list(ex.map(check, SERVICES))
def b2_cost():
d = os.path.join(HOME, 'backups-cost', 'daily')
try:
files = sorted(f for f in os.listdir(d) if f.endswith('.json'))
if files:
with open(os.path.join(d, files[-1])) as f:
return json.load(f)
except (OSError, ValueError):
pass
return None
def ntfy_topics(cfg):
topics = []
for label, path, key in (('disk-guard', 'disk-guard.env', 'NTFY_URL'),
('houses', 'house-scrape.env', 'HOUSE_NTFY_URL')):
url = read_env(os.path.join(CFG, path)).get(key)
if url:
topics.append((label, url))
for item in (cfg.get('NTFY_EXTRA') or '').split(','):
if '=' in item:
label, url = item.split('=', 1)
topics.append((label.strip(), url.strip()))
return topics
def ntfy(cfg):
msgs, seen, errors = [], set(), []
for label, url in ntfy_topics(cfg):
if url in seen:
continue
seen.add(url)
try:
req = urllib.request.Request(url.rstrip('/') + '/json?poll=1&since=72h',
headers={'User-Agent': 'home-collector'})
with urllib.request.urlopen(req, timeout=8) as r:
for line in r.read().decode('utf-8', 'replace').splitlines():
try:
m = json.loads(line)
except ValueError:
continue
if m.get('event') != 'message':
continue
msgs.append({'id': m.get('id'), 'topic': label, 'time': m.get('time'),
'title': (m.get('title') or '')[:200],
'message': (m.get('message') or '')[:600],
'priority': m.get('priority', 3), 'tags': m.get('tags') or [],
'click': m.get('click')})
except Exception:
errors.append(label)
msgs.sort(key=lambda m: m['time'] or 0, reverse=True)
return {'messages': msgs[:40], 'topics': [t for t, _ in ntfy_topics(cfg)], 'errors': errors}
def write(db_url, rows):
tag = 'j' + secrets.token_hex(8)
sql = ['BEGIN;']
for key, data in rows.items():
payload = json.dumps(data, separators=(',', ':'))
sql.append(
f"INSERT INTO home.snapshots (key, data, collected_at) VALUES ('{key}', ${tag}${payload}${tag}$::jsonb, now()) "
"ON CONFLICT (key) DO UPDATE SET data = excluded.data, collected_at = excluded.collected_at;")
sql.append('COMMIT;')
r = subprocess.run(['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', db_url], input='\n'.join(sql),
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
# psql errors never contain the password; the URL is not echoed.
sys.stderr.write(r.stderr[-500:])
sys.exit(1)
def main():
cfg = read_env(os.path.join(CFG, 'home-collector.env'))
with cf.ThreadPoolExecutor(4) as ex:
f_sys = ex.submit(system_stats)
f_ct = ex.submit(containers)
f_svc = ex.submit(services)
f_ntfy = ex.submit(ntfy, cfg)
host = f_sys.result()
host['containers'] = f_ct.result()
host['services'] = f_svc.result()
host['timers'] = timers()
host['b2'] = b2_cost()
notes = f_ntfy.result()
rows = {'host': host, 'ntfy': notes}
if '--print' in sys.argv:
print(json.dumps(rows, indent=1))
return
url = os.environ.get('HOME_DB_URL') or cfg.get('HOME_DB_URL')
if not url:
sys.exit('HOME_DB_URL missing in ~/.config/home-collector.env')
write(url, rows)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Snapshot server stats, containers, timers and ntfy for stanrunge.dev/start
Wants=network-online.target
After=network-online.target
ConditionPathExists=%h/.config/home-collector.env
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 %h/.local/bin/home-collector
TimeoutStartSec=50
Nice=10

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run home-collector every minute (stanrunge.dev/start)
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Install/refresh the /start collector as a systemd user timer (run as stan on the server).
# Needs ~/.config/home-collector.env with HOME_DB_URL for the home_collector role (see README).
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
install -D -m 755 "$here/home-collector.py" "$HOME/.local/bin/home-collector"
install -D -m 644 "$here/home-collector.service" "$HOME/.config/systemd/user/home-collector.service"
install -D -m 644 "$here/home-collector.timer" "$HOME/.config/systemd/user/home-collector.timer"
systemctl --user daemon-reload
systemctl --user enable --now home-collector.timer
systemctl --user start home-collector.service || true
systemctl --user --no-pager status home-collector.service | head -5

View File

@@ -23,7 +23,8 @@ export default defineConfig({
'health',
'books',
'tasks',
'groceries'
'groceries',
'home'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -0,0 +1,66 @@
CREATE SCHEMA "home";
--> statement-breakpoint
CREATE TABLE "home"."links" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"url" text NOT NULL,
"section" text DEFAULT 'links' NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "links_title_check" CHECK (length(btrim("title")) between 1 and 80),
CONSTRAINT "links_url_check" CHECK ("url" ~ '^https?://' and length("url") <= 2000)
);
--> statement-breakpoint
CREATE TABLE "home"."snapshots" (
"key" text PRIMARY KEY,
"data" jsonb NOT NULL,
"collected_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "links_sort_idx" ON "home"."links" ("section","sort_order");--> statement-breakpoint
-- Privacy: like tasks and health, the start page is the app role only (no personal_ro).
-- Grants only run on prod, where migrations run as `personal`.
REVOKE ALL ON SCHEMA home FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA home TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA home TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA home TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA home
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
-- The host-side collector (deploy/home-collector) may only upsert snapshots. The role is
-- created out of band (it has a password); see deploy/home-collector/README.md.
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'home_collector') THEN
GRANT USAGE ON SCHEMA home TO home_collector;
GRANT SELECT, INSERT, UPDATE ON home.snapshots TO home_collector;
END IF;
END $$;
--> statement-breakpoint
-- Starter links; edited inline on /start.
INSERT INTO "home"."links" ("title", "url", "section", "sort_order") VALUES
('Gmail', 'https://mail.google.com/mail/u/0/', 'daily', 10),
('Calendar', 'https://calendar.google.com/', 'daily', 20),
('WhatsApp', 'https://web.whatsapp.com/', 'daily', 30),
('Discord', 'https://discord.com/app', 'daily', 40),
('X', 'https://x.com/home', 'daily', 50),
('YouTube', 'https://www.youtube.com/', 'daily', 60),
('Spotify', 'https://open.spotify.com/', 'daily', 70),
('Drive', 'https://drive.google.com/', 'daily', 80),
('Moneybird', 'https://moneybird.com/390525278507501515', 'money', 110),
('Revolut', 'https://app.revolut.com/', 'money', 120),
('Knab', 'https://www.knab.nl/', 'money', 130),
('Tikkie', 'https://www.tikkie.me/', 'money', 140),
('Gitea', 'https://git.stanrunge.dev/', 'dev', 210),
('GitHub', 'https://github.com/', 'dev', 220),
('Cloudflare', 'https://dash.cloudflare.com/', 'dev', 230),
('Tailscale', 'https://login.tailscale.com/admin/machines', 'dev', 240),
('Backblaze B2', 'https://secure.backblaze.com/b2_buckets.htm', 'dev', 250),
('1Password', 'https://my.1password.com/', 'dev', 260),
('osu!', 'https://osu.ppy.sh/', 'fun', 310),
('Twitch', 'https://www.twitch.tv/', 'fun', 320);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
# New-tab extension
A minimal Chrome / Edge / Brave (Manifest V3) extension that makes every new tab open
[stanrunge.dev/start](https://stanrunge.dev/start), the private dashboard. No permissions,
no tracking: the new-tab page just redirects.
## Install (load unpacked)
1. Download and unzip <https://stanrunge.dev/newtab-extension.zip> (or use this folder from the repo).
2. Open `chrome://extensions` and switch on **Developer mode** (top right).
3. Click **Load unpacked** and pick the unzipped `newtab-extension` folder.
4. Open a new tab. Chrome asks once whether to keep the changed new-tab page: choose **Keep it**.
Log in on stanrunge.dev once in that browser; after that the dashboard opens straight away.
To point it elsewhere, edit the URL in `newtab.html` and `newtab.js`, then press the reload
icon on the extension card. Firefox: set the homepage/new tab with an add-on such as
"New Tab Override" instead.
Rebuild the zip after changes: `bash extras/newtab-extension/build.sh`.

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Zip the extension into static/newtab-extension.zip (served at /newtab-extension.zip).
set -euo pipefail
cd "$(dirname "$0")/.."
python3 - <<'PY'
import os, zipfile
out = '../static/newtab-extension.zip'
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
for name in sorted(os.listdir('newtab-extension')):
if name != 'build.sh':
z.write(os.path.join('newtab-extension', name))
print('wrote static/newtab-extension.zip')
PY

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,8 @@
{
"manifest_version": 3,
"name": "Stan's start page",
"version": "1.0.0",
"description": "Opens stanrunge.dev/start in every new tab.",
"chrome_url_overrides": { "newtab": "newtab.html" },
"icons": { "128": "icon128.png" }
}

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>New tab</title>
<!-- Redirect before anything paints; newtab.js does the same as a fallback. -->
<meta http-equiv="refresh" content="0; url=https://stanrunge.dev/start" />
<style>
html {
background: #000;
color-scheme: dark;
}
</style>
<script src="newtab.js"></script>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,3 @@
// MV3 pages can't run inline scripts, so the redirect lives here. Change the URL to point the
// new tab somewhere else (then reload the extension on chrome://extensions).
location.replace('https://stanrunge.dev/start');

View File

@@ -24,6 +24,7 @@
document.querySelector('meta[name="theme-color"]').content =
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
</script>
<!-- analytics -->
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
<script>
@@ -35,6 +36,7 @@
gtag('config', 'G-7K10F5HJMQ');
</script>
<!-- /analytics -->
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />

View File

@@ -4,6 +4,8 @@ import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
import { error } from '@sveltejs/kit';
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
export const handle: Handle = async ({ event, resolve }) => {
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
// Better Auth validates request origins against its configured public URL.
@@ -21,7 +23,12 @@ export const handle: Handle = async ({ event, resolve }) => {
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
error(404, 'Not Found');
const response = await resolve(event);
// Private pages (the /start new-tab dashboard, trackers) skip Google Analytics: less to
// load, and nothing private gets reported.
const response = await resolve(
event,
ownerOnly ? { transformPageChunk: ({ html }) => html.replace(analytics, '') } : undefined
);
if (ownerOnly) {
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
response.headers.set('cache-control', 'private, no-store');

49
src/lib/home.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { ago, bytes, linkInput, timerStatus, type TimerState } from './home';
describe('linkInput', () => {
it('adds https:// and lowercases the section', () => {
expect(linkInput.parse({ title: ' Gmail ', url: 'mail.google.com', section: 'Daily' })).toEqual(
{
title: 'Gmail',
url: 'https://mail.google.com',
section: 'daily'
}
);
});
it('rejects other schemes and empty titles', () => {
expect(linkInput.safeParse({ title: 'x', url: 'javascript:alert(1)' }).success).toBe(false);
expect(linkInput.safeParse({ title: ' ', url: 'https://a.b' }).success).toBe(false);
});
it('defaults the section', () => {
expect(linkInput.parse({ title: 'a', url: 'https://a.b', section: '' }).section).toBe('links');
});
});
describe('timerStatus', () => {
const t: TimerState = {
label: 'x',
unit: 'x',
scope: 'user',
last: 100,
next: 1000,
running: false,
result: 'success',
exitStatus: 0
};
it('flags failures and late timers', () => {
expect(timerStatus(t, 1000)).toBe('ok');
expect(timerStatus({ ...t, result: 'exit-code' }, 1000)).toBe('fail');
expect(timerStatus(t, 1000 + 16 * 60)).toBe('late');
expect(timerStatus({ ...t, running: true }, 99999)).toBe('running');
});
});
describe('formatting', () => {
it('formats bytes and relative times', () => {
expect(bytes(8 * 1024 ** 3)).toBe('8.0 GB');
expect(ago(0, 30_000)).toBe('now');
expect(ago(0, 5 * 60_000)).toBe('5m ago');
expect(ago(3 * 3600_000, 0)).toBe('in 3h');
});
});

117
src/lib/home.ts Normal file
View File

@@ -0,0 +1,117 @@
import * as z from 'zod';
// Shared types and validation for /start (the owner's dashboard / new-tab page).
export const linkInput = z.object({
title: z.string().trim().min(1, 'Give the link a title').max(80, 'Title is too long'),
url: z
.string()
.trim()
.transform((u) => (/^[a-z][a-z0-9+.-]*:/i.test(u) ? u : `https://${u}`))
.pipe(
z
.string()
.max(2000)
.regex(/^https?:\/\/[^\s]+$/i, 'Use an http(s) URL')
),
section: z
.string()
.trim()
.max(40)
.transform((s) => s.toLowerCase() || 'links')
.default('links')
});
export type Container = {
name: string;
image: string;
state: string | null;
health: string | null;
restarts: number;
exitCode: number | null;
startedAt: string | null;
cpu: string | null;
mem: string | null;
};
export type TimerState = {
label: string;
unit: string;
scope: 'user' | 'system';
last: number | null;
next: number | null;
running: boolean;
result: string | null;
exitStatus: number;
};
export type ServiceCheck = {
name: string;
url: string;
ok: boolean | null;
code: number | null;
ms: number | null;
};
export type HostSnapshot = {
hostname: string;
cpus: number;
cpuPct: number;
load: number[];
uptimeS: number;
mem: { total: number; used: number; available: number };
swap: { total: number; used: number };
disks: { mount: string; total: number; used: number; free: number; pct: number }[];
containers: Container[];
services: ServiceCheck[];
timers: TimerState[];
b2: Record<string, unknown> | null;
};
export type NtfyMessage = {
id: string;
topic: string;
time: number;
title: string;
message: string;
priority: number;
tags: string[];
click: string | null;
};
export type NtfySnapshot = { messages: NtfyMessage[]; topics: string[]; errors: string[] };
/** A timer's health: failed if the last run failed, late if it should have run by now. */
export function timerStatus(t: TimerState, nowS: number): 'ok' | 'fail' | 'late' | 'running' {
if (t.running) return 'running';
if (t.result && t.result !== 'success') return 'fail';
if (t.next && nowS - t.next > 15 * 60) return 'late';
return 'ok';
}
export function bytes(n: number): string {
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) {
n /= 1024;
i++;
}
return `${n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)} ${u[i]}`;
}
/** Compact relative time: "now", "5m", "3h", "2d" (past) or "in 5m" (future). */
export function ago(ms: number, now: number): string {
const d = Math.round((now - ms) / 1000);
const a = Math.abs(d);
const s =
a < 45
? 'now'
: a < 3600
? `${Math.round(a / 60)}m`
: a < 86400 * 2
? `${Math.round(a / 3600)}h`
: `${Math.round(a / 86400)}d`;
if (s === 'now') return s;
return d < 0 ? `in ${s}` : `${s} ago`;
}
export function duration(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
return d ? `${d}d ${h}h` : `${h}h ${Math.floor((seconds % 3600) / 60)}m`;
}

View File

@@ -0,0 +1,37 @@
import { sql } from 'drizzle-orm';
import { check, index, integer, jsonb, text } from 'drizzle-orm/pg-core';
import { homeSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// The owner's start page (/start, also his browser new-tab page). Owner only, no user_id:
// like tasks and health the schema is granted to personal_app only (no personal_ro).
/** Personal quick links on /start: editable, reorderable, grouped by `section`. */
export const homeLinks = homeSchema.table(
'links',
{
id: id(),
title: text('title').notNull(),
url: text('url').notNull(),
section: text('section').notNull().default('links'),
sortOrder: integer('sort_order').notNull().default(0),
...timestamps
},
(t) => [
index('links_sort_idx').on(t.section, t.sortOrder),
check('links_title_check', sql`length(btrim(${t.title})) between 1 and 80`),
check('links_url_check', sql`${t.url} ~ '^https?://' and length(${t.url}) <= 2000`)
]
);
/**
* Snapshots written by the host-side collector (deploy/home-collector, a systemd user timer
* on the server): `host` = CPU/RAM/disk/containers/timers/service checks, `ntfy` = recent
* notifications. One row per key, overwritten each run; the collector role (home_collector)
* may only upsert here.
*/
export const homeSnapshots = homeSchema.table('snapshots', {
key: text('key').primaryKey(),
data: jsonb('data').notNull(),
collectedAt: timestamptz('collected_at').notNull().defaultNow()
});

View File

@@ -16,3 +16,4 @@ export * from './health';
export * from './books';
export * from './tasks';
export * from './groceries';
export * from './home';

View File

@@ -16,3 +16,4 @@ export const healthSchema = pgSchema('health');
export const booksSchema = pgSchema('books');
export const tasksSchema = pgSchema('tasks');
export const groceriesSchema = pgSchema('groceries');
export const homeSchema = pgSchema('home');

View File

@@ -20,6 +20,7 @@ export function requireOwner(event: OwnerEvent) {
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
export const ownerOnlyPrefixes = [
'/start',
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',
@@ -39,6 +40,7 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
*/
export const ownerWritePrefixes = [
'/start',
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',

View File

@@ -31,7 +31,10 @@
let { data, children } = $props();
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
// The book reader is a full-screen view; the site header and footer would sit on top of it.
const immersive = $derived(/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname));
const immersive = $derived(
/^\/trackers\/books\/\d+\/read\/?$/.test(page.url.pathname) ||
/^\/start\/?$/.test(page.url.pathname)
);
let menuOpen = $state(false);
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
let openMenu = $state<string | null>(null);

View File

@@ -0,0 +1,189 @@
import { db } from '#lib/server/db/index.js';
import {
healthBodyMeasurements,
homeLinks,
homeSnapshots,
todoActivity,
todoTasks
} from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { parseQuickTitle, statusTimestamps, todayIn } from '#lib/server/tasks.js';
import { linkInput, type HostSnapshot, type NtfySnapshot } from '#lib/home.js';
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// /start: the owner's dashboard and browser new-tab page. Everything comes from Postgres in
// one round of parallel queries (server stats and ntfy messages are written there by the
// host-side collector, deploy/home-collector), so no slow upstream can block the page.
const OPEN = ['todo', 'in_progress'] as const;
export const load: PageServerLoad = async (event) => {
// Logged out: go log in and come back (it's a new-tab page). Logged in as anyone else: 404.
if (!event.locals.user) redirect(303, '/login?returnTo=/start');
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
event.depends('home:live');
const today = todayIn();
const [tasks, openCount, links, snapshots, weight] = await Promise.all([
db
.select({
id: todoTasks.id,
title: todoTasks.title,
status: todoTasks.status,
priority: todoTasks.priority,
flagged: todoTasks.flagged,
dueDate: todoTasks.dueDate,
dueTime: todoTasks.dueTime
})
.from(todoTasks)
.where(
and(
inArray(todoTasks.status, OPEN),
sql`${todoTasks.parentId} IS NULL`,
or(
lte(todoTasks.dueDate, today),
eq(todoTasks.flagged, true),
eq(todoTasks.status, 'in_progress')
)
)
)
.orderBy(
sql`${todoTasks.dueDate} IS NULL`,
asc(todoTasks.dueDate),
desc(todoTasks.priority),
asc(todoTasks.sortOrder),
asc(todoTasks.id)
)
.limit(40),
db
.select({ n: sql<number>`count(*)::int` })
.from(todoTasks)
.where(inArray(todoTasks.status, OPEN)),
db
.select({
id: homeLinks.id,
title: homeLinks.title,
url: homeLinks.url,
section: homeLinks.section
})
.from(homeLinks)
.orderBy(asc(homeLinks.sortOrder), asc(homeLinks.id)),
db.select().from(homeSnapshots),
db
.select({ date: healthBodyMeasurements.date, kg: healthBodyMeasurements.weightKg })
.from(healthBodyMeasurements)
.where(isNotNull(healthBodyMeasurements.weightKg))
.orderBy(desc(healthBodyMeasurements.date), desc(healthBodyMeasurements.id))
.limit(1)
]);
const snap = (key: string) => snapshots.find((s) => s.key === key);
const host = snap('host');
const ntfy = snap('ntfy');
return {
today,
now: Date.now(),
tasks,
openCount: openCount[0]?.n ?? 0,
links,
host: host ? { at: host.collectedAt.getTime(), ...(host.data as HostSnapshot) } : null,
ntfy: ntfy ? { at: ntfy.collectedAt.getTime(), ...(ntfy.data as NtfySnapshot) } : null,
weight: weight[0] ?? null,
seo: { title: 'Start', description: 'Private start page.', index: false }
};
};
const intId = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
function owner(event: Parameters<typeof requireOwner>[0]) {
if (!isOwner(event)) return false;
return true;
}
export const actions: Actions = {
/** One-click complete (or reopen with status=todo). */
task: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const id = intId(form.get('id'));
const status = form.get('status') === 'todo' ? 'todo' : 'done';
if (!id) return fail(400, { message: 'Invalid task' });
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, id));
if (!prev) return fail(404, { message: 'Task not found' });
if (prev.status === status) return { ok: true };
await db.transaction(async (tx) => {
await tx
.update(todoTasks)
.set({ status, ...statusTimestamps(status, prev) })
.where(eq(todoTasks.id, id));
await tx
.insert(todoActivity)
.values({ taskId: id, kind: 'status', fromStatus: prev.status, toStatus: status });
});
return { ok: true };
},
/** Quick add: "!"/"!!"/"!!!" set flag/priority (as on /trackers/tasks); due today by default. */
addTask: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const { title, flagged, priority } = parseQuickTitle(String(form.get('title') ?? ''));
if (!title || title.length > 500) return fail(400, { message: 'Give the task a title' });
const due = form.get('someday') === 'on' ? null : todayIn();
const [row] = await db
.insert(todoTasks)
.values({ title, flagged, priority, dueDate: due, source: 'manual' })
.returning({ id: todoTasks.id });
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: 'todo' });
return { ok: true };
},
saveLink: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const form = await event.request.formData();
const parsed = linkInput.safeParse(Object.fromEntries(form));
if (!parsed.success)
return fail(400, { message: parsed.error.issues[0]?.message ?? 'Invalid link' });
const id = intId(form.get('id'));
if (id) {
await db.update(homeLinks).set(parsed.data).where(eq(homeLinks.id, id));
} else {
const [m] = await db
.select({ max: sql<number>`coalesce(max(${homeLinks.sortOrder}), 0)::int` })
.from(homeLinks);
await db.insert(homeLinks).values({ ...parsed.data, sortOrder: (m?.max ?? 0) + 10 });
}
return { ok: true };
},
deleteLink: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const id = intId((await event.request.formData()).get('id'));
if (!id) return fail(400, { message: 'Invalid link' });
await db.delete(homeLinks).where(eq(homeLinks.id, id));
return { ok: true };
},
/** Reorder: `order` is the full list of link ids in their new order. */
orderLinks: async (event) => {
if (!owner(event)) return fail(404, { message: 'Not Found' });
const raw = String((await event.request.formData()).get('order') ?? '');
const ids = raw
.split(',')
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (!ids.length || ids.length > 500) return fail(400, { message: 'Invalid order' });
await db.transaction(async (tx) => {
for (const [i, id] of ids.entries())
await tx
.update(homeLinks)
.set({ sortOrder: (i + 1) * 10 })
.where(eq(homeLinks.id, id));
});
return { ok: true };
}
};

View File

@@ -0,0 +1,846 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js';
let { data, form } = $props();
// Ticks every 30 s for relative times; starts at the server's clock so hydration matches, and resets on every reload.
let now = $derived(data.now);
let query = $state('');
let editing = $state(false);
let editId = $state<number | null>(null);
let searchEl = $state<HTMLInputElement>();
const trackers = [
{ name: 'Tasks', href: '/trackers/tasks' },
{ name: 'Money', href: '/trackers/money' },
{ name: 'Health', href: '/trackers/health' },
{ name: 'Weigh-in', href: '/trackers/health#body' },
{ name: 'Books', href: '/trackers/books' },
{ name: 'Houses', href: '/trackers/houses' },
{ name: 'Groceries', href: '/trackers/groceries' },
{ name: 'Energy', href: '/projects/energy' },
{ name: 'Lists', href: '/lists' },
{ name: 'DB', href: '/lists/db' }
];
const host = $derived(data.host);
const stale = $derived(!host || now - host.at > 3 * 60_000);
const containers = $derived(
(host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
);
const containersUp = $derived(containers.filter((c) => c.state === 'running').length);
const servicesDown = $derived((host?.services ?? []).filter((s) => s.ok === false));
const timers = $derived(
(host?.timers ?? []).map((t) => ({ ...t, status: timerStatus(t, Math.floor(now / 1000)) }))
);
const timerProblems = $derived(timers.filter((t) => t.status === 'fail' || t.status === 'late'));
const disk = $derived(host?.disks[0]);
const memPct = $derived(host ? (100 * host.mem.used) / host.mem.total : 0);
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
const sections = $derived.by(() => {
const groups: [string, typeof data.links][] = [];
for (const l of data.links) {
const g = groups.find(([name]) => name === l.section);
if (g) g[1].push(l);
else groups.push([l.section, [l]]);
}
return groups;
});
type Hit = { name: string; href: string; kind: string };
const everything = $derived<Hit[]>([
...data.links.map((l) => ({ name: l.title, href: l.url, kind: l.section })),
...(host?.services ?? []).map((s) => ({ name: s.name, href: s.url, kind: 'service' })),
...trackers.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' }))
]);
const hits = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return everything
.filter((h) => h.name.toLowerCase().includes(q) || h.href.toLowerCase().includes(q))
.sort(
(a, b) =>
Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q))
)
.slice(0, 8);
});
$effect(() => {
// Live refresh: re-run the load (one DB round trip) every 60 s while the tab is visible.
const tick = setInterval(() => (now = Date.now()), 30_000);
const refresh = setInterval(() => {
if (document.visibilityState === 'visible') invalidate('home:live');
}, 60_000);
return () => {
clearInterval(tick);
clearInterval(refresh);
};
});
function onVisible() {
if (document.visibilityState === 'visible' && Date.now() - data.now > 20_000) {
now = Date.now();
invalidate('home:live');
}
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement;
const typing = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable;
if (e.key === '/' && !typing) {
e.preventDefault();
searchEl?.focus();
}
}
function onSearchKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
query = '';
searchEl?.blur();
} else if (e.key === 'Enter') {
const q = query.trim();
if (!q) return;
const target = hits[0]?.href ?? `https://www.google.com/search?q=${encodeURIComponent(q)}`;
if (e.metaKey || e.ctrlKey) window.open(target, '_blank');
else location.href = target;
}
}
function move(id: number, dir: -1 | 1) {
const ids = data.links.map((l) => l.id);
const i = ids.indexOf(id);
const j = i + dir;
if (i < 0 || j < 0 || j >= ids.length) return '';
[ids[i], ids[j]] = [ids[j], ids[i]];
return ids.join(',');
}
function host_(url: string) {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return url;
}
}
function containerDot(c: Container) {
if (c.state !== 'running') return c.state === 'exited' && c.exitCode === 0 ? 'off' : 'bad';
if (c.health === 'unhealthy') return 'bad';
if (c.health === 'starting') return 'warn';
return 'ok';
}
function shortName(n: string) {
return n.replace(/-1$/, '').replace(/^personal-website-app/, 'website');
}
const dueLabel = (d: string | null) =>
!d ? '' : d < data.today ? 'overdue' : d === data.today ? 'today' : d.slice(5);
const today = $derived(
new Intl.DateTimeFormat('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'Europe/Amsterdam'
}).format(now)
);
const clock = $derived(
new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
}).format(now)
);
</script>
<svelte:window onkeydown={onKey} />
<svelte:document onvisibilitychange={onVisible} />
<div class="dash">
<header class="top">
<div class="when">
<b>{clock}</b> <span>{today}</span>
</div>
<div class="search">
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={searchEl}
bind:value={query}
onkeydown={onSearchKey}
type="search"
placeholder="Search links, services, trackers… (/), Enter opens, no match = Google"
aria-label="Search"
autocomplete="off"
spellcheck="false"
autofocus
/>
{#if hits.length}
<ul class="hits">
{#each hits as h, i (h.kind + h.href + h.name)}
<li class={{ first: i === 0 }}>
<a href={h.href}><span>{h.name}</span><small>{h.kind}</small></a>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#if host}
<span class={['chip', host.cpuPct > 85 && 'warn']}>CPU {Math.round(host.cpuPct)}%</span>
<span class={['chip', memPct > 90 && 'warn']}>RAM {Math.round(memPct)}%</span>
{#if disk}<span class={['chip', disk.pct >= 85 && 'bad']}>Disk {Math.round(disk.pct)}%</span
>{/if}
<span class={['chip', servicesDown.length && 'bad']}
>{servicesDown.length ? `${servicesDown.length} down` : 'all up'}</span
>
{/if}
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
>{data.weight.kg} kg</a
>{/if}
</div>
</header>
<div class="grid">
<div class="col">
<!-- Tasks -->
<section class="card tasks">
<h2>
<a href="/trackers/tasks">Tasks</a>
<small>{data.tasks.length} now · {data.openCount} open</small>
</h2>
<form method="POST" action="?/addTask" use:enhance class="add">
<input name="title" placeholder="Add a task for today… (! flags, !! / !!! priority)" />
<label title="No due date"><input type="checkbox" name="someday" /> later</label>
</form>
<ul class="list">
{#each data.tasks as t (t.id)}
<li class={{ flagged: t.flagged }}>
<form method="POST" action="?/task" use:enhance>
<input type="hidden" name="id" value={t.id} />
<button class="check" aria-label="Complete {t.title}" title="Complete"></button>
</form>
<span class="title">{t.title}</span>
{#if t.priority >= 2}<span class="pri p{t.priority}">{'!'.repeat(t.priority)}</span
>{/if}
{#if t.status === 'in_progress'}<span class="tag">doing</span>{/if}
{#if t.dueDate}<span class={['tag', t.dueDate < data.today && 'bad']}
>{dueLabel(t.dueDate)}{t.dueTime ? ` ${t.dueTime.slice(0, 5)}` : ''}</span
>{/if}
</li>
{:else}
<li class="empty">Nothing due. Nice.</li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Notifications -->
<section class="card">
<h2>
Notifications
<small
>{data.ntfy?.topics.join(' · ') ?? 'no collector yet'}{#if data.ntfy?.errors.length}
· <span class="bad">{data.ntfy.errors.join(', ')} unreachable</span>{/if}</small
>
</h2>
<ul class="list notes">
{#each data.ntfy?.messages ?? [] as m (m.id)}
<li class={['note', m.priority >= 4 && 'hi']}>
<div class="meta">
<span class="tag">{m.topic}</span>
<time>{ago(m.time * 1000, now)}</time>
</div>
{#if m.click}
<a href={m.click} class="body"
>{#if m.title}<b>{m.title}</b>{/if}
{m.message}</a
>
{:else}
<p class="body">
{#if m.title}<b>{m.title}</b>{/if}
{m.message}
</p>
{/if}
</li>
{:else}
<li class="empty">No notifications in the last 3 days.</li>
{/each}
</ul>
</section>
<!-- Timers & backups -->
<section class="card">
<h2>
Backups & timers
<small class={{ bad: timerProblems.length > 0 }}
>{timerProblems.length ? `${timerProblems.length} need a look` : 'all fine'}</small
>
</h2>
<ul class="list timers">
{#each timers as t (t.unit)}
<li title="{t.unit} ({t.scope}) · result {t.result ?? '?'} · exit {t.exitStatus}">
<i class="dot {t.status === 'ok' ? 'ok' : t.status === 'running' ? 'warn' : 'bad'}"
></i>
<span class="name">{t.label}</span>
<span class="num"
>{t.status === 'running'
? 'running'
: t.last
? ago(t.last * 1000, now)
: 'not since boot'}</span
>
<span class="num dim">{t.next ? ago(t.next * 1000, now) : ''}</span>
</li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Server -->
<section class="card">
<h2>
<a href="http://stan-vm.tail665403.ts.net:19999">Server</a>
<small class={{ bad: stale }}
>{host ? `${host.hostname} · ${ago(host.at, now)}` : 'no snapshot yet'}{stale && host
? ' (stale)'
: ''}</small
>
</h2>
{#if host}
<div class="meters">
{@render meter('CPU', host.cpuPct, `${Math.round(host.cpuPct)}% · ${host.cpus} cores`)}
{@render meter('RAM', memPct, `${bytes(host.mem.used)} / ${bytes(host.mem.total)}`)}
{@render meter(
'Swap',
swapPct,
host.swap.total ? `${bytes(host.swap.used)} / ${bytes(host.swap.total)}` : 'none'
)}
{#each host.disks as d (d.mount)}
{@render meter(
`Disk ${d.mount}`,
d.pct,
`${bytes(d.free)} free of ${bytes(d.total)}`
)}
{/each}
</div>
<p class="sub">
load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
containers
</p>
<ul class="containers">
{#each containers as c (c.name)}
<li title="{c.image} · {c.state}{c.health ? ` (${c.health})` : ''}">
<i class="dot {containerDot(c)}"></i>
<span class="name">{shortName(c.name)}</span>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num">{c.cpu ?? ''}</span>
<span class="num">{c.mem ?? ''}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">The host collector hasn't written a snapshot yet.</p>
{/if}
</section>
<!-- Services -->
<section class="card">
<h2>
Services <small
>{(host?.services.length ?? 0) - servicesDown.length}/{host?.services.length ?? 0} up</small
>
</h2>
<ul class="tiles">
{#each host?.services ?? [] as s (s.name)}
<li>
<a
href={s.url}
title="{host_(s.url)} · {s.code ?? '?'}{s.ms != null ? ` · ${s.ms} ms` : ''}"
>
<i class="dot {s.ok === false ? 'bad' : s.ok ? 'ok' : 'off'}"></i>{s.name}
</a>
</li>
{/each}
</ul>
<h3>Trackers</h3>
<ul class="tiles">
{#each trackers as t (t.href)}
<li><a href={t.href}>{t.name}</a></li>
{/each}
</ul>
</section>
</div>
<div class="col">
<!-- Personal links -->
<section class="card links">
<h2>
Links
<button class="ghost" onclick={() => ((editing = !editing), (editId = null))}
>{editing ? 'done' : 'edit'}</button
>
</h2>
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
{#each sections as [section, links] (section)}
{#if sections.length > 1}<h3>{section}</h3>{/if}
<ul class={editing ? 'list' : 'tiles'}>
{#each links as l (l.id)}
<li>
{#if editing && editId === l.id}
{@render linkForm(l)}
{:else if editing}
<span class="name">{l.title} <small class="dim">{host_(l.url)}</small></span>
{@const up = move(l.id, -1)}
{@const down = move(l.id, 1)}
<form method="POST" action="?/orderLinks" use:enhance class="inline">
<input type="hidden" name="order" value={up} />
<button class="ghost" aria-label="Move up" disabled={!up}>↑</button>
</form>
<form method="POST" action="?/orderLinks" use:enhance class="inline">
<input type="hidden" name="order" value={down} />
<button class="ghost" aria-label="Move down" disabled={!down}>↓</button>
</form>
<button class="ghost" onclick={() => (editId = l.id)}>edit</button>
<form method="POST" action="?/deleteLink" use:enhance class="inline">
<input type="hidden" name="id" value={l.id} />
<button class="ghost bad" aria-label="Delete {l.title}">✕</button>
</form>
{:else}
<a href={l.url} title={l.url}>{l.title}</a>
{/if}
</li>
{/each}
</ul>
{/each}
{#if editing}
<h3>Add a link</h3>
{@render linkForm(null)}
{/if}
</section>
</div>
</div>
</div>
{#snippet meter(label: string, pct: number, detail: string)}
<div class="meter">
<span class="label">{label}</span>
<span class="bar"
><i
class={[pct >= 90 ? 'bad' : pct >= 75 ? 'warn' : 'ok']}
style:width="{Math.min(100, pct)}%"
></i></span
>
<span class="num">{detail}</span>
</div>
{/snippet}
{#snippet linkForm(l: { id: number; title: string; url: string; section: string } | null)}
<form
method="POST"
action="?/saveLink"
class="linkform"
use:enhance={() =>
async ({ result, update }) => {
await update({ reset: !l });
if (result.type === 'success') editId = null;
}}
>
{#if l}<input type="hidden" name="id" value={l.id} />{/if}
<input name="title" placeholder="Title" value={l?.title ?? ''} required maxlength="80" />
<input name="url" placeholder="https://…" value={l?.url ?? ''} required />
<input name="section" placeholder="section" value={l?.section ?? 'links'} maxlength="40" />
<button>{l ? 'save' : 'add'}</button>
{#if l}<button type="button" class="ghost" onclick={() => (editId = null)}>cancel</button>{/if}
</form>
{/snippet}
<style>
.dash {
--line: var(--site-border);
--ok: #3fb950;
--warn: #d29922;
--bad: #f85149;
max-width: 1600px;
margin: 0 auto;
padding: 12px 16px 24px;
font-size: 13px;
line-height: 1.35;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.top {
display: grid;
grid-template-columns: auto minmax(240px, 1fr) auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.when b {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.when span {
color: var(--site-muted);
margin-left: 6px;
}
.search {
position: relative;
}
input:not([type='checkbox']) {
width: 100%;
background: var(--site-surface);
color: var(--site-text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 9px;
font: inherit;
}
input:focus {
outline: 1px solid var(--site-muted);
}
.search input {
padding: 8px 12px;
font-size: 14px;
}
.hits {
position: absolute;
z-index: 10;
left: 0;
right: 0;
top: 100%;
margin-top: 4px;
background: var(--site-surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px;
list-style: none;
}
.hits a {
display: flex;
justify-content: space-between;
padding: 5px 8px;
border-radius: 4px;
}
.hits .first a,
.hits a:hover {
background: var(--site-hover);
text-decoration: none;
}
.hits small,
.dim,
h2 small,
.sub,
.empty,
time {
color: var(--site-muted);
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Four fixed columns (tasks | notifications + timers | server + services | links), two
on laptops, one on phones: no gaps from uneven card heights. */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.col {
display: grid;
gap: 12px;
min-width: 0;
}
@media (max-width: 1280px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.card {
background: var(--site-surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
min-width: 0;
}
h2 {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 8px;
}
h2 small {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
margin-left: auto;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
h3 {
font-size: 11px;
text-transform: uppercase;
color: var(--site-muted);
margin: 10px 0 4px;
letter-spacing: 0.04em;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border-bottom: 1px solid var(--line);
min-width: 0;
}
.list li:last-child {
border-bottom: 0;
}
.list .title,
.list .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.add label {
color: var(--site-muted);
white-space: nowrap;
font-size: 12px;
}
.check {
width: 14px;
height: 14px;
border: 1.5px solid var(--site-muted);
border-radius: 50%;
background: none;
cursor: pointer;
padding: 0;
display: block;
}
.check:hover {
border-color: var(--ok);
background: color-mix(in srgb, var(--ok) 30%, transparent);
}
.flagged .title::before {
content: '⚑ ';
color: var(--warn);
}
.tag {
font-size: 11px;
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
color: var(--site-muted);
white-space: nowrap;
}
.pri {
color: var(--warn);
font-weight: 700;
}
.pri.p3,
.bad,
.tag.bad {
color: var(--bad);
}
.warn,
.tag.warn {
color: var(--warn);
}
.notes {
max-height: 420px;
overflow-y: auto;
}
.note {
flex-direction: column;
align-items: stretch !important;
}
.note .meta {
display: flex;
justify-content: space-between;
}
.note .body {
margin: 2px 0 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.note.hi .body b {
color: var(--bad);
}
.meters {
display: grid;
gap: 5px;
}
.meter {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 8px;
align-items: center;
}
.bar {
height: 7px;
background: var(--site-hover);
border-radius: 4px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
}
.bar .ok {
background: var(--ok);
}
.bar .warn {
background: var(--warn);
}
.bar .bad {
background: var(--bad);
}
.num {
font-variant-numeric: tabular-nums;
color: var(--site-muted);
white-space: nowrap;
font-size: 12px;
}
.sub {
margin: 6px 0;
font-size: 12px;
}
.containers {
display: grid;
gap: 0;
}
.containers li {
display: flex;
align-items: center;
gap: 5px;
padding: 1px 0;
min-width: 0;
}
.containers .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.containers .num {
font-size: 11px;
}
.dot {
display: inline-block;
flex: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--site-muted);
margin-right: 4px;
}
.dot.ok {
background: var(--ok);
}
.dot.warn {
background: var(--warn);
}
.dot.bad {
background: var(--bad);
}
.dot.off {
background: #555;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 4px;
}
.tiles a {
display: flex;
align-items: center;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiles a:hover {
background: var(--site-hover);
text-decoration: none;
}
button {
font: inherit;
color: var(--site-text);
background: var(--site-hover);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
}
button.ghost {
background: none;
border: 0;
padding: 0 4px;
color: var(--site-muted);
}
button.ghost:hover {
color: var(--site-text);
}
h2 button.ghost {
margin-left: auto;
text-transform: none;
font-weight: 400;
}
.inline {
display: contents;
}
.linkform {
display: grid;
grid-template-columns: 1fr 1.6fr 0.8fr auto auto;
gap: 4px;
width: 100%;
}
@media (max-width: 760px) {
.top {
grid-template-columns: 1fr;
}
.chips {
justify-content: flex-start;
}
.grid {
grid-template-columns: 1fr;
}
.linkform {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -187,7 +187,7 @@
</div>
<div class="grid gap-4 lg:grid-cols-3">
<section class="panel lg:col-span-2">
<section id="body" class="panel lg:col-span-2">
{@render sectionHeader('Body', 'body_measurements')}
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
<input type="hidden" name="table" value="body_measurements" />

BIN
static/newtab-extension.zip Normal file

Binary file not shown.