Files
website/src/lib/home.ts

144 lines
3.8 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;
/** com.docker.compose.project / .service labels (absent on standalone containers). */
project?: string | null;
service?: 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;
};
/** One second of live stats from /start/live (see #lib/server/live-stats). */
export type LiveContainer = {
/** CPU % of one core (like `docker stats`), memory in bytes. */
cpu: number | null;
mem: number | null;
state: string | null;
health: string | null;
};
export type LiveFrame = {
t: number;
/** Host CPU % (all cores = 100) and iowait share. */
cpu: number;
iowait: number;
mem: { used: number; total: number };
/** Bits per second. */
net: { rx: number; tx: number };
/** Bytes per second. */
io: { read: number; write: number };
load: number[];
containers: Record<string, LiveContainer>;
};
export type LiveHistory = Pick<LiveFrame, 't' | 'cpu' | 'mem' | 'net' | 'io'>[];
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`;
}