From 189d5d99c511f5952c8daf31154afb575a0636e3 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sat, 1 Aug 2026 16:53:58 -0400 Subject: [PATCH] Add runtime dashboard configuration --- Containerfile | 1 + README.md | 75 ++++++++++++++ compose.example.yaml | 14 +++ config/default.json | 159 +++++++++++++++++++++++++++++ src/App.tsx | 137 +++++++++++++++++++------ src/config.ts | 198 +++++++++++++++++++++++++++++++++++++ src/data/apps.ts | 59 ----------- src/data/communityInfra.ts | 16 --- src/data/privateApps.ts | 44 --------- src/data/websites.ts | 7 -- src/index.ts | 23 ++++- src/types.ts | 31 ++++++ 12 files changed, 603 insertions(+), 161 deletions(-) create mode 100644 compose.example.yaml create mode 100644 config/default.json create mode 100644 src/config.ts delete mode 100644 src/data/apps.ts delete mode 100644 src/data/communityInfra.ts delete mode 100644 src/data/privateApps.ts delete mode 100644 src/data/websites.ts diff --git a/Containerfile b/Containerfile index 745c0d0..f9244c3 100644 --- a/Containerfile +++ b/Containerfile @@ -4,5 +4,6 @@ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . ENV NODE_ENV=production +ENV WATSUP_CONFIG_PATH=/app/config/default.json EXPOSE 3000 CMD ["bun", "run", "src/index.ts"] diff --git a/README.md b/README.md index c6378fe..76ce661 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,81 @@ watsup is a convenient little dashboard and app launcher for [cute.haus](https:/ In addition to some pleasantries (top 5 stories on Hacker News, weather, search), it shows every website and service I host, with a checkmark reflecting whether they're reachable or not. +## configuration + +The dashboard is configured at runtime from JSON. The bundled +[`config/default.json`](config/default.json) reproduces the default dashboard, +but the container can use any mounted file by setting `WATSUP_CONFIG_PATH`: + +```yaml +services: + watsup: + image: watsup + ports: + - "3000:3000" + environment: + WATSUP_CONFIG_PATH: /run/configs/watsup.json + configs: + - source: watsup-dashboard + target: /run/configs/watsup.json + +configs: + watsup-dashboard: + file: ./watsup.json +``` + +The config contains an ordered list of sections. Add as many `services` and +`widgets` sections as needed: + +```json +{ + "title": "Home", + "sections": [ + { + "type": "widgets", + "columns": 4, + "widgets": [ + { "type": "weather" }, + { "type": "search", "span": 3 } + ] + }, + { + "type": "services", + "title": "Public services", + "columns": 3, + "refreshInterval": 20000, + "services": [ + { + "name": "Example", + "url": "https://example.com", + "healthUrl": "https://example.com/health", + "goodStatuses": [200, 204] + } + ] + }, + { + "type": "services", + "title": "Internal services", + "columns": 4, + "services": [ + { + "name": "Grafana", + "url": "https://grafana.example.com", + "icon": "https://example.com/grafana.png" + } + ] + } + ] +} +``` + +`columns` and widget `span` accept values from 1 through 4. Available widget +types are `weather`, `search`, `lobsters`, and `hacker-news`. Service `icon`, +`healthUrl`, `goodStatuses`, and section `refreshInterval` are optional. Without +`goodStatuses`, any response below HTTP 500 is considered online. The config is +read when `/api/config` is requested, so replacing the file and refreshing the +page applies changes without rebuilding the image. + ## build stuff To install dependencies: diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..5e22f24 --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,14 @@ +services: + watsup: + build: . + ports: + - "3000:3000" + environment: + WATSUP_CONFIG_PATH: /run/configs/watsup.json + configs: + - source: watsup-dashboard + target: /run/configs/watsup.json + +configs: + watsup-dashboard: + file: ./config/default.json diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..9fb9672 --- /dev/null +++ b/config/default.json @@ -0,0 +1,159 @@ +{ + "title": "watsup", + "sections": [ + { + "type": "widgets", + "columns": 4, + "widgets": [ + { "type": "weather" }, + { "type": "search", "span": 3 } + ] + }, + { + "type": "widgets", + "columns": 2, + "widgets": [ + { "type": "lobsters", "title": "Lobsters" }, + { "type": "hacker-news", "title": "Hacker News" } + ] + }, + { + "type": "services", + "title": "Community Apps+Infra", + "columns": 4, + "services": [ + { "name": "atbbs", "url": "https://atbbs.xyz/" }, + { "name": "morsels.blue", "url": "https://morsels.blue/" }, + { + "name": "aly.social", + "url": "https://pds.cute.haus", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bluesky.png" + }, + { + "name": "slingshot", + "url": "https://slingshot.cute.haus", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bluesky.png" + } + ] + }, + { + "type": "services", + "title": "Webpages", + "columns": 3, + "services": [ + { "name": "Aly Raffauf", "url": "https://aly.codes/" }, + { "name": "Switchyard", "url": "https://switchyard.aly.codes/" }, + { "name": "Morgan Tamayo", "url": "https://morgantamayo.com/" } + ] + }, + { + "type": "services", + "title": "Apps", + "columns": 4, + "services": [ + { + "name": "Pocket ID", + "url": "https://id.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/pocket-id.png" + }, + { + "name": "Jellyfin", + "url": "https://jellyfin.cute.haus", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/jellyfin.png" + }, + { + "name": "Plex", + "url": "https://plex.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/plex.png" + }, + { + "name": "Ombi", + "url": "https://ombi.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/ombi.png" + }, + { + "name": "Audiobookshelf", + "url": "https://audiobookshelf.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/audiobookshelf.png" + }, + { + "name": "Immich", + "url": "https://immich.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/immich.png" + }, + { + "name": "Navidrome", + "url": "https://navidrome.cute.haus", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/navidrome.png" + }, + { + "name": "Paperless", + "url": "https://paperless.cute.haus", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/paperless-ngx.png" + }, + { + "name": "Forĝejo", + "url": "https://git.aly.codes/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/forgejo.png" + }, + { + "name": "Vaultwarden", + "url": "https://vault.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png" + }, + { + "name": "Uptime Kuma", + "url": "https://kuma.cute.haus/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png" + } + ] + }, + { + "type": "services", + "title": "Homelab", + "columns": 4, + "services": [ + { + "name": "Sonarr", + "url": "https://sonarr.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/sonarr.png" + }, + { + "name": "Radarr", + "url": "https://radarr.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/radarr.png" + }, + { + "name": "Lidarr", + "url": "https://lidarr.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/lidarr.png" + }, + { + "name": "Prowlarr", + "url": "https://prowlarr.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/prowlarr.png" + }, + { + "name": "Bazarr", + "url": "https://bazarr.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bazarr.png" + }, + { + "name": "Tautulli", + "url": "https://tautulli.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tautulli.png" + }, + { + "name": "qBittorrent", + "url": "https://qbittorrent.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/qbittorrent.png" + }, + { + "name": "Grafana", + "url": "https://grafana.narwhal-snapper.ts.net/", + "icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/grafana.png" + } + ] + } + ] +} diff --git a/src/App.tsx b/src/App.tsx index d7fbbec..7a756ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,122 @@ +import { useEffect, useState } from "react"; import Layout from "./Layout"; -import ServiceGrid from "./components/ServiceGrid"; import { HackerNews } from "./components/HackerNews"; import { Lobsters } from "./components/Lobsters"; +import { SearchBar } from "./components/SearchBar"; +import ServiceGrid from "./components/ServiceGrid"; import { Weather } from "./components/Weather"; -import { SearchBar } from "./components/SearchBar.tsx"; -import { apps } from "./data/apps"; -import { communityInfra } from "./data/communityInfra"; -import { privateApps } from "./data/privateApps"; -import { websites } from "./data/websites"; +import type { + DashboardConfig, + DashboardSection, + GridColumns, + Widget, +} from "./types"; + +const GRID_COLUMNS: Record = { + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", +}; + +const GRID_SPANS: Record = { + 1: "md:col-span-1", + 2: "md:col-span-2", + 3: "md:col-span-3", + 4: "md:col-span-4", +}; export function App() { + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/config") + .then(async (response) => { + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + }) + .then((loadedConfig) => { + setConfig(loadedConfig); + if (loadedConfig.title) document.title = loadedConfig.title; + }) + .catch(() => setError("Dashboard configuration could not be loaded.")); + }, []); + + if (error) return ; + if (!config) return ; + return ( -
-
- -
- -
- -
+
+ {config.sections.map((section, index) => ( + + ))}
+ + ); +} -
-
-

Lobsters

- -
+function DashboardMessage({ message }: { message: string }) { + return ( + +
{message}
+
+ ); +} -
-

Hacker News

- +function DashboardSectionView({ section }: { section: DashboardSection }) { + if (section.type === "services") { + return ( +
+

{section.title}

+ +
+ ); + } + + const columns = section.columns ?? 1; + + return ( +
+ {section.widgets.map((widget, index) => ( +
+
-
+ ))} + + ); +} -

Community Apps+Infra

- -

Webpages

- -

Apps

- -

Homelab

- - +function WidgetView({ widget }: { widget: Widget }) { + const content = renderWidget(widget); + if (!widget.title) return content; + + return ( + <> +

{widget.title}

+ {content} + ); } +function renderWidget(widget: Widget) { + switch (widget.type) { + case "weather": + return ; + case "search": + return ; + case "lobsters": + return ; + case "hacker-news": + return ; + } +} + export default App; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a42595a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,198 @@ +import type { + DashboardConfig, + DashboardSection, + GridColumns, + Service, + Widget, + WidgetType, +} from "./types"; + +const DEFAULT_CONFIG_PATH = new URL("../config/default.json", import.meta.url); +const VALID_COLUMNS = new Set([1, 2, 3, 4]); +const VALID_WIDGET_TYPES = new Set([ + "weather", + "search", + "lobsters", + "hacker-news", +]); + +export async function loadDashboardConfig(): Promise { + const configPath = process.env.WATSUP_CONFIG_PATH ?? DEFAULT_CONFIG_PATH; + const configFile = Bun.file(configPath); + + if (!(await configFile.exists())) { + throw new Error(`Dashboard config not found at ${String(configPath)}`); + } + + let value: unknown; + try { + value = await configFile.json(); + } catch (error) { + throw new Error( + `Dashboard config is not valid JSON: ${getErrorMessage(error)}`, + ); + } + + return parseDashboardConfig(value); +} + +function parseDashboardConfig(value: unknown): DashboardConfig { + const config = requireObject(value, "config"); + const sections = requireArray(config.sections, "config.sections").map( + parseSection, + ); + + return { + title: optionalString(config.title, "config.title"), + sections, + }; +} + +function parseSection(value: unknown, index: number): DashboardSection { + const path = `config.sections[${index}]`; + const section = requireObject(value, path); + + if (section.type === "services") { + return { + type: "services", + title: requireString(section.title, `${path}.title`), + columns: optionalColumns(section.columns, `${path}.columns`), + refreshInterval: optionalPositiveNumber( + section.refreshInterval, + `${path}.refreshInterval`, + ), + services: requireArray(section.services, `${path}.services`).map( + (service, serviceIndex) => + parseService(service, `${path}.services[${serviceIndex}]`), + ), + }; + } + + if (section.type === "widgets") { + return { + type: "widgets", + columns: optionalColumns(section.columns, `${path}.columns`), + widgets: requireArray(section.widgets, `${path}.widgets`).map( + (widget, widgetIndex) => + parseWidget(widget, `${path}.widgets[${widgetIndex}]`), + ), + }; + } + + throw new Error(`${path}.type must be "services" or "widgets"`); +} + +function parseService(value: unknown, path: string): Service { + const service = requireObject(value, path); + const goodStatuses = service.goodStatuses; + + return { + name: requireString(service.name, `${path}.name`), + url: requireHttpUrl(service.url, `${path}.url`), + icon: optionalHttpUrl(service.icon, `${path}.icon`), + healthUrl: optionalHttpUrl(service.healthUrl, `${path}.healthUrl`), + goodStatuses: + goodStatuses === undefined + ? undefined + : requireArray(goodStatuses, `${path}.goodStatuses`).map( + (status, statusIndex) => + requireHttpStatus(status, `${path}.goodStatuses[${statusIndex}]`), + ), + }; +} + +function parseWidget(value: unknown, path: string): Widget { + const widget = requireObject(value, path); + if (!VALID_WIDGET_TYPES.has(widget.type as WidgetType)) { + throw new Error( + `${path}.type must be weather, search, lobsters, or hacker-news`, + ); + } + + return { + type: widget.type as WidgetType, + title: optionalString(widget.title, `${path}.title`), + span: optionalColumns(widget.span, `${path}.span`), + }; +} + +function requireObject(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + return value as Record; +} + +function requireArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value; +} + +function requireString(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${path} must be a non-empty string`); + } + return value; +} + +function optionalString(value: unknown, path: string): string | undefined { + if (value === undefined) return undefined; + return requireString(value, path); +} + +function optionalColumns( + value: unknown, + path: string, +): GridColumns | undefined { + if (value === undefined) return undefined; + if (!VALID_COLUMNS.has(value as number)) { + throw new Error(`${path} must be an integer from 1 through 4`); + } + return value as GridColumns; +} + +function optionalPositiveNumber( + value: unknown, + path: string, +): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`${path} must be a positive number`); + } + return value; +} + +function requireHttpStatus(value: unknown, path: string): number { + if ( + !Number.isInteger(value) || + (value as number) < 100 || + (value as number) > 599 + ) { + throw new Error(`${path} must be an HTTP status from 100 through 599`); + } + return value as number; +} + +function requireHttpUrl(value: unknown, path: string): string { + const url = requireString(value, path); + if (!isHttpUrl(url)) throw new Error(`${path} must be an HTTP(S) URL`); + return url; +} + +function optionalHttpUrl(value: unknown, path: string): string | undefined { + if (value === undefined) return undefined; + return requireHttpUrl(value, path); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/data/apps.ts b/src/data/apps.ts deleted file mode 100644 index fe7b9d7..0000000 --- a/src/data/apps.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Service } from "../types"; - -export const apps: Service[] = [ - { - name: "Pocket ID", - url: "https://id.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/pocket-id.png", - }, - { - name: "Jellyfin", - url: "https://jellyfin.cute.haus", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/jellyfin.png", - }, - { - name: "Plex", - url: "https://plex.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/plex.png", - }, - { - name: "Ombi", - url: "https://ombi.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/ombi.png", - }, - { - name: "Audiobookshelf", - url: "https://audiobookshelf.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/audiobookshelf.png", - }, - { - name: "Immich", - url: "https://immich.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/immich.png", - }, - { - name: "Navidrome", - url: "https://navidrome.cute.haus", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/navidrome.png", - }, - { - name: "Paperless", - url: "https://paperless.cute.haus", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/paperless-ngx.png", - }, - { - name: "Forĝejo", - url: "https://git.aly.codes/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/forgejo.png", - }, - { - name: "Vaultwarden", - url: "https://vault.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png", - }, - { - name: "Uptime Kuma", - url: "https://kuma.cute.haus/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png", - }, -]; diff --git a/src/data/communityInfra.ts b/src/data/communityInfra.ts deleted file mode 100644 index 38567e0..0000000 --- a/src/data/communityInfra.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Service } from "../types"; - -export const communityInfra: Service[] = [ - { name: "atbbs", url: "https://atbbs.xyz/" }, - { name: "morsels.blue", url: "https://morsels.blue/" }, - { - name: "aly.social", - url: "https://pds.cute.haus", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bluesky.png", - }, - { - name: "slingshot", - url: "https://slingshot.cute.haus", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bluesky.png", - }, -]; diff --git a/src/data/privateApps.ts b/src/data/privateApps.ts deleted file mode 100644 index bc5f24e..0000000 --- a/src/data/privateApps.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Service } from "../types"; - -export const privateApps: Service[] = [ - { - name: "Sonarr", - url: "https://sonarr.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/sonarr.png", - }, - { - name: "Radarr", - url: "https://radarr.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/radarr.png", - }, - { - name: "Lidarr", - url: "https://lidarr.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/lidarr.png", - }, - { - name: "Prowlarr", - url: "https://prowlarr.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/prowlarr.png", - }, - { - name: "Bazarr", - url: "https://bazarr.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bazarr.png", - }, - { - name: "Tautulli", - url: "https://tautulli.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tautulli.png", - }, - { - name: "qBittorrent", - url: "https://qbittorrent.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/qbittorrent.png", - }, - { - name: "Grafana", - url: "https://grafana.narwhal-snapper.ts.net/", - icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/grafana.png", - }, -]; diff --git a/src/data/websites.ts b/src/data/websites.ts deleted file mode 100644 index a27205e..0000000 --- a/src/data/websites.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Service } from "../types"; - -export const websites: Service[] = [ - { name: "Aly Raffauf", url: "https://aly.codes/" }, - { name: "Switchyard", url: "https://switchyard.aly.codes/" }, - { name: "Morgan Tamayo", url: "https://morgantamayo.com/" }, -]; diff --git a/src/index.ts b/src/index.ts index 5cff971..5406f87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { serve } from "bun"; import index from "./index.html"; +import { loadDashboardConfig } from "./config"; let hnCache: { stories: any[]; fetchedAt: number } | null = null; let lobstersCache: { stories: any[]; fetchedAt: number } | null = null; @@ -70,9 +71,10 @@ async function findFavicon(siteOrigin: string): Promise { const iconLinkPattern = /]+rel=["'][^"']*icon[^"']*["'][^>]*>/gi; for (const linkTag of homepage.match(iconLinkPattern) ?? []) { const hrefMatch = linkTag.match(/href=["']([^"']+)["']/i); - if (!hrefMatch) continue; + const hrefValue = hrefMatch?.[1]; + if (!hrefValue) continue; - const href = decodeHtmlEntities(hrefMatch[1]); + const href = decodeHtmlEntities(hrefValue); const iconUrl = new URL(href, siteOrigin).toString(); const icon = await fetchIcon(iconUrl); if (icon) return icon; @@ -120,10 +122,25 @@ async function checkStatuses( const server = serve({ hostname: "0.0.0.0", - port: 3000, + port: Number(process.env.PORT ?? 3000), routes: { "/": index, + "/api/config": { + async GET() { + try { + return Response.json(await loadDashboardConfig(), { + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown config error"; + console.error(message); + return new Response(message, { status: 500 }); + } + }, + }, + "/api/check": { async POST(req) { const items = await req.json(); diff --git a/src/types.ts b/src/types.ts index 053d6a1..54aa91e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +export type GridColumns = 1 | 2 | 3 | 4; + export type Service = { name: string; url: string; @@ -5,3 +7,32 @@ export type Service = { healthUrl?: string; goodStatuses?: number[]; }; + +export type ServicesSection = { + type: "services"; + title: string; + columns?: GridColumns; + refreshInterval?: number; + services: Service[]; +}; + +export type WidgetType = "weather" | "search" | "lobsters" | "hacker-news"; + +export type Widget = { + type: WidgetType; + title?: string; + span?: GridColumns; +}; + +export type WidgetsSection = { + type: "widgets"; + columns?: GridColumns; + widgets: Widget[]; +}; + +export type DashboardSection = ServicesSection | WidgetsSection; + +export type DashboardConfig = { + title?: string; + sections: DashboardSection[]; +}; -- 2.51.2