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:
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 './tasks';
|
||||
export * from './groceries';
|
||||
export * from './home';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user