@porkytheblack/garage-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.env.example +3 -0
  2. package/DESIGN.md +90 -0
  3. package/README.md +62 -0
  4. package/app/(app)/credentials/add-profile-modal.tsx +116 -0
  5. package/app/(app)/credentials/add-provider-modal.tsx +172 -0
  6. package/app/(app)/credentials/edit-provider-modal.tsx +107 -0
  7. package/app/(app)/credentials/edit-reasoning-modal.tsx +96 -0
  8. package/app/(app)/credentials/form.tsx +132 -0
  9. package/app/(app)/credentials/model-combobox.tsx +151 -0
  10. package/app/(app)/credentials/page.tsx +126 -0
  11. package/app/(app)/keys/page.tsx +164 -0
  12. package/app/(app)/layout.tsx +53 -0
  13. package/app/(app)/namespaces/list.tsx +47 -0
  14. package/app/(app)/namespaces/mint-dialog.tsx +64 -0
  15. package/app/(app)/namespaces/page.tsx +137 -0
  16. package/app/(app)/page.tsx +86 -0
  17. package/app/(app)/provisioning/page.tsx +84 -0
  18. package/app/(app)/sessions/[id]/page.tsx +184 -0
  19. package/app/(app)/sessions/page.tsx +88 -0
  20. package/app/(app)/storage/page.tsx +164 -0
  21. package/app/(app)/storage/toggle.tsx +43 -0
  22. package/app/(app)/workspaces/list.tsx +34 -0
  23. package/app/(app)/workspaces/page.tsx +111 -0
  24. package/app/globals.css +297 -0
  25. package/app/layout.tsx +32 -0
  26. package/app/login/page.tsx +62 -0
  27. package/components/app-sidebar.tsx +120 -0
  28. package/components/app-topbar.tsx +76 -0
  29. package/components/brand.tsx +47 -0
  30. package/components/chat/composer.tsx +199 -0
  31. package/components/chat/conversation.tsx +86 -0
  32. package/components/chat/error-card.tsx +89 -0
  33. package/components/chat/markdown.tsx +12 -0
  34. package/components/chat/message.tsx +99 -0
  35. package/components/chat/permission-prompt.tsx +45 -0
  36. package/components/chat/slash-menu.tsx +54 -0
  37. package/components/chat/task-list.tsx +56 -0
  38. package/components/chat/tool-call.tsx +91 -0
  39. package/components/fleet/lanes.tsx +107 -0
  40. package/components/fleet/launch.tsx +99 -0
  41. package/components/fleet/onboarding-launch.tsx +46 -0
  42. package/components/fleet/onboarding-model.tsx +109 -0
  43. package/components/fleet/onboarding-provider.tsx +137 -0
  44. package/components/fleet/onboarding-shared.tsx +66 -0
  45. package/components/fleet/onboarding.tsx +75 -0
  46. package/components/primitives.tsx +65 -0
  47. package/components/provisioning/provision-dialog.tsx +130 -0
  48. package/components/session/agent-roster.tsx +121 -0
  49. package/components/session/files-panel.tsx +170 -0
  50. package/components/session/files-remote.tsx +63 -0
  51. package/components/session/inspector.tsx +148 -0
  52. package/components/session/model-switcher.tsx +59 -0
  53. package/components/session/new-session-dialog.tsx +139 -0
  54. package/components/session/reasoning-knob.tsx +100 -0
  55. package/components/session/session-switcher.tsx +62 -0
  56. package/components/shared.tsx +171 -0
  57. package/components/theme-toggle.tsx +26 -0
  58. package/components/ui/avatar.tsx +39 -0
  59. package/components/ui/badge.tsx +30 -0
  60. package/components/ui/button.tsx +45 -0
  61. package/components/ui/card.tsx +44 -0
  62. package/components/ui/command.tsx +90 -0
  63. package/components/ui/dialog.tsx +88 -0
  64. package/components/ui/dropdown-menu.tsx +77 -0
  65. package/components/ui/input.tsx +22 -0
  66. package/components/ui/label.tsx +22 -0
  67. package/components/ui/popover.tsx +33 -0
  68. package/components/ui/scroll-area.tsx +41 -0
  69. package/components/ui/select.tsx +100 -0
  70. package/components/ui/separator.tsx +25 -0
  71. package/components/ui/skeleton.tsx +7 -0
  72. package/components/ui/sonner.tsx +28 -0
  73. package/components/ui/table.tsx +51 -0
  74. package/components/ui/tabs.tsx +50 -0
  75. package/components/ui/textarea.tsx +20 -0
  76. package/components/ui/tooltip.tsx +30 -0
  77. package/components.json +21 -0
  78. package/lib/api.ts +85 -0
  79. package/lib/auth.tsx +80 -0
  80. package/lib/format.ts +34 -0
  81. package/lib/hooks.ts +65 -0
  82. package/lib/launch.ts +25 -0
  83. package/lib/template.ts +34 -0
  84. package/lib/theme.ts +71 -0
  85. package/lib/types.ts +262 -0
  86. package/lib/useSession.ts +193 -0
  87. package/lib/utils.ts +7 -0
  88. package/lib/verify-provider.ts +31 -0
  89. package/next.config.mjs +16 -0
  90. package/package.json +49 -0
  91. package/postcss.config.mjs +6 -0
  92. package/tailwind.config.ts +94 -0
  93. package/tsconfig.json +21 -0
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const TooltipProvider = TooltipPrimitive.Provider;
8
+ const Tooltip = TooltipPrimitive.Root;
9
+ const TooltipTrigger = TooltipPrimitive.Trigger;
10
+
11
+ const TooltipContent = React.forwardRef<
12
+ React.ElementRef<typeof TooltipPrimitive.Content>,
13
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
14
+ >(({ className, sideOffset = 6, ...props }, ref) => (
15
+ <TooltipPrimitive.Portal>
16
+ <TooltipPrimitive.Content
17
+ ref={ref}
18
+ sideOffset={sideOffset}
19
+ className={cn(
20
+ "z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md",
21
+ "data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ </TooltipPrimitive.Portal>
27
+ ));
28
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29
+
30
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/lib"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
package/lib/api.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Typed client for the Garage REST API. All requests are made from the browser
3
+ * with the admin JWT (or an API key) as a Bearer token and an optional
4
+ * X-Glorp-Namespace header. The base URL comes from NEXT_PUBLIC_GARAGE_URL.
5
+ */
6
+
7
+ export const GARAGE_URL = (process.env.NEXT_PUBLIC_GARAGE_URL ?? "http://127.0.0.1:4271").replace(/\/$/, "");
8
+ export const API_BASE = `${GARAGE_URL}/api/v1`;
9
+
10
+ const TOKEN_KEY = "garage.token";
11
+ const NS_KEY = "garage.namespace";
12
+
13
+ export function getToken(): string | null {
14
+ return typeof window === "undefined" ? null : localStorage.getItem(TOKEN_KEY);
15
+ }
16
+ export function setToken(token: string | null): void {
17
+ if (typeof window === "undefined") return;
18
+ if (token) localStorage.setItem(TOKEN_KEY, token);
19
+ else localStorage.removeItem(TOKEN_KEY);
20
+ }
21
+ export function getNamespace(): string | null {
22
+ return typeof window === "undefined" ? null : localStorage.getItem(NS_KEY);
23
+ }
24
+ export function setNamespace(ns: string | null): void {
25
+ if (typeof window === "undefined") return;
26
+ if (ns) localStorage.setItem(NS_KEY, ns);
27
+ else localStorage.removeItem(NS_KEY);
28
+ }
29
+
30
+ export class ApiError extends Error {
31
+ constructor(
32
+ readonly status: number,
33
+ message: string,
34
+ ) {
35
+ super(message);
36
+ }
37
+ }
38
+
39
+ interface Opts {
40
+ method?: string;
41
+ body?: unknown;
42
+ namespace?: string | null;
43
+ auth?: boolean;
44
+ }
45
+
46
+ export async function api<T>(path: string, opts: Opts = {}): Promise<T> {
47
+ const headers: Record<string, string> = { "content-type": "application/json" };
48
+ const token = getToken();
49
+ if (opts.auth !== false && token) headers.authorization = `Bearer ${token}`;
50
+ const ns = opts.namespace ?? getNamespace();
51
+ if (ns) headers["x-glorp-namespace"] = ns;
52
+
53
+ const res = await fetch(API_BASE + path, {
54
+ method: opts.method ?? "GET",
55
+ headers,
56
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
57
+ });
58
+
59
+ const text = await res.text();
60
+ const data = text ? safeParse(text) : null;
61
+ if (!res.ok) {
62
+ const message = (data && (data.message || data.error)) || res.statusText;
63
+ throw new ApiError(res.status, message);
64
+ }
65
+ return data as T;
66
+ }
67
+
68
+ function safeParse(text: string): any {
69
+ try {
70
+ return JSON.parse(text);
71
+ } catch {
72
+ return { raw: text };
73
+ }
74
+ }
75
+
76
+ /** WebSocket URL for a session's event stream (token + ns ride as query params). */
77
+ export function sessionWsUrl(id: string): string {
78
+ const u = new URL(`${API_BASE}/sessions/${id}/events`);
79
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
80
+ const token = getToken();
81
+ if (token) u.searchParams.set("api_key", token);
82
+ const ns = getNamespace();
83
+ if (ns) u.searchParams.set("ns", ns);
84
+ return u.toString();
85
+ }
package/lib/auth.tsx ADDED
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Admin auth context. Holds the JWT + identity, exposes login/logout, and
5
+ * gates the dashboard: unauthenticated users are routed to /login.
6
+ */
7
+
8
+ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
9
+ import { useRouter, usePathname } from "next/navigation";
10
+ import { api, setToken, getToken } from "./api";
11
+ import type { Identity } from "./types";
12
+
13
+ interface AuthState {
14
+ identity: Identity | null;
15
+ ready: boolean;
16
+ login: (username: string, password: string) => Promise<void>;
17
+ logout: () => void;
18
+ }
19
+
20
+ const Ctx = createContext<AuthState | null>(null);
21
+
22
+ export function AuthProvider({ children }: { children: ReactNode }) {
23
+ const [identity, setIdentity] = useState<Identity | null>(null);
24
+ const [ready, setReady] = useState(false);
25
+ const router = useRouter();
26
+ const pathname = usePathname();
27
+
28
+ const refresh = useCallback(async () => {
29
+ if (!getToken()) {
30
+ setIdentity(null);
31
+ setReady(true);
32
+ return;
33
+ }
34
+ try {
35
+ const me = await api<Identity>("/auth/me");
36
+ setIdentity(me.authenticated ? me : null);
37
+ } catch {
38
+ setIdentity(null);
39
+ } finally {
40
+ setReady(true);
41
+ }
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ void refresh();
46
+ }, [refresh]);
47
+
48
+ // Redirect rules once we know the auth state.
49
+ useEffect(() => {
50
+ if (!ready) return;
51
+ const onLogin = pathname === "/login";
52
+ if (!identity && !onLogin) router.replace("/login");
53
+ if (identity && onLogin) router.replace("/");
54
+ }, [ready, identity, pathname, router]);
55
+
56
+ const login = useCallback(async (username: string, password: string) => {
57
+ const res = await api<{ token: string }>("/auth/login", {
58
+ method: "POST",
59
+ body: { username, password },
60
+ auth: false,
61
+ });
62
+ setToken(res.token);
63
+ await refresh();
64
+ router.replace("/");
65
+ }, [refresh, router]);
66
+
67
+ const logout = useCallback(() => {
68
+ setToken(null);
69
+ setIdentity(null);
70
+ router.replace("/login");
71
+ }, [router]);
72
+
73
+ return <Ctx.Provider value={{ identity, ready, login, logout }}>{children}</Ctx.Provider>;
74
+ }
75
+
76
+ export function useAuth(): AuthState {
77
+ const v = useContext(Ctx);
78
+ if (!v) throw new Error("useAuth must be used within AuthProvider");
79
+ return v;
80
+ }
package/lib/format.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** Small humanizers so the UI shows meaning, not raw machine numbers. */
2
+
3
+ /** Relative time: "just now", "5m", "2h", "3d", then a short date. */
4
+ export function timeAgo(iso: string | null | undefined): string {
5
+ if (!iso) return "—";
6
+ const then = new Date(iso).getTime();
7
+ if (Number.isNaN(then)) return "—";
8
+ const secs = Math.floor((Date.now() - then) / 1000);
9
+ if (secs < 45) return "just now";
10
+ const mins = Math.floor(secs / 60);
11
+ if (mins < 60) return `${mins}m`;
12
+ const hrs = Math.floor(mins / 60);
13
+ if (hrs < 24) return `${hrs}h`;
14
+ const days = Math.floor(hrs / 24);
15
+ if (days < 30) return `${days}d`;
16
+ return new Date(then).toLocaleDateString(undefined, { month: "short", day: "numeric" });
17
+ }
18
+
19
+ /** Compact counts: 980 → "980", 12_300 → "12.3K", 4_500_000 → "4.5M". */
20
+ export function compact(n: number | null | undefined): string {
21
+ if (n == null || Number.isNaN(n)) return "—";
22
+ return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n);
23
+ }
24
+
25
+ /** Last path segment of a workspace path, for a friendly label. */
26
+ export function baseName(path: string): string {
27
+ const parts = path.replace(/\/+$/, "").split("/");
28
+ return parts[parts.length - 1] || path;
29
+ }
30
+
31
+ /** "1 step" / "3 steps". */
32
+ export function plural(n: number, word: string): string {
33
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
34
+ }
package/lib/hooks.ts ADDED
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ /** Small data-fetching hook so pages stay declarative. Toasts use `sonner`. */
4
+
5
+ import { useState, useEffect, useCallback, useRef } from "react";
6
+ import { api, ApiError } from "./api";
7
+
8
+ export interface Query<T> {
9
+ data: T | null;
10
+ error: string | null;
11
+ loading: boolean;
12
+ /** True only while a silent background refresh (poll/reload) is in flight. */
13
+ refreshing: boolean;
14
+ reload: () => void;
15
+ }
16
+
17
+ /**
18
+ * Fetch `path` on mount and whenever a dep changes. Pass `pollMs` to keep the
19
+ * data live: it refetches on an interval *silently* — `loading` stays false and
20
+ * the previous data stays on screen, so polled views never flicker.
21
+ */
22
+ export function useQuery<T>(path: string | null, deps: unknown[] = [], pollMs?: number): Query<T> {
23
+ const [data, setData] = useState<T | null>(null);
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [loading, setLoading] = useState(true);
26
+ const [refreshing, setRefreshing] = useState(false);
27
+ const [tick, setTick] = useState(0);
28
+ const hasData = useRef(false);
29
+
30
+ useEffect(() => {
31
+ if (!path) {
32
+ setLoading(false);
33
+ return;
34
+ }
35
+ let cancelled = false;
36
+ if (hasData.current) setRefreshing(true);
37
+ else setLoading(true);
38
+ api<T>(path)
39
+ .then((d) => {
40
+ if (cancelled) return;
41
+ setData(d);
42
+ hasData.current = true;
43
+ setError(null);
44
+ })
45
+ .catch((e: ApiError) => !cancelled && setError(e.message))
46
+ .finally(() => {
47
+ if (cancelled) return;
48
+ setLoading(false);
49
+ setRefreshing(false);
50
+ });
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [path, tick, ...deps]);
56
+
57
+ useEffect(() => {
58
+ if (!path || !pollMs) return;
59
+ const t = setInterval(() => setTick((n) => n + 1), pollMs);
60
+ return () => clearInterval(t);
61
+ }, [path, pollMs]);
62
+
63
+ const reload = useCallback(() => setTick((t) => t + 1), []);
64
+ return { data, error, loading, refreshing, reload };
65
+ }
package/lib/launch.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { api } from "./api";
2
+ import type { SessionDto } from "./types";
3
+
4
+ export interface LaunchOpts {
5
+ prompt?: string;
6
+ workspaceId?: string;
7
+ workspace?: string;
8
+ profileId?: string;
9
+ permissionMode?: string;
10
+ }
11
+
12
+ /** Create a session (optionally with a first message) and return its id. */
13
+ export async function launchSession(opts: LaunchOpts): Promise<string> {
14
+ const body: Record<string, unknown> = {};
15
+ if (opts.workspaceId) body.workspaceId = opts.workspaceId;
16
+ else if (opts.workspace?.trim()) body.workspace = opts.workspace.trim();
17
+ if (opts.profileId) body.profileId = opts.profileId;
18
+ if (opts.permissionMode) body.permissionMode = opts.permissionMode;
19
+
20
+ const session = await api<SessionDto>("/sessions", { method: "POST", body });
21
+ if (opts.prompt?.trim()) {
22
+ await api(`/sessions/${session.id}/messages`, { method: "POST", body: { text: opts.prompt.trim() } });
23
+ }
24
+ return session.id;
25
+ }
@@ -0,0 +1,34 @@
1
+ import type { TemplateStep } from "./types";
2
+
3
+ const TOKEN = /\{(param|env):([A-Za-z0-9_]+)\}/g;
4
+
5
+ /** Unique `{param:NAME}` names referenced anywhere in a template's steps. */
6
+ export function templateParams(steps: TemplateStep[]): string[] {
7
+ const names = new Set<string>();
8
+ const scan = (s?: string) => {
9
+ if (!s) return;
10
+ TOKEN.lastIndex = 0;
11
+ let m: RegExpExecArray | null;
12
+ while ((m = TOKEN.exec(s))) if (m[1] === "param") names.add(m[2]);
13
+ };
14
+ for (const step of steps) {
15
+ if (step.type === "git-clone") {
16
+ scan(step.repo);
17
+ scan(step.dest);
18
+ scan(step.ref);
19
+ } else if (step.type === "shell") {
20
+ scan(step.command);
21
+ } else if (step.type === "copy") {
22
+ scan(step.from);
23
+ scan(step.to);
24
+ }
25
+ }
26
+ return [...names];
27
+ }
28
+
29
+ /** One-line, human description of a step. */
30
+ export function stepSummary(step: TemplateStep): string {
31
+ if (step.type === "git-clone") return `Clone ${step.repo}${step.ref ? ` @ ${step.ref}` : ""}${step.dest ? ` → ${step.dest}` : ""}`;
32
+ if (step.type === "shell") return step.command;
33
+ return `Copy ${step.from} → ${step.to}`;
34
+ }
package/lib/theme.ts ADDED
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * Light/dark theme state. The source of truth is the `dark` class on <html>,
7
+ * set before paint by the boot script in app/layout.tsx (reads localStorage,
8
+ * falls back to the OS preference). This hook mirrors that state into React
9
+ * and keeps every consumer (toggle, toaster) in sync via a window event.
10
+ */
11
+
12
+ const KEY = "garage.theme";
13
+ const EVENT = "garage-theme";
14
+
15
+ export type ThemePref = "light" | "dark" | "system";
16
+ export type Theme = "light" | "dark";
17
+
18
+ function readPref(): ThemePref {
19
+ if (typeof window === "undefined") return "system";
20
+ const v = window.localStorage.getItem(KEY);
21
+ return v === "light" || v === "dark" ? v : "system";
22
+ }
23
+
24
+ function systemTheme(): Theme {
25
+ return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
26
+ }
27
+
28
+ export function resolveTheme(pref: ThemePref): Theme {
29
+ return pref === "system" ? systemTheme() : pref;
30
+ }
31
+
32
+ function apply(pref: ThemePref) {
33
+ document.documentElement.classList.toggle("dark", resolveTheme(pref) === "dark");
34
+ }
35
+
36
+ export function useTheme(): { pref: ThemePref; resolved: Theme; setPref: (p: ThemePref) => void } {
37
+ // Server renders "system"; the boot script has already set the class, so
38
+ // the first client effect only aligns React state — no visual flip.
39
+ const [pref, setPrefState] = React.useState<ThemePref>("system");
40
+ const [resolved, setResolved] = React.useState<Theme>("dark");
41
+
42
+ React.useEffect(() => {
43
+ const sync = () => {
44
+ const p = readPref();
45
+ setPrefState(p);
46
+ setResolved(resolveTheme(p));
47
+ apply(p);
48
+ };
49
+ sync();
50
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
51
+ mq.addEventListener("change", sync);
52
+ window.addEventListener(EVENT, sync);
53
+ window.addEventListener("storage", sync);
54
+ return () => {
55
+ mq.removeEventListener("change", sync);
56
+ window.removeEventListener(EVENT, sync);
57
+ window.removeEventListener("storage", sync);
58
+ };
59
+ }, []);
60
+
61
+ const setPref = React.useCallback((p: ThemePref) => {
62
+ if (p === "system") window.localStorage.removeItem(KEY);
63
+ else window.localStorage.setItem(KEY, p);
64
+ window.dispatchEvent(new Event(EVENT));
65
+ }, []);
66
+
67
+ return { pref, resolved, setPref };
68
+ }
69
+
70
+ /** Inline <head> script: sets the `dark` class before first paint. */
71
+ export const THEME_BOOT_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(KEY)});var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;