feat: scaffold PulseNode dashboard (M1)
Next.js 16 + TypeScript + Tailwind v4 app with a YAML-driven config system: schema validation (zod), .env interpolation, and a widget registry rendering bookmark/search/group widgets. CSS custom properties (--pn-*) drive theming and are overridable from config.yml's theme.variables. Config load errors surface as a readable error page instead of crashing the app. Hot reload, docker/system/http collectors, and the WebSocket push layer land in later milestones per the approved plan.
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!config/.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--pn-bg: #0b0d12;
|
||||||
|
--pn-surface: #12151c;
|
||||||
|
--pn-surface-raised: #171b24;
|
||||||
|
--pn-border: #242938;
|
||||||
|
--pn-fg: #e6e8ee;
|
||||||
|
--pn-fg-muted: #8a90a2;
|
||||||
|
--pn-accent: #38bdf8;
|
||||||
|
--pn-radius: 0.75rem;
|
||||||
|
--pn-status-up: #34d399;
|
||||||
|
--pn-status-down: #f87171;
|
||||||
|
--pn-status-degraded: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--pn-bg: #f5f6f8;
|
||||||
|
--pn-surface: #ffffff;
|
||||||
|
--pn-surface-raised: #f0f1f4;
|
||||||
|
--pn-border: #e2e4ea;
|
||||||
|
--pn-fg: #14161c;
|
||||||
|
--pn-fg-muted: #666b7a;
|
||||||
|
--pn-accent: #0284c7;
|
||||||
|
--pn-status-up: #059669;
|
||||||
|
--pn-status-down: #dc2626;
|
||||||
|
--pn-status-degraded: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--pn-bg);
|
||||||
|
--color-surface: var(--pn-surface);
|
||||||
|
--color-surface-raised: var(--pn-surface-raised);
|
||||||
|
--color-border: var(--pn-border);
|
||||||
|
--color-fg: var(--pn-fg);
|
||||||
|
--color-fg-muted: var(--pn-fg-muted);
|
||||||
|
--color-accent: var(--pn-accent);
|
||||||
|
--color-status-up: var(--pn-status-up);
|
||||||
|
--color-status-down: var(--pn-status-down);
|
||||||
|
--color-status-degraded: var(--pn-status-degraded);
|
||||||
|
--radius-widget: var(--pn-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--pn-bg);
|
||||||
|
color: var(--pn-fg);
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { configStore } from "@/lib/config/loader";
|
||||||
|
import { ThemeStyleInjector } from "@/components/layout/ThemeStyleInjector";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "PulseNode",
|
||||||
|
description: "Self-hosted infrastructure dashboard",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
let mode: "dark" | "light" = "dark";
|
||||||
|
let variables: Record<string, string> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = configStore.get();
|
||||||
|
mode = config.theme.mode === "light" ? "light" : "dark";
|
||||||
|
variables = config.theme.variables;
|
||||||
|
} catch {
|
||||||
|
// page.tsx surfaces the actual config error; layout falls back to defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" data-theme={mode} className="h-full">
|
||||||
|
<head>
|
||||||
|
<ThemeStyleInjector variables={variables} />
|
||||||
|
</head>
|
||||||
|
<body className="min-h-full antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { configStore, ConfigError } from "@/lib/config/loader";
|
||||||
|
import { Dashboard } from "@/components/layout/Dashboard";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = configStore.get();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof ConfigError ? err.message : "Unknown configuration error";
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-6 py-16">
|
||||||
|
<h1 className="text-lg font-semibold text-status-down">Configuration Error</h1>
|
||||||
|
<pre className="rounded-[var(--radius-widget)] border border-status-down bg-surface p-4 text-sm whitespace-pre-wrap text-fg">
|
||||||
|
{message}
|
||||||
|
</pre>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dashboard config={config} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from "@/lib/config/schema";
|
||||||
|
import { GroupSection } from "./GroupSection";
|
||||||
|
|
||||||
|
export function Dashboard({ config }: { config: Config }) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex max-w-6xl flex-col gap-8 px-6 py-10">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold text-fg">{config.settings.title}</h1>
|
||||||
|
</header>
|
||||||
|
{config.groups.map((group) => (
|
||||||
|
<GroupSection key={group.name} group={group} />
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Group } from "@/lib/config/schema";
|
||||||
|
import { widgetRegistry } from "@/components/widgets/registry";
|
||||||
|
|
||||||
|
export function GroupSection({ group }: { group: Group }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-xs font-semibold tracking-wide text-fg-muted uppercase">
|
||||||
|
{group.name}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||||
|
{group.widgets.map((widget, index) => {
|
||||||
|
const Component = widgetRegistry[widget.type];
|
||||||
|
return <Component key={index} widget={widget as never} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function ThemeStyleInjector({ variables }: { variables: Record<string, string> }) {
|
||||||
|
const entries = Object.entries(variables);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
const css = `:root{${entries.map(([key, value]) => `${key}:${value};`).join("")}}`;
|
||||||
|
|
||||||
|
return <style id="pulsenode-theme-overrides" dangerouslySetInnerHTML={{ __html: css }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Widget } from "@/lib/config/schema";
|
||||||
|
|
||||||
|
type BookmarkWidget = Extract<Widget, { type: "bookmark" }>;
|
||||||
|
|
||||||
|
export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={widget.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex flex-col gap-1 rounded-[var(--radius-widget)] border border-border bg-surface p-4 transition-colors hover:border-accent"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-fg">{widget.name}</span>
|
||||||
|
{widget.description && (
|
||||||
|
<span className="text-xs text-fg-muted">{widget.description}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { Widget } from "@/lib/config/schema";
|
||||||
|
import { BookmarkWidget } from "./bookmark/Widget";
|
||||||
|
import { SearchWidget } from "./search/Widget";
|
||||||
|
|
||||||
|
type WidgetComponent<T extends Widget["type"]> = ComponentType<{
|
||||||
|
widget: Extract<Widget, { type: T }>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const widgetRegistry: { [K in Widget["type"]]: WidgetComponent<K> } = {
|
||||||
|
bookmark: BookmarkWidget,
|
||||||
|
search: SearchWidget,
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import type { SearchEngine, Widget } from "@/lib/config/schema";
|
||||||
|
|
||||||
|
type SearchWidget = Extract<Widget, { type: "search" }>;
|
||||||
|
|
||||||
|
const ENGINE_URLS: Record<string, string> = {
|
||||||
|
duckduckgo: "https://duckduckgo.com/?q=",
|
||||||
|
google: "https://www.google.com/search?q=",
|
||||||
|
bing: "https://www.bing.com/search?q=",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchWidget({ widget }: { widget: SearchWidget }) {
|
||||||
|
const [engine, setEngine] = useState(widget.defaultEngine ?? widget.engines[0]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
window.open(`${ENGINE_URLS[engine]}${encodeURIComponent(trimmed)}`, "_blank", "noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex items-center gap-2 rounded-[var(--radius-widget)] border border-border bg-surface p-2 sm:col-span-2"
|
||||||
|
>
|
||||||
|
{widget.engines.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={engine}
|
||||||
|
onChange={(event) => setEngine(event.target.value as SearchEngine)}
|
||||||
|
className="rounded bg-transparent text-xs text-fg-muted outline-none"
|
||||||
|
>
|
||||||
|
{widget.engines.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
className="flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-muted"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Public hostnames of the services PulseNode links to / monitors.
|
||||||
|
# These are not secrets, but keeping them here (rather than hardcoded in
|
||||||
|
# config.yml) means the same config.yml works across environments.
|
||||||
|
TRAEFIK_HOST_TRAEFIK=traefik.example.com
|
||||||
|
TRAEFIK_HOST_CODE=code.example.com
|
||||||
|
TRAEFIK_HOST_COOLIFY=coolify.example.com
|
||||||
|
TRAEFIK_HOST_GITEA=git.example.com
|
||||||
|
TRAEFIK_HOST_HEADSCALE=headscale.example.com
|
||||||
|
TRAEFIK_HOST_IMMICH=photos.example.com
|
||||||
|
TRAEFIK_HOST_N8N=n8n.example.com
|
||||||
|
TRAEFIK_HOST_PASSBOLT=passbolt.example.com
|
||||||
|
TRAEFIK_HOST_UMAMI=analytics.example.com
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
settings:
|
||||||
|
title: PulseNode
|
||||||
|
|
||||||
|
theme:
|
||||||
|
mode: dark
|
||||||
|
variables:
|
||||||
|
--pn-accent: "#38bdf8"
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- name: Core Infra
|
||||||
|
widgets:
|
||||||
|
- type: bookmark
|
||||||
|
name: Traefik
|
||||||
|
href: https://${TRAEFIK_HOST_TRAEFIK}
|
||||||
|
description: Reverse proxy dashboard
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: Coolify
|
||||||
|
href: https://${TRAEFIK_HOST_COOLIFY}
|
||||||
|
description: Deployment platform
|
||||||
|
|
||||||
|
- name: Dev Tools
|
||||||
|
widgets:
|
||||||
|
- type: bookmark
|
||||||
|
name: Gitea
|
||||||
|
href: https://${TRAEFIK_HOST_GITEA}
|
||||||
|
description: Git hosting & CI
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: Code Server
|
||||||
|
href: https://${TRAEFIK_HOST_CODE}
|
||||||
|
description: Browser IDE
|
||||||
|
|
||||||
|
- name: Apps
|
||||||
|
widgets:
|
||||||
|
- type: bookmark
|
||||||
|
name: Immich
|
||||||
|
href: https://${TRAEFIK_HOST_IMMICH}
|
||||||
|
description: Photo & video library
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: n8n
|
||||||
|
href: https://${TRAEFIK_HOST_N8N}
|
||||||
|
description: Workflow automation
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: Passbolt
|
||||||
|
href: https://${TRAEFIK_HOST_PASSBOLT}
|
||||||
|
description: Password manager
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: Umami
|
||||||
|
href: https://${TRAEFIK_HOST_UMAMI}
|
||||||
|
description: Web analytics
|
||||||
|
|
||||||
|
- type: bookmark
|
||||||
|
name: Headscale
|
||||||
|
href: https://${TRAEFIK_HOST_HEADSCALE}
|
||||||
|
description: VPN mesh control
|
||||||
|
|
||||||
|
- name: Search
|
||||||
|
widgets:
|
||||||
|
- type: search
|
||||||
|
engines: [duckduckgo, google]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/g;
|
||||||
|
|
||||||
|
export function interpolateEnv(
|
||||||
|
raw: string,
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
): string {
|
||||||
|
return raw.replace(VAR_PATTERN, (_match, name: string, hasDefault: string | undefined, fallback: string | undefined) => {
|
||||||
|
const value = env[name];
|
||||||
|
if (value !== undefined) return value;
|
||||||
|
if (hasDefault !== undefined) return fallback ?? "";
|
||||||
|
throw new Error(`Missing environment variable "${name}" referenced in config.yml`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import chokidar, { type FSWatcher } from "chokidar";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import { configSchema, type Config } from "./schema";
|
||||||
|
import { interpolateEnv } from "./interpolate";
|
||||||
|
|
||||||
|
const CONFIG_DIR = path.join(process.cwd(), "config");
|
||||||
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.yml");
|
||||||
|
const ENV_PATH = path.join(CONFIG_DIR, ".env");
|
||||||
|
|
||||||
|
export class ConfigError extends Error {}
|
||||||
|
|
||||||
|
function loadEnv(): Record<string, string | undefined> {
|
||||||
|
if (!existsSync(ENV_PATH)) return { ...process.env };
|
||||||
|
const parsed = dotenv.parse(readFileSync(ENV_PATH));
|
||||||
|
return { ...process.env, ...parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConfig(): Config {
|
||||||
|
if (!existsSync(CONFIG_PATH)) {
|
||||||
|
throw new ConfigError(`Config file not found at ${CONFIG_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
||||||
|
const env = loadEnv();
|
||||||
|
|
||||||
|
let interpolated: string;
|
||||||
|
try {
|
||||||
|
interpolated = interpolateEnv(raw, env);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ConfigError((err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedYaml: unknown;
|
||||||
|
try {
|
||||||
|
parsedYaml = parseYaml(interpolated);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ConfigError(`Failed to parse config.yml: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = configSchema.safeParse(parsedYaml);
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||||
|
.join("\n");
|
||||||
|
throw new ConfigError(`config.yml failed validation:\n${issues}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigListener = (config: Config) => void;
|
||||||
|
type ConfigErrorListener = (error: ConfigError) => void;
|
||||||
|
|
||||||
|
class ConfigStore {
|
||||||
|
private current: Config | null = null;
|
||||||
|
private watcher: FSWatcher | null = null;
|
||||||
|
private readonly listeners = new Set<ConfigListener>();
|
||||||
|
private readonly errorListeners = new Set<ConfigErrorListener>();
|
||||||
|
|
||||||
|
load(): Config {
|
||||||
|
this.current = readConfig();
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): Config {
|
||||||
|
return this.current ?? this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(listener: ConfigListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(listener: ConfigErrorListener): () => void {
|
||||||
|
this.errorListeners.add(listener);
|
||||||
|
return () => this.errorListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(): void {
|
||||||
|
if (this.watcher) return;
|
||||||
|
this.watcher = chokidar.watch([CONFIG_PATH, ENV_PATH], {
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
||||||
|
ignoreInitial: true,
|
||||||
|
});
|
||||||
|
this.watcher.on("all", () => this.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.watcher?.close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(): void {
|
||||||
|
try {
|
||||||
|
const next = readConfig();
|
||||||
|
this.current = next;
|
||||||
|
for (const listener of this.listeners) listener(next);
|
||||||
|
} catch (err) {
|
||||||
|
const configError = err instanceof ConfigError ? err : new ConfigError((err as Error).message);
|
||||||
|
console.error(`[config] ${configError.message}`);
|
||||||
|
for (const listener of this.errorListeners) listener(configError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configStore = new ConfigStore();
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchEngineSchema = z.enum(["duckduckgo", "google", "bing"]);
|
||||||
|
|
||||||
|
export const bookmarkWidgetSchema = z.object({
|
||||||
|
type: z.literal("bookmark"),
|
||||||
|
name: z.string(),
|
||||||
|
href: z.string().url(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchWidgetSchema = z.object({
|
||||||
|
type: z.literal("search"),
|
||||||
|
engines: z.array(searchEngineSchema).min(1).default(["duckduckgo"]),
|
||||||
|
defaultEngine: searchEngineSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const widgetSchema = z.discriminatedUnion("type", [
|
||||||
|
bookmarkWidgetSchema,
|
||||||
|
searchWidgetSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const groupSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
widgets: z.array(widgetSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const themeSchema = z
|
||||||
|
.object({
|
||||||
|
mode: z.enum(["dark", "light", "auto"]).default("dark"),
|
||||||
|
variables: z.record(z.string(), z.string()).default({}),
|
||||||
|
customCssPath: z.string().optional(),
|
||||||
|
})
|
||||||
|
.default({ mode: "dark", variables: {} });
|
||||||
|
|
||||||
|
export const settingsSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().default("PulseNode"),
|
||||||
|
})
|
||||||
|
.default({ title: "PulseNode" });
|
||||||
|
|
||||||
|
export const configSchema = z.object({
|
||||||
|
theme: themeSchema,
|
||||||
|
settings: settingsSchema,
|
||||||
|
groups: z.array(groupSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
export type Widget = z.infer<typeof widgetSchema>;
|
||||||
|
export type Group = z.infer<typeof groupSchema>;
|
||||||
|
export type SearchEngine = z.infer<typeof searchEngineSchema>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "pulsenode",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"dockerode": "^5.0.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"next": "16.3.1",
|
||||||
|
"p-limit": "^7.3.1",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"systeminformation": "^5.33.1",
|
||||||
|
"ws": "^8.21.3",
|
||||||
|
"yaml": "^2.9.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/dockerode": "^4.0.1",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@11.21.0"
|
||||||
|
}
|
||||||
Generated
+5039
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
cpu-features: false
|
||||||
|
esbuild: true
|
||||||
|
protobufjs: false
|
||||||
|
sharp: false
|
||||||
|
ssh2: false
|
||||||
|
unrs-resolver: false
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Please design detailed a plan for a magnificent and sophisticated dashboard web app:
|
||||||
|
|
||||||
|
- The app uses pnpm, tailwindcss@4 and react / next.js
|
||||||
|
- The app monitors system, docker, API endpoints, etc.
|
||||||
|
- The app is highly configurable through a configuration yml file
|
||||||
|
- The app can hot load the configuration file
|
||||||
|
- The app stores secrets in an .env file and interpolates them in the configuration file
|
||||||
|
- The app contains plenty of widgets, which can be configured
|
||||||
|
- The app offers widgets for all services in Projekte/stacks
|
||||||
|
- The app is configurable with CSS variables.
|
||||||
|
- The app offers browser real-time updates via WebSockets
|
||||||
|
- The app is deployable via docker (compose)
|
||||||
|
|
||||||
|
References:
|
||||||
|
- https://github.com/gethomepage/homepage
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user