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
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:
31
deploy/home-collector/README.md
Normal file
31
deploy/home-collector/README.md
Normal 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`.
|
||||||
344
deploy/home-collector/home-collector.py
Normal file
344
deploy/home-collector/home-collector.py
Normal 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()
|
||||||
11
deploy/home-collector/home-collector.service
Normal file
11
deploy/home-collector/home-collector.service
Normal 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
|
||||||
10
deploy/home-collector/home-collector.timer
Normal file
10
deploy/home-collector/home-collector.timer
Normal 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
|
||||||
12
deploy/home-collector/install.sh
Executable file
12
deploy/home-collector/install.sh
Executable 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
|
||||||
@@ -23,7 +23,8 @@ export default defineConfig({
|
|||||||
'health',
|
'health',
|
||||||
'books',
|
'books',
|
||||||
'tasks',
|
'tasks',
|
||||||
'groceries'
|
'groceries',
|
||||||
|
'home'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
66
drizzle/20261024150925_home/migration.sql
Normal file
66
drizzle/20261024150925_home/migration.sql
Normal 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);
|
||||||
17149
drizzle/20261024150925_home/snapshot.json
Normal file
17149
drizzle/20261024150925_home/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
19
extras/newtab-extension/README.md
Normal file
19
extras/newtab-extension/README.md
Normal 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`.
|
||||||
13
extras/newtab-extension/build.sh
Executable file
13
extras/newtab-extension/build.sh
Executable 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
|
||||||
BIN
extras/newtab-extension/icon128.png
Normal file
BIN
extras/newtab-extension/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 527 B |
8
extras/newtab-extension/manifest.json
Normal file
8
extras/newtab-extension/manifest.json
Normal 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" }
|
||||||
|
}
|
||||||
17
extras/newtab-extension/newtab.html
Normal file
17
extras/newtab-extension/newtab.html
Normal 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>
|
||||||
3
extras/newtab-extension/newtab.js
Normal file
3
extras/newtab-extension/newtab.js
Normal 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');
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
document.querySelector('meta[name="theme-color"]').content =
|
document.querySelector('meta[name="theme-color"]').content =
|
||||||
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
|
document.documentElement.dataset.theme === 'light' ? '#ffffff' : '#000000';
|
||||||
</script>
|
</script>
|
||||||
|
<!-- analytics -->
|
||||||
<!-- Google tag (gtag.js) -->
|
<!-- Google tag (gtag.js) -->
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7K10F5HJMQ"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
|
|
||||||
gtag('config', 'G-7K10F5HJMQ');
|
gtag('config', 'G-7K10F5HJMQ');
|
||||||
</script>
|
</script>
|
||||||
|
<!-- /analytics -->
|
||||||
|
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { auth } from '#lib/server/auth.js';
|
|||||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
|
// Match the endpoint path here: the dev proxy may terminate HTTPS before Vite.
|
||||||
// Better Auth validates request origins against its configured public URL.
|
// 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';
|
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
|
||||||
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
||||||
error(404, 'Not Found');
|
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) {
|
if (ownerOnly) {
|
||||||
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
|
response.headers.set('x-robots-tag', 'noindex, nofollow, noarchive');
|
||||||
response.headers.set('cache-control', 'private, no-store');
|
response.headers.set('cache-control', 'private, no-store');
|
||||||
|
|||||||
49
src/lib/home.test.ts
Normal file
49
src/lib/home.test.ts
Normal 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
117
src/lib/home.ts
Normal 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`;
|
||||||
|
}
|
||||||
37
src/lib/server/db/schema/home.ts
Normal file
37
src/lib/server/db/schema/home.ts
Normal 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()
|
||||||
|
});
|
||||||
@@ -16,3 +16,4 @@ export * from './health';
|
|||||||
export * from './books';
|
export * from './books';
|
||||||
export * from './tasks';
|
export * from './tasks';
|
||||||
export * from './groceries';
|
export * from './groceries';
|
||||||
|
export * from './home';
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export const healthSchema = pgSchema('health');
|
|||||||
export const booksSchema = pgSchema('books');
|
export const booksSchema = pgSchema('books');
|
||||||
export const tasksSchema = pgSchema('tasks');
|
export const tasksSchema = pgSchema('tasks');
|
||||||
export const groceriesSchema = pgSchema('groceries');
|
export const groceriesSchema = pgSchema('groceries');
|
||||||
|
export const homeSchema = pgSchema('home');
|
||||||
|
|||||||
@@ -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). */
|
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||||
export const ownerOnlyPrefixes = [
|
export const ownerOnlyPrefixes = [
|
||||||
|
'/start',
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
'/trackers/groceries',
|
'/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.
|
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||||
*/
|
*/
|
||||||
export const ownerWritePrefixes = [
|
export const ownerWritePrefixes = [
|
||||||
|
'/start',
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
'/trackers/groceries',
|
'/trackers/groceries',
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
const seo = $derived(getSeo(page.url.pathname, page.data, data.environment));
|
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.
|
// 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);
|
let menuOpen = $state(false);
|
||||||
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
|
||||||
let openMenu = $state<string | null>(null);
|
let openMenu = $state<string | null>(null);
|
||||||
|
|||||||
189
src/routes/start/+page.server.ts
Normal file
189
src/routes/start/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
846
src/routes/start/+page.svelte
Normal file
846
src/routes/start/+page.svelte
Normal 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>
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-3">
|
<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')}
|
{@render sectionHeader('Body', 'body_measurements')}
|
||||||
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
|
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
|
||||||
<input type="hidden" name="table" value="body_measurements" />
|
<input type="hidden" name="table" value="body_measurements" />
|
||||||
|
|||||||
BIN
static/newtab-extension.zip
Normal file
BIN
static/newtab-extension.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user