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 02251f4..0000000 Binary files a/src/frontend/src/assets/hero.png and /dev/null differ 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 ( +