From bbc5db473a6c4b95902b1861a888537ef538d193 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 3 Aug 2026 14:22:50 +0100 Subject: [PATCH] feat(frontend): redesign console UI with new component tree and design system --- src/frontend/DESIGN.md | 211 ++++++++++ src/frontend/PRODUCT.md | 59 +++ src/frontend/bun.lock | 27 +- src/frontend/index.html | 7 +- src/frontend/package.json | 7 +- src/frontend/src/app.css | 0 src/frontend/src/app.tsx | 395 +++--------------- src/frontend/src/assets/hero.png | Bin 13057 -> 0 bytes src/frontend/src/assets/preact.svg | 1 - src/frontend/src/assets/vite.svg | 1 - src/frontend/src/components/brand-mark.tsx | 26 ++ .../connection/connection-badge.tsx | 46 ++ .../src/components/container/status.ts | 81 ++++ src/frontend/src/components/layout/header.tsx | 53 +++ .../src/components/layout/status-states.tsx | 70 ++++ .../src/components/layout/toolbar.tsx | 92 ++++ .../src/components/service/service-card.tsx | 265 ++++++++++++ .../src/components/service/service-grid.tsx | 16 + src/frontend/src/components/ui/badge.tsx | 54 +++ .../{lib/shadcn => }/components/ui/button.tsx | 14 +- src/frontend/src/components/ui/card.tsx | 89 ++++ src/frontend/src/components/ui/input.tsx | 29 ++ src/frontend/src/components/ui/sonner.tsx | 49 +++ src/frontend/src/components/ui/spinner.tsx | 16 + src/frontend/src/index.css | 391 +++++++++-------- .../src/lib/components/connectionBadge.tsx | 48 --- .../src/lib/components/installButton.tsx | 52 --- .../src/lib/components/startButton.tsx | 44 -- .../src/lib/components/stopButton.tsx | 45 -- .../src/lib/components/uninstallButton.tsx | 45 -- src/frontend/src/lib/maestro/containers.tsx | 56 --- .../src/lib/shadcn/components/ui/badge.tsx | 58 --- .../src/lib/shadcn/components/ui/card.tsx | 100 ----- .../src/lib/shadcn/components/ui/input.tsx | 29 -- .../src/lib/shadcn/components/ui/spinner.tsx | 10 - .../src/lib/shadcn/components/ui/tabs.tsx | 91 ---- src/frontend/src/lib/shadcn/lib/utils.ts | 6 - src/frontend/src/main.tsx | 2 +- src/frontend/tsconfig.app.json | 2 +- src/frontend/vite.config.ts | 2 +- 40 files changed, 1469 insertions(+), 1120 deletions(-) create mode 100644 src/frontend/DESIGN.md create mode 100644 src/frontend/PRODUCT.md delete mode 100644 src/frontend/src/app.css delete mode 100644 src/frontend/src/assets/hero.png delete mode 100644 src/frontend/src/assets/preact.svg delete mode 100644 src/frontend/src/assets/vite.svg create mode 100644 src/frontend/src/components/brand-mark.tsx create mode 100644 src/frontend/src/components/connection/connection-badge.tsx create mode 100644 src/frontend/src/components/container/status.ts create mode 100644 src/frontend/src/components/layout/header.tsx create mode 100644 src/frontend/src/components/layout/status-states.tsx create mode 100644 src/frontend/src/components/layout/toolbar.tsx create mode 100644 src/frontend/src/components/service/service-card.tsx create mode 100644 src/frontend/src/components/service/service-grid.tsx create mode 100644 src/frontend/src/components/ui/badge.tsx rename src/frontend/src/{lib/shadcn => }/components/ui/button.tsx (65%) create mode 100644 src/frontend/src/components/ui/card.tsx create mode 100644 src/frontend/src/components/ui/input.tsx create mode 100644 src/frontend/src/components/ui/sonner.tsx create mode 100644 src/frontend/src/components/ui/spinner.tsx delete mode 100644 src/frontend/src/lib/components/connectionBadge.tsx delete mode 100644 src/frontend/src/lib/components/installButton.tsx delete mode 100644 src/frontend/src/lib/components/startButton.tsx delete mode 100644 src/frontend/src/lib/components/stopButton.tsx delete mode 100644 src/frontend/src/lib/components/uninstallButton.tsx delete mode 100644 src/frontend/src/lib/maestro/containers.tsx delete mode 100644 src/frontend/src/lib/shadcn/components/ui/badge.tsx delete mode 100644 src/frontend/src/lib/shadcn/components/ui/card.tsx delete mode 100644 src/frontend/src/lib/shadcn/components/ui/input.tsx delete mode 100644 src/frontend/src/lib/shadcn/components/ui/spinner.tsx delete mode 100644 src/frontend/src/lib/shadcn/components/ui/tabs.tsx delete mode 100644 src/frontend/src/lib/shadcn/lib/utils.ts diff --git a/src/frontend/DESIGN.md b/src/frontend/DESIGN.md new file mode 100644 index 0000000..a6559e3 --- /dev/null +++ b/src/frontend/DESIGN.md @@ -0,0 +1,211 @@ +--- +name: Maestro · dcntrlized console +description: Dark ops console for running a self-hosted node — precise, calm, alive at night. +colors: + primary: "oklch(0.4981 0.1996 354.66)" + primary-foreground: "oklch(0.97 0.012 354)" + secondary: "oklch(0.63 0.15 354.66)" + tertiary: "oklch(0.39 0.14 354.66)" + signal: "oklch(0.74 0.13 160)" + ember: "oklch(0.78 0.15 55)" + alert: "oklch(0.66 0.2 24)" + night-ink: "oklch(0.14 0.015 265)" + panel: "oklch(0.173 0.017 265)" + raised: "oklch(0.19 0.018 265)" + slate: "oklch(0.218 0.026 354)" + muted-fill: "oklch(0.205 0.018 265)" + frost: "oklch(0.955 0.004 265)" + ash: "oklch(0.7 0.02 265)" + edge: "oklch(100% 0 0 / 8%)" +typography: + title: + fontFamily: "IBM Plex Sans Variable, system-ui, sans-serif" + fontSize: "15px" + fontWeight: 600 + body: + fontFamily: "IBM Plex Sans Variable, system-ui, sans-serif" + fontSize: "13px" + fontWeight: 400 + lineHeight: 1.55 + label: + fontFamily: "JetBrains Mono Variable, ui-monospace, monospace" + fontSize: "11px" + fontWeight: 500 +rounded: + sm: "7px" + md: "9px" + lg: "11px" + xl: "15px" + pill: "9999px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + xl: "24px" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.lg}" + padding: "12px 16px" + button-primary-hover: + backgroundColor: "color-mix(in oklab, {colors.primary} 80%, transparent)" + button-ghost: + backgroundColor: "transparent" + textColor: "{colors.ash}" + rounded: "{rounded.lg}" + chip-running: + backgroundColor: "color-mix(in oklab, {colors.signal} 10%, transparent)" + textColor: "{colors.signal}" + rounded: "{rounded.pill}" + card-service: + backgroundColor: "{colors.panel}" + rounded: "{rounded.xl}" + input-search: + backgroundColor: "color-mix(in oklab, {colors.muted-fill} 40%, transparent)" + rounded: "{rounded.lg}" +--- + +# Design System: Maestro · dcntrlized console + +## Overview + +**Creative North Star: "The Night Conductor"** + +Maestro is the dark booth at the edge of the night. A single operator runs every service on the rack from a near-black console — a raspberry glow overhead, warm ember and signal lights breathing along the rails below. Nothing on screen shouts; everything is readable at a glance, like instrument faces in a dim cockpit. The interface is calm, precise, and quietly alive: status lights pulse only when something is actually happening, surfaces sit in hairline-defined layers, and every action reads as a deliberate act of control rather than a marketing flourish. + +Density is operator-dense but never crowded. One service equals one card; the card compresses identity (monogram tile), state (status light), provenance (vendor, image, source), and the single correct next action into a tight unit. The header is a glance-line: brand, installed count, update flag, and connection state in one row. The visual system is built on tonal layering over flat ink — near-black blue-zinc surfaces stacked by elevation, separated by 8% white hairlines — with soft black drop shadows used only as ambient grounding, never as decoration. + +**Key Characteristics:** +- Nocturnal and utilitarian — near-black blue-zinc ink, never pure black, never white-page light. +- Raspberry is the only accent and it is rare; it marks brand, the primary action, and focus. +- Warm status lights (signal / ember) carry operational truth; blue is banned from status semantics. +- Hairline borders over tonal surfaces convey structure; shadows are ambient, not structural. +- Mono type does the technical talking; the sans voice is quiet, structured, legible. +- Motion is a response to state — pings, toasts, hovers — gated behind `prefers-reduced-motion`. + +## Colors + +The palette is one raspberry accent against a cool blue-zinc ink scene. Status colors are warm lights (green, orange, red) tuned to sit inside the scene and read at a glance without clashing with the accent. + +### Primary +- **Raspberry** (oklch(0.4981 0.1996 354.66) · #b00b69): The single accent. Used for the brand mark, the primary action button, the active search/filter treatment, and the focus ring. Its rarity is the point — it should never paint a whole screen. +- **Primary Frost** (oklch(0.97 0.012 354)): Frost-white text *on* raspberry. The dark accent needs light ink — holds AA at 6.2:1 while staying inside the night scene. + +### Secondary +- **Rose** (oklch(0.63 0.15 354.66)): The brand family's lighter voice — supporting accents, chart fills, hover tints. Derived from the raspberry hue, never picked ad hoc. +- **Ember** (oklch(0.78 0.15 55)): Warm orange. Warns — stopping, restarting, transitions, "update available", registry unreachable. +- **Signal** (oklch(0.74 0.13 160)): Warm emerald. Running / connected / success — the "all clear" light. + +### Tertiary +- **Wine** (oklch(0.39 0.14 354.66)): The deep bookend of the raspberry family — pressed states and depth accents. Too dark for text on the scene; surfaces only. +- **Alert** (oklch(0.66 0.2 24)): Crimson red. Destructive intent and hard failures — uninstall, errors, lost connection. Deepened to read as danger beside the raspberry accent. + +### Neutral +- **Night Ink** (oklch(0.14 0.015 265)): Page background. The scene's floor. +- **Frost** (oklch(0.955 0.004 265)): Primary text — headings, names, labels. +- **Ash** (oklch(0.7 0.02 265)): Secondary text and metadata. Set bright enough to hold AA at 11px (4.5:1 on secondary). +- **Panel** (oklch(0.173 0.017 265)): Card surface — one step up from the page. +- **Raised** (oklch(0.19 0.018 265)): Popover / toast surface — the top layer. +- **Blush** (oklch(0.218 0.026 354)): Secondary and selected fills — quiet chips, monogram tiles. Near-black ink with a faint raspberry cast. +- **Muted Fill** (oklch(0.205 0.018 265)): Quiet fills — inputs, version chips. +- **Edge** (oklch(100% 0 0 / 8%)): Hairline borders and dividers. Structure without weight. + +### Named Rules +**The One Voice Rule.** Raspberry appears on ≤10% of any given screen. Its rarity is the signal — when raspberry appears, it means "act here" or "this is Maestro itself." +**The Derived Family Rule.** Rose and wine are the raspberry family's lightness bookends, derived in OKLCH from the primary hue — never chosen ad hoc. Rose carries support (accents, tints, charts); wine carries depth (press, shadows of brand surfaces). +**The No Blue Light Rule.** Blue is banned from status semantics. Operational states are warm (signal / ember / alert) only; a blue accent reads as the old stock dashboard and breaks the scene. +**The Warm-Second Rule.** Warm accents are derived to sit *inside* the dark scene: on-card chips use a 10–25% tint of the accent over the surface, never a full-strength saturated wash. + +## Typography + +**Display Font:** IBM Plex Sans Variable (fallback system-ui, sans-serif) +**Body Font:** IBM Plex Sans Variable (fallback system-ui, sans-serif) +**Label/Mono Font:** JetBrains Mono Variable (fallback ui-monospace, Menlo, monospace) + +**Character:** A structured, technical-but-human pairing. IBM Plex Sans is quietly engineered — clear, slightly industrial, no flourish — while JetBrains Mono carries the machine voice: names, images, versions, and counts. Mono is used for anything the operator might copy or compare; sans for anything meant to be read. + +### Hierarchy +Type sizes are authored at 100% density in the DESIGN tokens and rendered 1.5× by the root `font-size: 150%` on `` (rem-based utilities scale, px and viewport units don't). The relative steps below are the design intent; on screen they read 18 / 21 / 24 / 27px. +- **Title** (600, 15px, 1.2): Brand wordmark, card names. The only semibold in the system. +- **Body** (400, 13px, 1.55): Descriptions and secondary reading. Capped at 2 lines in cards. +- **Label** (500, 11px, mono): Versions, image refs, counts, vendor, service names, timestamps. Tabular-nums where numeric (`tabular-nums`). + +### Named Rules +**The Mono Voice Rule.** Anything technical is mono: image references, versions, service names, counts. If a user could copy it, set it in JetBrains Mono. +**The One-Weight Rule.** Sans weight stays at 400 (body) and 600 (title). No 500 intermediates; hierarchy comes from size and mono, not weight gradations. + +## Layout + +**Density baseline: desktop-first at 150%, a single root rule.** The console targets desktop operators on 1080p displays at 100% zoom. Density is achieved with one line in `src/index.css` — `html { font-size: 150% }` — so every rem-based utility (`text-sm`, `text-lg`, `max-w-xl`, `h-8`, `gap-4`, `rounded-lg`) scales 1.5× while keeping its standard Tailwind name. No theme values are rewritten; the only density token is `--radius` (0.6875rem), the design's 11px corner base, which scales with the root font-size. `zoom` was ruled out because it scales viewport units and px too, breaking `min-h-screen`/`dvh` filling and the hairline px focus ring. Px values (focus ring `ring-[3px]`, shadows, borders) stay hairline. Breakpoints are pinned to fixed CSS px (`--breakpoint-*: 640/768/1024/1280px`) rather than rem, so the responsive grid stays tied to the real viewport and keeps 3 columns on desktop instead of collapsing. + +The console is a single centered column capped at 1800px (`max-w-console` = `--container-console: 75rem`, scaled 1.5× by the root font-size) with horizontal padding 24px / 36px desktop. The header is a fixed glance-line: 96px tall (`h-16`), sticky, hairline bottom border, frosted (`bg-background/85` + `backdrop-blur-md`) so cards scroll under it. Below it, the page stacks with 36px rhythm: toolbar → result count → grid. + +Services render in a 3-column grid at desktop (`xl:grid-cols-3`); narrower viewports drop to 2 columns at `sm` and 1 below, but mobile is not a design target — the desktop layout is the committed truth. Cards are equal-height units; each card is a self-contained column with footer actions pinned to the bottom. The toolbar keeps its single-row search + segmented filters on desktop. + +Spacing follows the tailwind half-step rhythm (6 / 12 / 18 / 24 / 36px at the 150% root). Inside cards: content padding 24–30px, gaps 18px between identity row, description, and metadata; footer separated by a hairline with 18px top padding. + +## Elevation & Depth + +**Hairline precision** — depth is carried by tonal layering and hairline borders first, ambient shadow second. Every surface is flat at rest; elevation reads as a lighter fill (Night Ink → Panel → Raised) plus an 8% white hairline. Shadows are soft black ambient pools used only to lift interactive/critical layers off the page. + +### Shadow Vocabulary +- **Card** (`0 1px 0 rgba(0,0,0,0.4), 0 10px 28px -14px rgba(0,0,0,0.6)`): The resting card pool. Ambient, non-structural. +- **Card Hover** (`0 1px 0 rgba(0,0,0,0.4), 0 16px 36px -16px rgba(0,0,0,0.75)`): A card under the cursor — a slightly deeper pool, nothing dramatic. +- **Toast** (`0 8px 30px -8px rgba(0,0,0,0.7)`): The floating layer — toasts hover above everything. + +### Named Rules +**The Flat-At-Rest Rule.** Surfaces are flat at rest. Shadows appear only as a state response (hover, floating layers) or as ambient grounding. No shadows on chips, badges, or buttons — borders do that work. + +## Shapes + +The form language is **precision with a softened edge**: corners are rounded but never pill-soft on structural surfaces. Cards use 15px (`rounded-xl`), buttons and inputs 11px (`rounded-lg`), the brand mark 10px. Only status/state *chips* and the connection badge break into full pills (`rounded-full`) — the pill shape is reserved for state, never structure. Monogram tiles are 36px squares at `rounded-lg` with a hairline border and a tinted fill matching their status tone. The overall silhouette reads as a rack of neat, softly-cornered instruments. + +## Components + +### Buttons +- **Shape:** 11px radius, tight 1–3px gutters, 28px resting height (24/32/36 by size step). +- **Primary (Raspberry):** raspberry fill with frost-white text (6.2:1). Hover dims to 80% (`hover:bg-primary/80`); press nudges down 1px. +- **Status actions (Start / Update):** transparent fills with a tinted 30% border and tinted text — signal for Start, ember for Update. These read as "operational" rather than "branded". +- **Ghost:** transparent, ash text; hover warms to a muted fill. Used for uninstall (hover flips to alert) and the connection badge. +- **Focus:** raspberry ring at 30% (`ring-3 ring-primary/30`) + border shift; always visible for keyboard. +- **Disabled:** 50% opacity, pointer-events off. + +### Chips +- **Status chip:** full pill, 11px mono-adjacent sans, 1.5px gutter with a 2px status light. Tint follows the tone: signal/ember/alert at 10% fill over the surface with a 25% border. +- **Filter pill:** segmented control in a hairline tray (`bg-muted/60`); active pill lifts to `bg-card` with a hairline ring, inactive stays ash on hover. + +### Cards / Containers +- **Corner Style:** 15px (`rounded-xl`). +- **Background:** Panel (oklch(0.173 0.017 265)) + hairline Edge border. +- **Shadow Strategy:** ambient Card pool; hover deepens to Card Hover (see Elevation). +- **Internal Padding:** 20px horizontal, 16px top, content gap 12px; footer hairline-separated, 12px top. +- **Behavior:** hover deepens border to `border-border/90` and the shadow pool. The update badge is an ember chip with an up-arrow; version → latest is a mono "vX → vY" pair. + +### Inputs / Fields +- **Style:** 11px radius, 36px tall, muted-fill at 40% opacity over the surface, hairline `--input` border, ash placeholder. +- **Focus:** raspberry ring at 30% + border shift to `--ring`. No shadow. + +### Navigation +- **Style:** the sticky header is a hairline bar: brand block (mark + wordmark + mono subtitle) left; right side holds the installed count chip, the ember "updates" pill, and the connection badge. On desktop (the design target) all elements show in the glance-line. + +### Service Card (signature) +The rack unit. Left: 36px status-tinted monogram tile. Middle: title (sans 600) + vendor (mono), the image ref as a compact mono copy chip (host-stripped, truncating, click-to-copy with a toast), 2-line description clamp. Bottom metadata strip: current version chip, ember update chip when stale, container-ID copy chip when running, and a right-aligned mono "Source" link. Footer: uninstall ghost icon left; right side the correct action — Install (raspberry), Start (signal), Stop (outline), Update (ember) — one primary action per state, disabled while busy with a spinner. + +## Do's and Don'ts + +### Do: +- **Do** keep raspberry rare and purposeful — brand mark, one primary action, focus ring. +- **Do** use signal for running/connected and ember for warning/updates; these warm lights are the operator's truth. +- **Do** set every technical value (versions, images, names, counts) in JetBrains Mono. +- **Do** separate surfaces with hairlines and tonal steps, and keep shadows ambient-only. +- **Do** show exactly one primary action per card state, and disable it with a spinner while pending. +- **Do** gate all motion (status pings, toast entrance) behind `prefers-reduced-motion`. + +### Don't: +- **Don't** use blue for any status or accent role — the scene is raspberry + warm status lights only. +- **Don't** paint surfaces with full-strength saturated washes; status tints sit at 10–25% over the surface. +- **Don't** put shadows on chips, badges, or buttons — borders carry that weight. +- **Don't** add sans weights beyond 400/600, or introduce a second sans family. +- **Don't** pill-round structural elements (cards, buttons, inputs); pills are reserved for state chips and the connection badge. diff --git a/src/frontend/PRODUCT.md b/src/frontend/PRODUCT.md new file mode 100644 index 0000000..f0f8705 --- /dev/null +++ b/src/frontend/PRODUCT.md @@ -0,0 +1,59 @@ +# Product + + + +## Platform + +web + +## Users + +- Primary: dcntrlized node operators and self-hosters who run their own infrastructure and deploy services from the dcntrlized registry onto a Docker host. [Inferred from README positioning and docker-compose evidence; confirm.] +- Adjacent: developers and tinkerers evaluating Maestro and the dcntrlized ecosystem. + +## Product Purpose + +Maestro is the coordination layer of the dcntrlized ecosystem: a single web console that runs on a Docker host, lists the services installed on that node, lets the operator install new services from the dcntrlized container registry, and start, stop, update, and uninstall them. Success is a node operator managing the whole service stack from one console without dropping to the shell. + +## Positioning + +The mechanism is a Git-backed, decentralized service registry: services are published as OCI images with rich metadata in a Gitea registry, discovered through `/api/registry`, and installed by reference onto the node. The dashboard merges that remote catalog with the live local Docker state so installed services, available updates, and registry-only (not-yet-installed) services live in one view. Plainly: a decentralized app store that runs on your own hardware. + +## Operating Context + +- Web dashboard served from the Go backend on `:8080`, which talks to the local Docker socket. +- Live state flows over WebSocket (`ws://127.0.0.1:8080/api/ws`); the catalog is fetched once from `/api/registry`. +- Real-time statuses come from Docker (running, exited, stopped, created, restarting) plus UI states (installed, uninstalled). +- Service metadata (title, vendor, author, description, source, licenses, revision) comes from OCI annotations published with each image. +- Upstream registry is git.dcntrlized.org (Gitea), org `Dcntrlized`. +- Maestro itself runs as a container (image `maestro`); its own container is hidden from the list. +- Update detection compares the installed version against the registry latest via semver; versions are normalized to `X.Y.Z`. + +## Capabilities and Constraints + +- Capabilities: list installed + registry services; filter (all / running / updates / registry-only); search by name, image, or source; install, update (reinstall), start, stop, uninstall; live connection indicator; repository link per registry service. +- Constraints: browser talks to the backend on `127.0.0.1:8080` (dev proxy in Vite); no auth in the current UI; the data model merges local containers with remote registry items and must keep both sources distinct. +- Terminology: containers/services, registry, installed, running, "update available". + +## Brand Commitments + +- Name: Maestro. Organization: dcntrlized (lowercase, `.org`). Docker image name `maestro`. +- Open source (LICENSE present); dcntrlized ethos is decentralized, self-hosted, own-your-infrastructure. + +## Evidence on Hand + +- Backend Go source in `../src/` (handlers, lib) is the API and data-model truth. +- `services.json` at repo root lists registry examples: KeepNote (dcntrlized.org:8081), SafeGuard (dcntrlized.org:8082). +- README, docker-compose.yaml (mounts docker.sock, Gitea tokens), LICENSE. +- No DESIGN.md. Incumbent UI is a stock shadcn template — evidence of what it looked like, not authority over what it becomes. + +## Product Principles + +- Own your stack: the console runs on the node, beside the services it manages. +- One surface, live state: local Docker reality and the remote catalog appear in a single merged view. +- Act without the shell: install, start, stop, update, uninstall all happen from the UI. +- Honest status: every service shows a truthful current state and the action that fits it. + +## Accessibility & Inclusion + +- No product-specific requirement established yet; standard WCAG 2.x AA contrast and full keyboard support expected. diff --git a/src/frontend/bun.lock b/src/frontend/bun.lock index 8ad6d4e..5ac0086 100644 --- a/src/frontend/bun.lock +++ b/src/frontend/bun.lock @@ -5,17 +5,16 @@ "": { "name": "frontend", "dependencies": { - "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tailwindcss/vite": "^4.3.3", - "@types/semver": "^7.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", "preact": "^10.29.3", "react-aria-components": "^1.19.0", - "react-icons": "^5.7.0", - "semver": "^7.8.5", "shadcn": "^4.15.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", @@ -101,7 +100,9 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + "@fontsource-variable/ibm-plex-sans": ["@fontsource-variable/ibm-plex-sans@5.3.0", "", {}, "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], @@ -227,8 +228,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -675,8 +674,6 @@ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], - "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], - "react-stately": ["react-stately@3.48.0", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.36.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA=="], "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], @@ -701,7 +698,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -729,6 +726,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -819,12 +818,6 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -853,6 +846,8 @@ "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], + "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], diff --git a/src/frontend/index.html b/src/frontend/index.html index 7f3c7b9..f640ea2 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -4,9 +4,12 @@ - frontend + + + Maestro · dcntrlized console +
+ - diff --git a/src/frontend/package.json b/src/frontend/package.json index efa5072..b628105 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,17 +9,16 @@ "preview": "vite preview" }, "dependencies": { - "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tailwindcss/vite": "^4.3.3", - "@types/semver": "^7.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.27.0", "preact": "^10.29.3", "react-aria-components": "^1.19.0", - "react-icons": "^5.7.0", - "semver": "^7.8.5", "shadcn": "^4.15.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0" diff --git a/src/frontend/src/app.css b/src/frontend/src/app.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index 39412b8..27f5c47 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -1,341 +1,84 @@ -import './app.css' -import { ConnectionBadge } from '@lib/components/connectionBadge' -import { registry } from '@lib/ws/registry' -import { useEffect, useState, useMemo } from 'preact/hooks' -import { - type Container, - type RegistryItem, - STATUS_PRIORITY -} from '@lib/types' -import semver from 'semver' - -// Shadcn UI Component Imports -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@shadcn/components/ui/card' -import { Input } from '@shadcn/components/ui/input' -import { Badge } from '@shadcn/components/ui/badge' -import { InstallButton } from '@lib/components/installButton' -import { UninstallButton } from '@lib/components/uninstallButton' -import { StartButton } from '@lib/components/startButton' -import { StopButton } from '@lib/components/stopButton' - -// Helper to extract base image without tag or digest -function getBaseImage(imageRef?: string): string { - if (!imageRef) return '' - const untagged = imageRef.split('@')[0].split(':')[0] - return untagged.toLowerCase() -} - -// Helper to format version strings for semver comparison -function cleanVersion(v?: string): string | null { - if (!v) return null - const trimmed = v.trim() - return semver.valid(semver.coerce(trimmed)) -} +import { useMemo, useState } from 'preact/hooks' +import { useContainerStore } from '@lib/hooks/useContainerStore' +import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' +import { mergeServices } from '@lib/domain/merge' +import { filterServices, serviceCounts } from '@lib/domain/filter' +import type { ServiceFilter } from '@lib/types' +import { Header } from '@components/layout/header' +import { Toolbar } from '@components/layout/toolbar' +import { ServiceGrid } from '@components/service/service-grid' +import { Toaster } from '@components/ui/sonner' +import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout/status-states' export function App() { - const [rawContainers, setRawContainers] = useState(() => registry.getState().containers) - const [masterRegistry, setMasterRegistry] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [filter, setFilter] = useState<'all' | 'running' | 'updates' | 'missing'>('all') + const { containers, status } = useContainerStore() + const registry = useMasterRegistry() + const [query, setQuery] = useState('') + const [filter, setFilter] = useState('all') - useEffect(() => { - const unsubscribe = registry.subscribe(() => { - setRawContainers(registry.getState().containers) - }) - return unsubscribe - }, []) + const services = useMemo(() => { + const items = registry.state.kind === 'ok' ? registry.state.items : [] + return mergeServices(containers, items) + }, [containers, registry.state]) - useEffect(() => { - const getRegistryItems = async () => { - try { - const response = await fetch('/api/registry') - const data: Array = await response.json() - setMasterRegistry(data) - } catch (err) { - console.error("Failed to fetch remote registry:", err) - } - } - getRegistryItems() - }, []) + const counts = useMemo(() => serviceCounts(services), [services]) + const visible = useMemo( + () => filterServices(services, query, filter), + [services, query, filter] + ) - // Hydrate into a single clean RegistryItem list - const containers = useMemo(() => { - const map = new Map() + const loading = registry.state.kind === 'loading' && containers.length === 0 + const registryError = registry.state.kind === 'error' - // 1. Process local containers from Websocket - for (const c of rawContainers) { - if (c.name.toUpperCase().includes("MAESTRO")) continue - - const key = c.name.toLowerCase() - const existing = map.get(key) - const currentPriority = STATUS_PRIORITY[(c.status || '').toLowerCase()] ?? 0 - - if (!existing) { - map.set(key, { - id: c.id, - name: c.name, - version: c.version || '', - image: c.image, - source: c.source, - status: c.status, - origin: 'local' - }) - } else { - const existingPriority = STATUS_PRIORITY[(existing.status || '').toLowerCase()] ?? 0 - if (currentPriority > existingPriority) { - existing.id = c.id - existing.status = c.status - existing.image = c.image - } - } - } - - // 2. Match remote registry items against local containers - for (const r of masterRegistry) { - if (r.name.toUpperCase().includes("MAESTRO")) continue - - const rNameLower = r.name.toLowerCase() - const rBaseImage = getBaseImage(r.image) - - let existingKey: string | undefined - for (const [key, item] of map.entries()) { - const itemBaseImage = getBaseImage(item.image) - if (key === rNameLower || (rBaseImage && itemBaseImage === rBaseImage)) { - existingKey = key - break - } - } - - const existing = existingKey ? map.get(existingKey) : undefined - - if (existing) { - existing.origin = 'both' - existing.remoteSource = r.source - existing.url = r.url || existing.url - existing.latestVersion = r.version - existing.title = r.title || existing.title - existing.vendor = r.vendor || existing.vendor - existing.author = r.author || existing.author - existing.description = r.description || existing.description - - const currentV = cleanVersion(existing.version) - const remoteV = cleanVersion(r.version) - - if (currentV && remoteV && semver.gt(remoteV, currentV)) { - existing.hasUpdate = true - } - } else { - map.set(rNameLower, { - ...r, - id: `remote-${r.name}`, - status: 'uninstalled', - origin: 'remote', - version: '', - latestVersion: r.version, - remoteSource: r.source - }) - } - } - - return Array.from(map.values()) - }, [rawContainers, masterRegistry]) - - const filteredContainers = useMemo(() => { - return containers.filter((c) => { - const searchStr = searchQuery.toLowerCase() - const matchesSearch = - c.name.toLowerCase().includes(searchStr) || - c.image?.toLowerCase().includes(searchStr) || - c.source?.toLowerCase().includes(searchStr) || - c.remoteSource?.toLowerCase().includes(searchStr) - - if (filter === 'running') return matchesSearch && c.status?.toLowerCase() === 'running' - if (filter === 'updates') return matchesSearch && c.hasUpdate - if (filter === 'missing') return matchesSearch && c.origin === 'remote' - return matchesSearch - }) - }, [containers, searchQuery, filter]) - - const totalInstalled = containers.filter(c => c.origin !== 'remote').length - const totalUpdates = containers.filter(c => c.hasUpdate).length - const totalMissing = containers.filter(c => c.origin === 'remote').length + const showUpdates = () => setFilter('updates') + const clearFilters = () => { + setQuery('') + setFilter('all') + } return ( -
-
-
-
-

Container Manager

- - {totalInstalled} Installed - - {totalUpdates > 0 && ( - - {totalUpdates} {totalUpdates === 1 ? 'Update Available' : 'Updates Available'} - - )} -
- -
-
+
+
-
-
-
- - - - setSearchQuery((e.target as HTMLInputElement).value)} - className="pl-9" +
+ {loading ? ( + + ) : ( + <> + {registryError && } + + -
-
- - - - -
-
+

+ Showing {visible.length} of {counts.total} services +

-
- {filteredContainers.map((container) => { - const isRemoteOnly = container.origin === 'remote' - const isInGitRegistry = container.origin === 'both' || isRemoteOnly - const isRunning = (container.status || '').toLowerCase() === 'running' - - return ( - - -
- - {container.title || container.name} - - - {!isRemoteOnly && ( - - )} - {isRemoteOnly ? 'Not Installed' : container.status} - -
-
- - - {container.image && ( -
- Image: - - {container.image} - -
- )} - {(container.source || container.remoteSource) && ( - - )} - {!isRemoteOnly && container.version && ( -
- Installed Version: - {container.version} -
- )} - {isRemoteOnly && container.latestVersion && ( -
- Available Ver: - {container.latestVersion} -
- )} - {container.hasUpdate && ( -
- Latest Ver: - {container.latestVersion} -
- )} -
- - {isInGitRegistry && ( - - - Repo → - - -
- { isRemoteOnly ? : null } - { container.hasUpdate ? : null} - { container.status === 'installed' ? <>: null} - { isRunning ? : null} - { container.status === 'stopped' || container.status === 'exited' ? <>: null} -
-
- )} -
- ) - })} -
+ {visible.length === 0 ? ( + + ) : ( + + )} + + )}
+ +
) -} \ No newline at end of file +} diff --git a/src/frontend/src/assets/hero.png b/src/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/src/frontend/src/assets/preact.svg b/src/frontend/src/assets/preact.svg deleted file mode 100644 index 908f17d..0000000 --- a/src/frontend/src/assets/preact.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/frontend/src/assets/vite.svg b/src/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/frontend/src/components/brand-mark.tsx b/src/frontend/src/components/brand-mark.tsx new file mode 100644 index 0000000..ba3b6f5 --- /dev/null +++ b/src/frontend/src/components/brand-mark.tsx @@ -0,0 +1,26 @@ +import { cn } from '@lib/utils' + +/** + * The Maestro mark: a stacked deck with a lit LED — the conductor's rack. + */ +export function BrandMark({ className }: { className?: string }) { + return ( + + ) +} diff --git a/src/frontend/src/components/connection/connection-badge.tsx b/src/frontend/src/components/connection/connection-badge.tsx new file mode 100644 index 0000000..7a3129a --- /dev/null +++ b/src/frontend/src/components/connection/connection-badge.tsx @@ -0,0 +1,46 @@ +import { cn } from '@lib/utils' +import { connectWebSocket } from '@lib/ws/client' +import type { ConnectionStatus } from '@lib/ws/registry' +import { Button } from '@components/ui/button' + +interface ConnectionProps { + status: ConnectionStatus +} + +function connectionMeta(status: ConnectionStatus) { + switch (status) { + case 'open': + return { label: 'Live', dot: 'bg-success', ping: 'bg-success' } + case 'connecting': + return { label: 'Connecting…', dot: 'bg-warning', ping: 'bg-warning' } + case 'closing': + return { label: 'Connecting…', dot: 'bg-warning', ping: 'bg-warning' } + default: + return { label: 'Reconnecting…', dot: 'bg-destructive', ping: 'bg-destructive' } + } +} + +export function ConnectionBadge({ status }: ConnectionProps) { + const meta = connectionMeta(status) + + return ( + + ) +} diff --git a/src/frontend/src/components/container/status.ts b/src/frontend/src/components/container/status.ts new file mode 100644 index 0000000..8e9a1d9 --- /dev/null +++ b/src/frontend/src/components/container/status.ts @@ -0,0 +1,81 @@ +export interface StatusMeta { + label: string + dot: string + ping: boolean + chip: string + tile: string +} + +/** + * Single source of truth for how a service state reads: the light, the chip, + * and the monogram tile all derive from the same tone mapping. + */ +export function statusMeta(status: string): StatusMeta { + switch (status.toLowerCase()) { + case 'running': + return { + label: 'Running', + dot: 'bg-success', + ping: true, + chip: 'border-success/25 bg-success/10 text-success', + tile: 'border-success/20 bg-success/10 text-success', + } + case 'starting': + return { + label: 'Starting', + dot: 'bg-warning', + ping: true, + chip: 'border-warning/25 bg-warning/10 text-warning', + tile: 'border-warning/20 bg-warning/10 text-warning', + } + case 'restarting': + return { + label: 'Restarting', + dot: 'bg-warning', + ping: true, + chip: 'border-warning/25 bg-warning/10 text-warning', + tile: 'border-warning/20 bg-warning/10 text-warning', + } + case 'stopped': + case 'exited': + return { + label: 'Stopped', + dot: 'bg-warning', + ping: false, + chip: 'border-warning/25 bg-warning/10 text-warning', + tile: 'border-warning/20 bg-warning/10 text-warning', + } + case 'created': + return { + label: 'Created', + dot: 'bg-foreground/60', + ping: false, + chip: 'border-border bg-secondary text-secondary-foreground', + tile: 'border-border bg-secondary text-muted-foreground', + } + case 'installed': + return { + label: 'Installed', + dot: 'bg-foreground/60', + ping: false, + chip: 'border-border bg-secondary text-secondary-foreground', + tile: 'border-border bg-secondary text-muted-foreground', + } + case 'uninstalled': + return { + label: 'Not installed', + dot: 'bg-muted-foreground/60', + ping: false, + chip: 'border-border bg-muted text-muted-foreground', + tile: 'border-border bg-muted text-muted-foreground', + } + default: + return { + label: status || 'Unknown', + dot: 'bg-foreground/50', + ping: false, + chip: 'border-border bg-muted text-muted-foreground', + tile: 'border-border bg-muted text-muted-foreground', + } + } +} diff --git a/src/frontend/src/components/layout/header.tsx b/src/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..d8c7d7f --- /dev/null +++ b/src/frontend/src/components/layout/header.tsx @@ -0,0 +1,53 @@ +import { BrandMark } from '@components/brand-mark' +import { ConnectionBadge } from '@components/connection/connection-badge' +import type { ConnectionStatus } from '@lib/ws/registry' + +interface HeaderProps { + installed: number + updates: number + connection: ConnectionStatus + onShowUpdates: () => void +} + +export function Header({ installed, updates, connection, onShowUpdates }: HeaderProps) { + return ( +
+
+
+ +
+

+ Maestro +

+

+ dcntrlized console +

+
+
+ +
+ + {installed} installed + + + {updates > 0 && ( + + )} + + +
+
+
+ ) +} diff --git a/src/frontend/src/components/layout/status-states.tsx b/src/frontend/src/components/layout/status-states.tsx new file mode 100644 index 0000000..5df38c3 --- /dev/null +++ b/src/frontend/src/components/layout/status-states.tsx @@ -0,0 +1,70 @@ +import { ServerCrash, Boxes } from 'lucide-react' +import { BrandMark } from '@components/brand-mark' +import { Button } from '@components/ui/button' +import { Spinner } from '@components/ui/spinner' + +export function LoadingState() { + return ( +
+
+ +
+
+ + Connecting to the node… +
+
+ ) +} + +interface RegistryErrorProps { + onRetry: () => void +} + +export function RegistryErrorState({ onRetry }: RegistryErrorProps) { + return ( +
+
+
+ +
+ ) +} + +interface EmptyStateProps { + query: string + filter: string + total: number + onClear: () => void +} + +export function EmptyState({ query, filter, total, onClear }: EmptyStateProps) { + const emptyNode = total === 0 && !query && filter === 'all' + return ( +
+
+ ) +} diff --git a/src/frontend/src/components/layout/toolbar.tsx b/src/frontend/src/components/layout/toolbar.tsx new file mode 100644 index 0000000..ab4bca0 --- /dev/null +++ b/src/frontend/src/components/layout/toolbar.tsx @@ -0,0 +1,92 @@ +import { Search } from 'lucide-react' +import { Input } from '@components/ui/input' +import { FILTERS } from '@lib/domain/filter' +import type { ServiceFilter } from '@lib/types' +import { cn } from '@lib/utils' + +export interface ToolbarCounts { + total: number + running: number + updates: number + available: number +} + +interface ToolbarProps { + query: string + onQuery: (query: string) => void + filter: ServiceFilter + onFilter: (filter: ServiceFilter) => void + counts: ToolbarCounts +} + +const COUNT_KEY: Record = { + all: 'total', + running: 'running', + updates: 'updates', + available: 'available', +} + +export function Toolbar({ query, onQuery, filter, onFilter, counts }: ToolbarProps) { + return ( +
+
+
+ +
+ {FILTERS.map((option) => { + const active = filter === option.id + const count = counts[COUNT_KEY[option.id]] + return ( + + ) + })} +
+
+ ) +} diff --git a/src/frontend/src/components/service/service-card.tsx b/src/frontend/src/components/service/service-card.tsx new file mode 100644 index 0000000..e8236a8 --- /dev/null +++ b/src/frontend/src/components/service/service-card.tsx @@ -0,0 +1,265 @@ +import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download } from 'lucide-react' +import { Button } from '@components/ui/button' +import { Card, CardContent, CardFooter } from '@components/ui/card' +import { statusMeta } from '@components/container/status' +import { toast } from 'sonner' +import { installContainer, startContainer, stopContainer, uninstallContainer } from '@lib/api/containers' +import type { Service } from '@lib/types' +import { cn } from '@lib/utils' +import { useAsyncAction } from '@lib/hooks/useAsyncAction' + +interface ServiceCardProps { + service: Service +} + +function withV(s?: string): string | null { + if (!s) return null + return s.startsWith('v') || /^\d/.test(s) ? s : `v${s}` +} + +function formatVersion(s?: string): string | null { + const v = withV(s) + return v ? `v${v.replace(/^v/, '')}` : null +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error' +} + +export function ServiceCard({ service }: ServiceCardProps) { + const meta = statusMeta(service.status) + const installed = service.origin !== 'remote' + const running = + service.status.toLowerCase() === 'running' || + service.status.toLowerCase() === 'starting' || + service.status.toLowerCase() === 'restarting' + + const current = formatVersion(service.version) + const latest = formatVersion(service.latestVersion) + + const shortContainerId = service.id.slice(0, 12) + const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name + + const copyId = useAsyncAction(async () => { + try { + await navigator.clipboard.writeText(service.id) + toast.success(`Copied ${service.name} container ID`, { description: service.id }) + } catch (error) { + toast.error(`Failed to copy ${service.name} container ID`, { description: errorMessage(error) }) + } + }) + + const copyImage = useAsyncAction(async () => { + if (!service.image) return + try { + await navigator.clipboard.writeText(service.image) + toast.success(`Copied ${service.name} image ref`, { description: service.image }) + } catch (error) { + toast.error(`Failed to copy ${service.name} image ref`, { description: errorMessage(error) }) + } + }) + + const install = useAsyncAction(async () => { + try { + await installContainer(service) + toast.success(`Installed ${service.name}`, { description: latest ? `v${latest.replace(/^v/, '')} from the dcntrlized registry` : undefined }) + } catch (error) { + toast.error(`Failed to install ${service.name}`, { description: errorMessage(error) }) + } + }) + + const update = useAsyncAction(async () => { + try { + await uninstallContainer(service) + await installContainer(service) + toast.success(`Updated ${service.name}`, { description: latest ? `v${current ?? ''} → ${latest}`.trim() : undefined }) + } catch (error) { + toast.error(`Failed to update ${service.name}`, { description: errorMessage(error) }) + } + }) + + const start = useAsyncAction(async () => { + try { + await startContainer(service) + toast.success(`Started ${service.name}`) + } catch (error) { + toast.error(`Failed to start ${service.name}`, { description: errorMessage(error) }) + } + }) + + const stop = useAsyncAction(async () => { + try { + await stopContainer(service) + toast.success(`Stopped ${service.name}`) + } catch (error) { + toast.error(`Failed to stop ${service.name}`, { description: errorMessage(error) }) + } + }) + + const uninstall = useAsyncAction(async () => { + try { + await uninstallContainer(service) + toast.success(`Uninstalled ${service.name}`) + } catch (error) { + toast.error(`Failed to uninstall ${service.name}`, { description: errorMessage(error) }) + } + }) + + const busy = install.pending || update.pending || start.pending || stop.pending || uninstall.pending + + return ( + + +
+ + +
+
+

+ {service.title || service.name} +

+ {service.vendor && ( + {service.vendor} + )} +
+ {service.image ? ( + + ) : ( +

{service.name}

+ )} +
+ + + {meta.ping && ( +
+ + {service.description && ( +

+ {service.description} +

+ )} + +
+ + {installed ? current ?? '—' : latest ?? '—'} + + {service.hasUpdate && latest && ( + + + )} + {running && installed && ( + + )} + {service.source && ( + + Source + + )} +
+
+ + + {installed && ( + + )} + + {installed && service.hasUpdate && ( + + )} + + {running ? ( + + ) : installed ? ( + + ) : ( + + )} + +
+ ) +} diff --git a/src/frontend/src/components/service/service-grid.tsx b/src/frontend/src/components/service/service-grid.tsx new file mode 100644 index 0000000..d06a5fc --- /dev/null +++ b/src/frontend/src/components/service/service-grid.tsx @@ -0,0 +1,16 @@ +import type { Service } from '@lib/types' +import { ServiceCard } from './service-card' + +interface ServiceGridProps { + services: Service[] +} + +export function ServiceGrid({ services }: ServiceGridProps) { + return ( +
+ {services.map((service) => ( + + ))} +
+ ) +} diff --git a/src/frontend/src/components/ui/badge.tsx b/src/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..824f39d --- /dev/null +++ b/src/frontend/src/components/ui/badge.tsx @@ -0,0 +1,54 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + secondary: "bg-secondary text-secondary-foreground", + destructive: "bg-destructive/10 text-destructive", + outline: "border-border text-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { + render?: (props: React.HTMLAttributes) => React.ReactNode + }) { + if (render) { + const renderProps = { + "data-slot": "badge", + "data-variant": variant, + className: cn(badgeVariants({ variant }), className), + ...props, + } + + return render(renderProps) + } + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/frontend/src/lib/shadcn/components/ui/button.tsx b/src/frontend/src/components/ui/button.tsx similarity index 65% rename from src/frontend/src/lib/shadcn/components/ui/button.tsx rename to src/frontend/src/components/ui/button.tsx index a2276d0..d8d2b73 100644 --- a/src/frontend/src/lib/shadcn/components/ui/button.tsx +++ b/src/frontend/src/components/ui/button.tsx @@ -7,22 +7,20 @@ import { type LinkProps as LinkPrimitiveProps, } from "react-aria-components" -import { cn } from "@lib/shadcn/lib/utils" +import { cn } from "@lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + "border-input bg-transparent text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-muted", + ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted", destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/src/frontend/src/components/ui/card.tsx b/src/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..2c50604 --- /dev/null +++ b/src/frontend/src/components/ui/card.tsx @@ -0,0 +1,89 @@ +import * as React from "react" + +import { cn } from "@lib/utils" + +function Card({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/frontend/src/components/ui/input.tsx b/src/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..c1ff3b5 --- /dev/null +++ b/src/frontend/src/components/ui/input.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { + composeRenderProps, + Input as InputPrimitive, +} from "react-aria-components" + +import { cn } from "@lib/utils" + +function Input({ + className, + type, + ...props +}: React.ComponentProps) { + return ( + + cn( + "h-9 w-full min-w-0 rounded-lg border border-input bg-muted/40 px-3 py-1.5 text-sm transition-[color,box-shadow] duration-150 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50", + className + ) + )} + {...props} + /> + ) +} + +export { Input } diff --git a/src/frontend/src/components/ui/sonner.tsx b/src/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..2a59df6 --- /dev/null +++ b/src/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +import { CheckCircle2, XCircle } from 'lucide-react' +import { Toaster as Sonner } from 'sonner' +import type * as React from 'react' + +type ToasterProps = React.ComponentProps + +function Toaster({ ...props }: ToasterProps) { + return ( +