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
118 lines
3.0 KiB
TypeScript
118 lines
3.0 KiB
TypeScript
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`;
|
|
}
|