@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,132 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, Eye, EyeOff } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Label } from "@/components/ui/label";
9
+ import { Spinner } from "@/components/shared";
10
+ import { DialogFooter } from "@/components/ui/dialog";
11
+
12
+ /** One labeled form row: a 13px label stacked over its control, with an
13
+ * optional hint line beneath. The shared unit of the credentials modals. */
14
+ export function Field({
15
+ label,
16
+ hint,
17
+ className,
18
+ children,
19
+ }: {
20
+ label: React.ReactNode;
21
+ hint?: React.ReactNode;
22
+ className?: string;
23
+ children: React.ReactNode;
24
+ }) {
25
+ return (
26
+ <div className={cn("space-y-1.5", className)}>
27
+ <Label>{label}</Label>
28
+ {children}
29
+ {hint && <p className="text-[12px] leading-relaxed text-faint">{hint}</p>}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ /** Two fields side by side on a comfortable gap — the modals' paired rows. */
35
+ export function FieldRow({ children }: { children: React.ReactNode }) {
36
+ return <div className="grid grid-cols-2 gap-4">{children}</div>;
37
+ }
38
+
39
+ /** A secret-key input with a show/hide toggle. Masks by default; reveals in
40
+ * monospace so a pasted key reads cleanly. Same focus ring as `Input`. */
41
+ export function KeyInput({
42
+ value,
43
+ onChange,
44
+ placeholder,
45
+ autoFocus,
46
+ }: {
47
+ value: string;
48
+ onChange: (v: string) => void;
49
+ placeholder?: string;
50
+ autoFocus?: boolean;
51
+ }) {
52
+ const [shown, setShown] = React.useState(false);
53
+ return (
54
+ <div className="relative">
55
+ <Input
56
+ type={shown ? "text" : "password"}
57
+ autoFocus={autoFocus}
58
+ value={value}
59
+ onChange={(e) => onChange(e.target.value)}
60
+ placeholder={placeholder}
61
+ className={cn("pr-9", shown && value && "font-mono text-[12.5px]")}
62
+ />
63
+ <button
64
+ type="button"
65
+ onClick={() => setShown((s) => !s)}
66
+ title={shown ? "Hide key" : "Show key"}
67
+ className="absolute right-1 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded text-faint transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
68
+ >
69
+ {shown ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
70
+ <span className="sr-only">{shown ? "Hide key" : "Show key"}</span>
71
+ </button>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ /** Post-save verification beat: a tinted band that reports whether the key
77
+ * actually works. Success counts the live models; failure shows the classified
78
+ * message from `verifyProvider`. Mirrors the `ErrorState` tint idiom. */
79
+ export function VerifyBanner({ state }: { state: { ok: true; models: number } | { ok: false; message: string } }) {
80
+ if (state.ok) {
81
+ return (
82
+ <div className="flex animate-fade-in items-center gap-2.5 rounded-lg border border-success/30 bg-success/[0.08] px-4 py-3 text-[13px]">
83
+ <Check className="size-4 shrink-0 text-success" />
84
+ <span className="text-foreground/90">
85
+ Key verified — <span className="font-medium text-foreground">{state.models}</span> model{state.models === 1 ? "" : "s"} available
86
+ </span>
87
+ </div>
88
+ );
89
+ }
90
+ return (
91
+ <div className="flex animate-fade-in items-start gap-2.5 rounded-lg border border-destructive/25 bg-destructive/[0.07] px-4 py-3 text-[13px]">
92
+ <span className="mt-1 size-1.5 shrink-0 rounded-full bg-destructive" />
93
+ <span className="text-foreground/90">{state.message}</span>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ /** Standard modal footer: a quiet Cancel and a right-aligned primary action
99
+ * that shows a spinner while its submit is in flight. */
100
+ export function ModalFooter({
101
+ onCancel,
102
+ onSubmit,
103
+ submitLabel,
104
+ busy,
105
+ disabled,
106
+ }: {
107
+ onCancel: () => void;
108
+ onSubmit: () => void;
109
+ submitLabel: string;
110
+ busy: boolean;
111
+ disabled?: boolean;
112
+ }) {
113
+ return (
114
+ <DialogFooter>
115
+ <Button variant="ghost" onClick={onCancel} disabled={busy}>
116
+ Cancel
117
+ </Button>
118
+ <Button onClick={onSubmit} disabled={busy || disabled}>
119
+ {busy ? <Spinner /> : null} {submitLabel}
120
+ </Button>
121
+ </DialogFooter>
122
+ );
123
+ }
124
+
125
+ /** Convert a label into a url-safe slug — drives the `custom-<slug>` preview. */
126
+ export function slugify(s: string): string {
127
+ return s
128
+ .toLowerCase()
129
+ .trim()
130
+ .replace(/[^a-z0-9]+/g, "-")
131
+ .replace(/^-+|-+$/g, "");
132
+ }
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, ChevronsUpDown, CloudOff, CornerDownLeft, Eye, Sparkles } from "lucide-react";
5
+ import { api } from "@/lib/api";
6
+ import { cn } from "@/lib/utils";
7
+ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
8
+ import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
9
+
10
+ type Fetched = { status: "loading" | "ok" | "error"; models: string[]; modalities?: Record<string, string[]>; error?: string };
11
+
12
+ /** Session-lived cache of live model lists, keyed by provider id. */
13
+ const liveCache = new Map<string, Fetched>();
14
+
15
+ /**
16
+ * Model picker: searches the provider's OWN model list (fetched live through
17
+ * the Garage, key server-side) with the static catalog as a fallback group and
18
+ * a free-text escape hatch. No more typing model ids from memory.
19
+ */
20
+ export function ModelCombobox({
21
+ providerId,
22
+ catalog,
23
+ value,
24
+ onChange,
25
+ defaultOpen = false,
26
+ }: {
27
+ providerId: string;
28
+ catalog: string[];
29
+ value: string;
30
+ onChange: (model: string) => void;
31
+ defaultOpen?: boolean;
32
+ }) {
33
+ const [open, setOpen] = React.useState(defaultOpen);
34
+ const [search, setSearch] = React.useState("");
35
+ const [live, setLive] = React.useState<Fetched | null>(providerId ? (liveCache.get(providerId) ?? null) : null);
36
+
37
+ React.useEffect(() => {
38
+ if (!open || !providerId) return;
39
+ const cached = liveCache.get(providerId);
40
+ if (cached && cached.status !== "error") {
41
+ setLive(cached);
42
+ return;
43
+ }
44
+ const loading: Fetched = { status: "loading", models: [] };
45
+ liveCache.set(providerId, loading);
46
+ setLive(loading);
47
+ api<{ models: string[]; modalities?: Record<string, string[]> }>(`/models/providers/${providerId}/models`)
48
+ .then((r) => {
49
+ const ok: Fetched = { status: "ok", models: r.models ?? [], modalities: r.modalities };
50
+ liveCache.set(providerId, ok);
51
+ setLive(ok);
52
+ })
53
+ .catch((e) => {
54
+ const err: Fetched = { status: "error", models: [], error: e instanceof Error ? e.message : "unavailable" };
55
+ liveCache.set(providerId, err);
56
+ setLive(err);
57
+ });
58
+ }, [open, providerId]);
59
+
60
+ const liveModels = live?.status === "ok" ? live.models : [];
61
+ const catalogExtra = catalog.filter((m) => !liveModels.includes(m));
62
+ const exact = liveModels.includes(search) || catalogExtra.includes(search);
63
+
64
+ const pick = (model: string) => {
65
+ onChange(model);
66
+ setOpen(false);
67
+ setSearch("");
68
+ };
69
+
70
+ return (
71
+ <Popover open={open} onOpenChange={setOpen}>
72
+ <PopoverTrigger asChild>
73
+ <button
74
+ type="button"
75
+ role="combobox"
76
+ aria-expanded={open}
77
+ disabled={!providerId}
78
+ className={cn(
79
+ "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors",
80
+ "hover:border-border-strong focus-visible:border-ring/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
81
+ "disabled:cursor-not-allowed disabled:opacity-50",
82
+ )}
83
+ >
84
+ <span className={cn("truncate font-mono text-[12.5px]", value ? "text-foreground" : "font-sans text-[13px] text-muted-foreground/60")}>
85
+ {value || "Pick a model…"}
86
+ </span>
87
+ <ChevronsUpDown className="size-3.5 shrink-0 text-faint" />
88
+ </button>
89
+ </PopoverTrigger>
90
+ <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
91
+ <Command shouldFilter={live?.status !== "loading"}>
92
+ <CommandInput placeholder="Search models…" value={search} onValueChange={setSearch} />
93
+ <CommandList>
94
+ {live?.status === "loading" && (
95
+ <div className="space-y-1.5 p-2" aria-label="Loading models">
96
+ {[88, 64, 76, 56].map((w, i) => (
97
+ <div key={i} className="skeleton h-6" style={{ width: `${w}%` }} />
98
+ ))}
99
+ </div>
100
+ )}
101
+
102
+ {live?.status === "error" && (
103
+ <div className="flex items-start gap-2 border-b border-border/60 px-3 py-2.5 text-[12px] text-muted-foreground">
104
+ <CloudOff className="mt-0.5 size-3.5 shrink-0 text-faint" />
105
+ Live list unavailable — pick from the catalog or type a model id.
106
+ </div>
107
+ )}
108
+
109
+ {live?.status !== "loading" && <CommandEmpty>No matching models.</CommandEmpty>}
110
+
111
+ {liveModels.length > 0 && (
112
+ <CommandGroup heading={`From ${providerId}`}>
113
+ {liveModels.map((m, i) => (
114
+ <CommandItem key={m} value={m} onSelect={pick} className="animate-slide-up font-mono text-[12.5px]" style={{ animationDelay: `${Math.min(i, 12) * 18}ms`, animationFillMode: "backwards" }}>
115
+ <Sparkles className="size-3.5 text-brand/70" />
116
+ <span className="flex-1 truncate">{m}</span>
117
+ {live?.modalities?.[m]?.includes("image") && <Eye className="size-3 text-faint" aria-label="vision-capable" />}
118
+ <Check className={cn("size-3.5 text-brand transition-opacity", value === m ? "opacity-100" : "opacity-0")} />
119
+ </CommandItem>
120
+ ))}
121
+ </CommandGroup>
122
+ )}
123
+
124
+ {catalogExtra.length > 0 && live?.status !== "loading" && (
125
+ <CommandGroup heading="Catalog">
126
+ {catalogExtra.map((m, i) => (
127
+ <CommandItem key={m} value={m} onSelect={pick} className="animate-slide-up font-mono text-[12.5px] text-muted-foreground" style={{ animationDelay: `${Math.min(liveModels.length + i, 14) * 18}ms`, animationFillMode: "backwards" }}>
128
+ <span className="size-3.5" />
129
+ <span className="flex-1 truncate">{m}</span>
130
+ <Check className={cn("size-3.5 text-brand transition-opacity", value === m ? "opacity-100" : "opacity-0")} />
131
+ </CommandItem>
132
+ ))}
133
+ </CommandGroup>
134
+ )}
135
+
136
+ {search.trim() && !exact && live?.status !== "loading" && (
137
+ <CommandGroup forceMount heading="Custom">
138
+ <CommandItem value={search} onSelect={() => pick(search.trim())} className="text-[13px]">
139
+ <CornerDownLeft className="size-3.5 text-faint" />
140
+ <span className="truncate">
141
+ Use “<span className="font-mono text-[12.5px]">{search.trim()}</span>”
142
+ </span>
143
+ </CommandItem>
144
+ </CommandGroup>
145
+ )}
146
+ </CommandList>
147
+ </Command>
148
+ </PopoverContent>
149
+ </Popover>
150
+ );
151
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { Eye, Cpu, Trash2 } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import { useQuery } from "@/lib/hooks";
6
+ import { api } from "@/lib/api";
7
+ import { compact, timeAgo } from "@/lib/format";
8
+ import { Page, PageHeader, Loading, EmptyState, ErrorState, ConfirmButton } from "@/components/shared";
9
+ import { SectionHeading } from "@/components/primitives";
10
+ import { Badge } from "@/components/ui/badge";
11
+ import { Button } from "@/components/ui/button";
12
+ import { AddProviderModal } from "./add-provider-modal";
13
+ import { AddProfileModal } from "./add-profile-modal";
14
+ import { EditProviderModal } from "./edit-provider-modal";
15
+ import { EditReasoningModal } from "./edit-reasoning-modal";
16
+ import type { Catalog, ProviderWire, ProfileWire } from "@/lib/types";
17
+
18
+ export default function CredentialsPage() {
19
+ const providers = useQuery<{ providers: ProviderWire[] }>("/models/providers");
20
+ const profiles = useQuery<{ profiles: ProfileWire[]; active_profile_id: string | null }>("/models/profiles");
21
+ const catalog = useQuery<Catalog>("/models/catalog");
22
+
23
+ const run = async (fn: () => Promise<unknown>, ok: string, reload: () => void) => {
24
+ try {
25
+ await fn();
26
+ reload();
27
+ toast.success(ok);
28
+ } catch (e) {
29
+ toast.error(e instanceof Error ? e.message : "Failed");
30
+ }
31
+ };
32
+
33
+ const provs = providers.data?.providers ?? [];
34
+ const profs = profiles.data?.profiles ?? [];
35
+ const activeId = profiles.data?.active_profile_id;
36
+
37
+ return (
38
+ <Page>
39
+ <PageHeader title="Models" description="Model providers and the profiles sessions inherit by default. API keys are stored by the credential adapter and never returned." />
40
+
41
+ <section className="mb-9">
42
+ <SectionHeading eyebrow="Credentials" title="Providers" action={<AddProviderModal catalog={catalog.data} onSaved={providers.reload} />} />
43
+ {providers.error && <ErrorState message={providers.error} className="mb-3" />}
44
+ <div className="surface overflow-hidden">
45
+ {providers.loading ? (
46
+ <Loading />
47
+ ) : provs.length === 0 ? (
48
+ <EmptyState icon={Cpu} title="No providers configured" description="Add a provider to give sessions a model to run on." />
49
+ ) : (
50
+ <div className="divide-y divide-border/60">
51
+ {provs.map((p) => (
52
+ <div key={p.id} className="group flex items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2">
53
+ <div className="min-w-0 flex-1">
54
+ <div className="flex items-center gap-1.5 truncate text-[13.5px] text-foreground">
55
+ <span className="truncate font-medium">{p.id}</span>
56
+ {p.based_on && <span className="text-[12px] font-normal text-faint">· based on {p.based_on}</span>}
57
+ </div>
58
+ <div className="truncate text-[11.5px] text-faint">
59
+ {p.adapter ? `${p.type} · ${p.adapter}` : p.type}
60
+ {p.base_url && <span className="ml-1.5 font-mono text-[12px]">{p.base_url}</span>}
61
+ </div>
62
+ </div>
63
+ {p.has_api_key ? (
64
+ <span className="inline-flex shrink-0 items-center gap-1.5 text-[11.5px] font-medium text-success">
65
+ <span className="size-1.5 rounded-full bg-success" /> key set
66
+ </span>
67
+ ) : (
68
+ <span className="shrink-0 text-[11.5px] text-faint">no key</span>
69
+ )}
70
+ <div className="flex shrink-0 items-center gap-0.5">
71
+ <EditProviderModal provider={p} onSaved={providers.reload} />
72
+ <ConfirmButton label="" icon={Trash2} onConfirm={() => run(() => api(`/models/providers/${p.id}`, { method: "DELETE" }), "Provider removed", providers.reload)} />
73
+ </div>
74
+ </div>
75
+ ))}
76
+ </div>
77
+ )}
78
+ </div>
79
+ </section>
80
+
81
+ <section>
82
+ <SectionHeading eyebrow="Defaults" title="Profiles" action={<AddProfileModal providers={provs} catalog={catalog.data} onSaved={profiles.reload} />} />
83
+ <div className="surface overflow-hidden">
84
+ {profiles.loading ? (
85
+ <Loading />
86
+ ) : profs.length === 0 ? (
87
+ <EmptyState icon={Cpu} title="No profiles" description="A profile pairs a provider with a model (and optional reasoning effort)." />
88
+ ) : (
89
+ <div className="divide-y divide-border/60">
90
+ {profs.map((p) => (
91
+ <div key={p.id} className="group flex items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2">
92
+ <div className="min-w-0 flex-1">
93
+ <div className="flex items-center gap-2 truncate text-[13.5px] text-foreground">
94
+ <span className="truncate font-medium">{p.label}</span>
95
+ {p.id === activeId && <Badge variant="brand">Default</Badge>}
96
+ </div>
97
+ <div className="truncate font-mono text-[12px] text-faint">{p.provider_id} · {p.model}</div>
98
+ </div>
99
+ {p.reasoning_label && p.reasoning_label !== "off" && (
100
+ <Badge variant="outline" className="shrink-0">{p.reasoning_label}</Badge>
101
+ )}
102
+ {p.input_modalities?.includes("image") && (
103
+ <Eye className="hidden size-3.5 shrink-0 text-faint md:block" aria-label="vision-capable" />
104
+ )}
105
+ {p.context_limit != null && (
106
+ <span className="tnum hidden shrink-0 text-[12px] text-faint md:block">{compact(p.context_limit)} ctx</span>
107
+ )}
108
+ <span className="tnum hidden w-14 shrink-0 text-right text-[12px] text-faint sm:block">{timeAgo(p.last_used_at)}</span>
109
+ <div className="flex shrink-0 items-center gap-0.5">
110
+ {p.id !== activeId && (
111
+ <Button variant="ghost" size="sm" onClick={() => run(() => api(`/models/profiles/${p.id}/activate`, { method: "POST" }), "Profile activated", profiles.reload)}>
112
+ Make default
113
+ </Button>
114
+ )}
115
+ <EditReasoningModal profile={p} onSaved={profiles.reload} />
116
+ <ConfirmButton label="" icon={Trash2} onConfirm={() => run(() => api(`/models/profiles/${p.id}`, { method: "DELETE" }), "Profile removed", profiles.reload)} />
117
+ </div>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ )}
122
+ </div>
123
+ </section>
124
+ </Page>
125
+ );
126
+ }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { KeyRound, Plus } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { useQuery } from "@/lib/hooks";
7
+ import { api } from "@/lib/api";
8
+ import { Page, PageHeader, Loading, EmptyState, ErrorState, ConfirmButton, SecretReveal, Spinner } from "@/components/shared";
9
+ import { timeAgo } from "@/lib/format";
10
+ import { cn } from "@/lib/utils";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Label } from "@/components/ui/label";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
16
+ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
17
+ import type { ApiKeyPublic } from "@/lib/types";
18
+
19
+ /** One key, dense but legible — name + prefix, scopes, and quiet timestamps. */
20
+ function KeyRow({ k, onRevoke }: { k: ApiKeyPublic; onRevoke: () => void }) {
21
+ return (
22
+ <div className={cn("flex items-center gap-3 px-3.5 py-2.5", k.revoked && "opacity-50")}>
23
+ <div className="min-w-0 flex-1">
24
+ <div className="flex items-center gap-2">
25
+ <span className={cn("truncate text-[13.5px] font-medium text-foreground", k.revoked && "line-through")}>{k.name}</span>
26
+ {k.revoked && <Badge variant="destructive">revoked</Badge>}
27
+ </div>
28
+ <div className="truncate font-mono text-[12px] text-faint">{k.keyPrefix}…</div>
29
+ </div>
30
+ <div className="hidden flex-wrap gap-1 sm:flex">
31
+ {k.scopes.map((s) => (
32
+ <Badge key={s} variant="outline">
33
+ {s}
34
+ </Badge>
35
+ ))}
36
+ </div>
37
+ <div className="hidden w-20 shrink-0 text-right sm:block">
38
+ <div className="tnum text-[12px] text-muted-foreground">{timeAgo(k.createdAt)}</div>
39
+ <div className="text-[11px] text-faint">created</div>
40
+ </div>
41
+ <div className="w-20 shrink-0 text-right">
42
+ <div className="tnum text-[12px] text-muted-foreground">{timeAgo(k.lastUsed)}</div>
43
+ <div className="text-[11px] text-faint">last used</div>
44
+ </div>
45
+ <div className="flex w-[88px] shrink-0 justify-end">{!k.revoked && <ConfirmButton label="Revoke" onConfirm={onRevoke} />}</div>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export default function KeysPage() {
51
+ const { data, loading, error, reload } = useQuery<{ data: ApiKeyPublic[] }>("/keys");
52
+ const [open, setOpen] = useState(false);
53
+ const [name, setName] = useState("");
54
+ const [scope, setScope] = useState("admin");
55
+ const [busy, setBusy] = useState(false);
56
+ const [minted, setMinted] = useState<string | null>(null);
57
+
58
+ const create = async () => {
59
+ setBusy(true);
60
+ try {
61
+ const res = await api<{ data: { key: string } }>("/keys", { method: "POST", body: { name: name.trim() || "api-key", scopes: [scope] } });
62
+ setMinted(res.data.key);
63
+ reload();
64
+ } catch (e) {
65
+ toast.error(e instanceof Error ? e.message : "Mint failed");
66
+ } finally {
67
+ setBusy(false);
68
+ }
69
+ };
70
+
71
+ const close = () => {
72
+ setOpen(false);
73
+ setName("");
74
+ setMinted(null);
75
+ };
76
+
77
+ const revoke = async (id: string) => {
78
+ try {
79
+ await api(`/keys/${id}`, { method: "DELETE" });
80
+ toast.success("Key revoked");
81
+ reload();
82
+ } catch (e) {
83
+ toast.error(e instanceof Error ? e.message : "Revoke failed");
84
+ }
85
+ };
86
+
87
+ const keys = data?.data ?? [];
88
+
89
+ return (
90
+ <Page>
91
+ <PageHeader
92
+ title="API Keys"
93
+ description="Keys for the REST API and the MCP server. Use one as GLORP_API_KEY, or send it as a Bearer token."
94
+ actions={
95
+ <Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
96
+ <DialogTrigger asChild>
97
+ <Button>
98
+ <Plus /> Mint key
99
+ </Button>
100
+ </DialogTrigger>
101
+ <DialogContent>
102
+ <DialogHeader>
103
+ <DialogTitle>Mint API key</DialogTitle>
104
+ <DialogDescription>The raw key is shown once. Store it somewhere safe.</DialogDescription>
105
+ </DialogHeader>
106
+ {minted ? (
107
+ <SecretReveal value={minted} />
108
+ ) : (
109
+ <div className="space-y-4">
110
+ <div className="space-y-1.5">
111
+ <Label>Name</Label>
112
+ <Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="mcp-server" />
113
+ </div>
114
+ <div className="space-y-1.5">
115
+ <Label>Scope</Label>
116
+ <Select value={scope} onValueChange={setScope}>
117
+ <SelectTrigger>
118
+ <SelectValue />
119
+ </SelectTrigger>
120
+ <SelectContent>
121
+ <SelectItem value="admin">admin — full control</SelectItem>
122
+ <SelectItem value="session">session — manage sessions only</SelectItem>
123
+ </SelectContent>
124
+ </Select>
125
+ </div>
126
+ </div>
127
+ )}
128
+ <DialogFooter>
129
+ {minted ? (
130
+ <Button onClick={close}>Done</Button>
131
+ ) : (
132
+ <>
133
+ <Button variant="ghost" onClick={close} disabled={busy}>
134
+ Cancel
135
+ </Button>
136
+ <Button onClick={create} disabled={busy}>
137
+ {busy ? <Spinner /> : <KeyRound />} Mint key
138
+ </Button>
139
+ </>
140
+ )}
141
+ </DialogFooter>
142
+ </DialogContent>
143
+ </Dialog>
144
+ }
145
+ />
146
+
147
+ {error && <ErrorState message={error} className="mb-4" />}
148
+
149
+ <div className="surface overflow-hidden">
150
+ {loading ? (
151
+ <Loading />
152
+ ) : keys.length === 0 ? (
153
+ <EmptyState icon={KeyRound} title="No API keys" description="Mint one for a client or the MCP server." />
154
+ ) : (
155
+ <div className="divide-y divide-border/60">
156
+ {keys.map((k) => (
157
+ <KeyRow key={k.id} k={k} onRevoke={() => revoke(k.id)} />
158
+ ))}
159
+ </div>
160
+ )}
161
+ </div>
162
+ </Page>
163
+ );
164
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import { useAuth } from "@/lib/auth";
6
+ import { AppSidebar } from "@/components/app-sidebar";
7
+ import { AppTopbar } from "@/components/app-topbar";
8
+ import { Loading } from "@/components/shared";
9
+ import { BrandLockup } from "@/components/brand";
10
+
11
+ const TITLES: Record<string, string> = {
12
+ "/": "Fleet",
13
+ "/sessions": "Sessions",
14
+ "/namespaces": "Namespaces",
15
+ "/workspaces": "Workspaces",
16
+ "/provisioning": "Provisioning",
17
+ "/credentials": "Models",
18
+ "/keys": "API Keys",
19
+ };
20
+
21
+ function titleFor(pathname: string): string {
22
+ if (TITLES[pathname]) return TITLES[pathname];
23
+ if (pathname.startsWith("/sessions/")) return "Session";
24
+ const top = "/" + (pathname.split("/")[1] ?? "");
25
+ return TITLES[top] ?? "Garage";
26
+ }
27
+
28
+ export default function AppLayout({ children }: { children: ReactNode }) {
29
+ const { ready, identity } = useAuth();
30
+ const pathname = usePathname();
31
+
32
+ // While auth resolves (or during the redirect to /login), keep it calm.
33
+ if (!ready || !identity) {
34
+ return (
35
+ <div className="grid min-h-screen place-items-center gap-6">
36
+ <div className="flex flex-col items-center gap-5">
37
+ <BrandLockup />
38
+ <Loading label="Connecting to Garage…" />
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div className="app-backdrop flex min-h-screen">
46
+ <AppSidebar />
47
+ <div className="flex h-screen min-w-0 flex-1 flex-col">
48
+ <AppTopbar title={titleFor(pathname)} />
49
+ <main className="min-h-0 flex-1 overflow-hidden">{children}</main>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { KeyRound, Layers, Trash2 } from "lucide-react";
4
+ import { timeAgo } from "@/lib/format";
5
+ import { ConfirmButton } from "@/components/shared";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import type { NamespaceDto } from "@/lib/types";
9
+
10
+ /** A single tenant partition: identity (name + slug) on the left, weight + actions trailing. */
11
+ export function NamespaceRow({
12
+ n,
13
+ onMint,
14
+ onDelete,
15
+ }: {
16
+ n: NamespaceDto;
17
+ onMint: (n: NamespaceDto) => void;
18
+ onDelete: (id: string) => void;
19
+ }) {
20
+ const count = n.session_count ?? 0;
21
+ return (
22
+ <div className="group flex items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-surface-2">
23
+ <Layers className="size-4 shrink-0 text-faint" />
24
+ <div className="min-w-0 flex-1">
25
+ <div className="flex items-center gap-2">
26
+ <span className="truncate text-[13.5px] font-medium text-foreground">{n.name}</span>
27
+ {n.is_default && (
28
+ <Badge variant="outline" className="shrink-0">
29
+ default
30
+ </Badge>
31
+ )}
32
+ </div>
33
+ <div className="truncate font-mono text-[12px] text-faint">{n.slug}</div>
34
+ </div>
35
+ <span className="tnum hidden w-20 shrink-0 text-right text-[12px] text-muted-foreground sm:block">
36
+ {count} {count === 1 ? "session" : "sessions"}
37
+ </span>
38
+ <span className="tnum hidden w-12 shrink-0 text-right text-[12px] text-faint md:block">{timeAgo(n.created_at)}</span>
39
+ <div className="flex shrink-0 items-center gap-1">
40
+ <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => onMint(n)}>
41
+ <KeyRound /> Mint key
42
+ </Button>
43
+ {!n.is_default && <ConfirmButton label="" icon={Trash2} onConfirm={() => onDelete(n.id)} />}
44
+ </div>
45
+ </div>
46
+ );
47
+ }