commit 80c2362ac223de8ee8e1a5c285c6419fbbf5611c Author: Sebastian Krüger Date: Mon Aug 17 13:49:55 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..856f7d7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..1d9e961 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8480889 --- /dev/null +++ b/app/layout.tsx @@ -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 = {}; + + 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 ( + + + + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..1ae6b2a --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+

Configuration Error

+
+          {message}
+        
+
+ ); + } + + return ; +} diff --git a/components/layout/Dashboard.tsx b/components/layout/Dashboard.tsx new file mode 100644 index 0000000..df43274 --- /dev/null +++ b/components/layout/Dashboard.tsx @@ -0,0 +1,15 @@ +import type { Config } from "@/lib/config/schema"; +import { GroupSection } from "./GroupSection"; + +export function Dashboard({ config }: { config: Config }) { + return ( +
+
+

{config.settings.title}

+
+ {config.groups.map((group) => ( + + ))} +
+ ); +} diff --git a/components/layout/GroupSection.tsx b/components/layout/GroupSection.tsx new file mode 100644 index 0000000..e73b469 --- /dev/null +++ b/components/layout/GroupSection.tsx @@ -0,0 +1,18 @@ +import type { Group } from "@/lib/config/schema"; +import { widgetRegistry } from "@/components/widgets/registry"; + +export function GroupSection({ group }: { group: Group }) { + return ( +
+

+ {group.name} +

+
+ {group.widgets.map((widget, index) => { + const Component = widgetRegistry[widget.type]; + return ; + })} +
+
+ ); +} diff --git a/components/layout/ThemeStyleInjector.tsx b/components/layout/ThemeStyleInjector.tsx new file mode 100644 index 0000000..f33f5f8 --- /dev/null +++ b/components/layout/ThemeStyleInjector.tsx @@ -0,0 +1,8 @@ +export function ThemeStyleInjector({ variables }: { variables: Record }) { + const entries = Object.entries(variables); + if (entries.length === 0) return null; + + const css = `:root{${entries.map(([key, value]) => `${key}:${value};`).join("")}}`; + + return