@polpo-ai/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.
- package/LICENSE +13 -0
- package/README.md +44 -0
- package/app/(dashboard)/error.tsx +47 -0
- package/app/(dashboard)/layout.tsx +41 -0
- package/app/(dashboard)/loading.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
- package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
- package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
- package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
- package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
- package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
- package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
- package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
- package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
- package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
- package/app/(dashboard)/projects/[id]/page.tsx +68 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
- package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
- package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
- package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
- package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
- package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
- package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
- package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
- package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
- package/app/(dashboard)/projects/projects-list.tsx +126 -0
- package/app/(playground)/error.tsx +42 -0
- package/app/(playground)/layout.tsx +27 -0
- package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
- package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
- package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
- package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
- package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +385 -0
- package/app/icon.svg +6 -0
- package/app/layout.tsx +39 -0
- package/app/page.tsx +11 -0
- package/components/ai-elements/code-block.tsx +562 -0
- package/components/dashboard/agent-capabilities.tsx +326 -0
- package/components/dashboard/agent-identity-header.tsx +50 -0
- package/components/dashboard/agent-model-picker.tsx +195 -0
- package/components/dashboard/agent-model-selector.tsx +121 -0
- package/components/dashboard/agent-picker-card.tsx +46 -0
- package/components/dashboard/agent-studio.tsx +242 -0
- package/components/dashboard/agents-table.tsx +48 -0
- package/components/dashboard/breadcrumb.tsx +70 -0
- package/components/dashboard/builder-chat.tsx +418 -0
- package/components/dashboard/chat-shell.tsx +40 -0
- package/components/dashboard/client-picker.tsx +63 -0
- package/components/dashboard/command-snippet.tsx +77 -0
- package/components/dashboard/connect-dialog.tsx +680 -0
- package/components/dashboard/copy-button.tsx +45 -0
- package/components/dashboard/copy-card.tsx +48 -0
- package/components/dashboard/file-browser.tsx +161 -0
- package/components/dashboard/hint.tsx +22 -0
- package/components/dashboard/inference-mode.tsx +15 -0
- package/components/dashboard/manual-refresh-button.tsx +52 -0
- package/components/dashboard/markdown.tsx +31 -0
- package/components/dashboard/mcp-install-panel.tsx +196 -0
- package/components/dashboard/mission-graph.tsx +262 -0
- package/components/dashboard/nav-tabs.tsx +86 -0
- package/components/dashboard/polpo-chat.tsx +468 -0
- package/components/dashboard/project-copilot.tsx +253 -0
- package/components/dashboard/sdk-snippet-panel.tsx +194 -0
- package/components/dashboard/section-header.tsx +37 -0
- package/components/dashboard/settings-form.tsx +2477 -0
- package/components/dashboard/sidebar.tsx +385 -0
- package/components/dashboard/skeletons.tsx +1054 -0
- package/components/dashboard/skills-install-wizard.tsx +273 -0
- package/components/dashboard/swarm-runs.tsx +316 -0
- package/components/dashboard/tab-toggle.tsx +35 -0
- package/components/dashboard/task-studio.tsx +224 -0
- package/components/dashboard/top-header.tsx +203 -0
- package/components/dashboard/webhook-deliveries.tsx +234 -0
- package/components/icons/coding-agents.tsx +151 -0
- package/components/json-ld.tsx +8 -0
- package/components/providers.tsx +25 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +356 -0
- package/components/ui/command.tsx +196 -0
- package/components/ui/dialog.tsx +157 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/multi-select.tsx +161 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +135 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/tabs.tsx +56 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/hooks/use-desktop-sidebar.tsx +50 -0
- package/hooks/use-mobile-sidebar.tsx +37 -0
- package/index.ts +14 -0
- package/lib/api.ts +194 -0
- package/lib/builder-context.ts +60 -0
- package/lib/data-client.ts +68 -0
- package/lib/get-query-client.ts +25 -0
- package/lib/polpo-client.tsx +49 -0
- package/lib/tool-catalog.ts +234 -0
- package/lib/use-event-catalog.ts +28 -0
- package/lib/utils.ts +6 -0
- package/package.json +99 -0
|
@@ -0,0 +1,2477 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import {
|
|
7
|
+
Plus,
|
|
8
|
+
Trash2,
|
|
9
|
+
Loader2,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
AlertTriangle,
|
|
12
|
+
Shield,
|
|
13
|
+
Check,
|
|
14
|
+
Copy,
|
|
15
|
+
ExternalLink,
|
|
16
|
+
Plug,
|
|
17
|
+
FileText,
|
|
18
|
+
CheckCircle2,
|
|
19
|
+
ChevronDown,
|
|
20
|
+
ChevronRight,
|
|
21
|
+
Minus,
|
|
22
|
+
Pencil,
|
|
23
|
+
Eye,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { fetchDataPlane, mutateDataPlane } from "#/lib/data-client";
|
|
26
|
+
import { fetchControlPlane } from "#/lib/data-client";
|
|
27
|
+
import type { ProjectSettings, ProjectGatewaySettings } from "#/lib/api";
|
|
28
|
+
import {
|
|
29
|
+
Dialog,
|
|
30
|
+
DialogContent,
|
|
31
|
+
DialogHeader,
|
|
32
|
+
DialogTitle,
|
|
33
|
+
DialogDescription,
|
|
34
|
+
DialogFooter,
|
|
35
|
+
DialogClose,
|
|
36
|
+
} from "#/components/ui/dialog";
|
|
37
|
+
import { CopyCard } from "#/components/dashboard/copy-card";
|
|
38
|
+
import { useEventCatalog } from "#/lib/use-event-catalog";
|
|
39
|
+
import { WebhookDeliveries } from "#/components/dashboard/webhook-deliveries";
|
|
40
|
+
import { InferenceModeRadio, type InferenceMode } from "#/components/dashboard/inference-mode";
|
|
41
|
+
import type { ByokEntry } from "#/lib/api";
|
|
42
|
+
|
|
43
|
+
const SALES_EMAIL = "hello@polpo.sh";
|
|
44
|
+
|
|
45
|
+
/** Providers selectable for request-scoped BYOK. Mirrors PROVIDER_ENV_MAP
|
|
46
|
+
* in @polpo-ai/core (the supported set the server validates against). */
|
|
47
|
+
const BYOK_PROVIDERS = [
|
|
48
|
+
{ value: "openai", label: "OpenAI" },
|
|
49
|
+
{ value: "anthropic", label: "Anthropic" },
|
|
50
|
+
{ value: "google", label: "Google (Gemini)" },
|
|
51
|
+
{ value: "xai", label: "xAI (Grok)" },
|
|
52
|
+
{ value: "groq", label: "Groq" },
|
|
53
|
+
{ value: "openrouter", label: "OpenRouter" },
|
|
54
|
+
{ value: "cerebras", label: "Cerebras" },
|
|
55
|
+
{ value: "mistral", label: "Mistral" },
|
|
56
|
+
{ value: "deepseek", label: "DeepSeek" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
interface Webhook {
|
|
60
|
+
id: string;
|
|
61
|
+
url: string;
|
|
62
|
+
events: string[];
|
|
63
|
+
created_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
interface SettingsFormProps {
|
|
68
|
+
projectId: string;
|
|
69
|
+
projectName: string;
|
|
70
|
+
projectSlug: string;
|
|
71
|
+
apiEndpoint: string;
|
|
72
|
+
initialSettings?: ProjectSettings | null;
|
|
73
|
+
/**
|
|
74
|
+
* Server-fetched Autumn integration status — passed to <IntegrationsTab>
|
|
75
|
+
* as TanStack Query `initialData` so the card renders the connection
|
|
76
|
+
* state instantly instead of flashing a "Loading status…" spinner.
|
|
77
|
+
*/
|
|
78
|
+
initialAutumnStatus?: AutumnStatusResponse | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type TabId = "general" | "gateway" | "integrations" | "webhooks";
|
|
82
|
+
|
|
83
|
+
const TABS: { id: TabId; label: string }[] = [
|
|
84
|
+
{ id: "general", label: "General" },
|
|
85
|
+
{ id: "gateway", label: "AI Gateway" },
|
|
86
|
+
{ id: "integrations", label: "Integrations" },
|
|
87
|
+
{ id: "webhooks", label: "Webhooks" },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export function SettingsForm({ projectId, projectName, projectSlug, apiEndpoint, initialSettings, initialAutumnStatus }: SettingsFormProps) {
|
|
91
|
+
const [active, setActive] = useState<TabId>("general");
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="max-w-2xl">
|
|
95
|
+
<h1 className="text-xl font-extrabold tracking-tight">Project Settings</h1>
|
|
96
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
97
|
+
Configure this project's general info, LLM gateway, and webhooks.
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<div className="mt-8 flex gap-1 border-b border-border overflow-x-auto scrollbar-none">
|
|
101
|
+
{TABS.map((tab) => (
|
|
102
|
+
<button
|
|
103
|
+
key={tab.id}
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => setActive(tab.id)}
|
|
106
|
+
data-testid={`settings-tab-${tab.id}`}
|
|
107
|
+
className={`relative px-3 py-2 text-sm transition-colors whitespace-nowrap ${
|
|
108
|
+
active === tab.id
|
|
109
|
+
? "text-foreground font-medium"
|
|
110
|
+
: "text-muted-foreground hover:text-foreground"
|
|
111
|
+
}`}
|
|
112
|
+
>
|
|
113
|
+
{tab.label}
|
|
114
|
+
{active === tab.id && (
|
|
115
|
+
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="mt-8">
|
|
122
|
+
{active === "general" && (
|
|
123
|
+
<GeneralTab
|
|
124
|
+
projectId={projectId}
|
|
125
|
+
projectName={projectName}
|
|
126
|
+
projectSlug={projectSlug}
|
|
127
|
+
apiEndpoint={apiEndpoint}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
{active === "gateway" && (
|
|
131
|
+
<GatewayTab projectId={projectId} initialSettings={initialSettings} />
|
|
132
|
+
)}
|
|
133
|
+
{active === "integrations" && (
|
|
134
|
+
<IntegrationsTab
|
|
135
|
+
projectId={projectId}
|
|
136
|
+
initialStatus={initialAutumnStatus}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{active === "webhooks" && <WebhooksTab projectId={projectId} />}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ── General Tab ──────────────────────────────────────────── */
|
|
146
|
+
|
|
147
|
+
function GeneralTab({ projectId, projectName, projectSlug, apiEndpoint }: {
|
|
148
|
+
projectId: string;
|
|
149
|
+
projectName: string;
|
|
150
|
+
projectSlug: string;
|
|
151
|
+
apiEndpoint: string;
|
|
152
|
+
}) {
|
|
153
|
+
const router = useRouter();
|
|
154
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
155
|
+
const [confirmName, setConfirmName] = useState("");
|
|
156
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
157
|
+
const [nameInput, setNameInput] = useState(projectName);
|
|
158
|
+
const [renameError, setRenameError] = useState<string | null>(null);
|
|
159
|
+
const [renameSaved, setRenameSaved] = useState(false);
|
|
160
|
+
|
|
161
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
162
|
+
|
|
163
|
+
const renameMutation = useMutation({
|
|
164
|
+
mutationFn: async (name: string) => {
|
|
165
|
+
const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
|
|
166
|
+
method: "PATCH",
|
|
167
|
+
credentials: "include",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ name }),
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const data = await res.json().catch(() => ({}));
|
|
173
|
+
throw new Error(data?.error ?? `Failed (${res.status})`);
|
|
174
|
+
}
|
|
175
|
+
return res.json();
|
|
176
|
+
},
|
|
177
|
+
onSuccess: () => {
|
|
178
|
+
setRenameError(null);
|
|
179
|
+
setRenameSaved(true);
|
|
180
|
+
setTimeout(() => setRenameSaved(false), 2000);
|
|
181
|
+
router.refresh();
|
|
182
|
+
},
|
|
183
|
+
onError: (err: Error) => {
|
|
184
|
+
setRenameError(err.message);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const nameChanged = nameInput.trim() !== projectName && nameInput.trim().length > 0;
|
|
189
|
+
|
|
190
|
+
const deleteMutation = useMutation({
|
|
191
|
+
mutationFn: async () => {
|
|
192
|
+
const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
|
|
193
|
+
method: "DELETE",
|
|
194
|
+
credentials: "include",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const data = await res.json().catch(() => ({}));
|
|
199
|
+
throw new Error(data.error ?? `Error ${res.status}`);
|
|
200
|
+
}
|
|
201
|
+
return res.json();
|
|
202
|
+
},
|
|
203
|
+
onSuccess: () => {
|
|
204
|
+
router.push("/projects");
|
|
205
|
+
},
|
|
206
|
+
onError: (err: Error) => {
|
|
207
|
+
setDeleteError(err.message);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<section>
|
|
213
|
+
<h2 className="text-lg font-semibold tracking-tight">Project details</h2>
|
|
214
|
+
<div className="mt-4 space-y-4">
|
|
215
|
+
<div>
|
|
216
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
|
217
|
+
Project name
|
|
218
|
+
</label>
|
|
219
|
+
<div className="flex items-stretch gap-2">
|
|
220
|
+
<input
|
|
221
|
+
type="text"
|
|
222
|
+
value={nameInput}
|
|
223
|
+
onChange={(e) => setNameInput(e.target.value)}
|
|
224
|
+
className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm focus:border-foreground/30 focus:outline-none transition-colors"
|
|
225
|
+
/>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => renameMutation.mutate(nameInput.trim())}
|
|
229
|
+
disabled={!nameChanged || renameMutation.isPending}
|
|
230
|
+
className="rounded border border-border bg-foreground/5 px-4 py-2 text-sm font-medium text-foreground hover:bg-foreground hover:text-background transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-foreground/5 disabled:hover:text-foreground"
|
|
231
|
+
>
|
|
232
|
+
{renameMutation.isPending ? "Saving…" : renameSaved ? "Saved" : "Save"}
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
{renameError && (
|
|
236
|
+
<p className="mt-2 text-xs text-destructive">{renameError}</p>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
<div>
|
|
240
|
+
<CopyCard label="Project ID" value={projectId} />
|
|
241
|
+
<p className="mt-1 text-[11px] text-muted-foreground">
|
|
242
|
+
Use this in the CLI:{" "}
|
|
243
|
+
<span className="font-mono">polpo --project-id <ID></span>
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
<div>
|
|
247
|
+
<CopyCard label="API endpoint" value={apiEndpoint} />
|
|
248
|
+
<p className="mt-1 text-[11px] text-muted-foreground">
|
|
249
|
+
Base URL for your project's data plane. Pair it with a project API
|
|
250
|
+
key.
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
{/* Danger zone */}
|
|
255
|
+
<div className="mt-16 border border-destructive/20 p-6">
|
|
256
|
+
<h3 className="text-sm font-medium text-destructive">Danger zone</h3>
|
|
257
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
258
|
+
Permanently remove this project, all agents, data, and API keys. This cannot be undone.
|
|
259
|
+
</p>
|
|
260
|
+
|
|
261
|
+
{!showDeleteConfirm ? (
|
|
262
|
+
<button
|
|
263
|
+
onClick={() => setShowDeleteConfirm(true)}
|
|
264
|
+
className="mt-4 border border-destructive/30 px-4 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
265
|
+
>
|
|
266
|
+
Delete project
|
|
267
|
+
</button>
|
|
268
|
+
) : (
|
|
269
|
+
<div className="mt-4 space-y-3">
|
|
270
|
+
<div className="flex items-start gap-2 rounded border border-destructive/20 bg-destructive/5 p-3">
|
|
271
|
+
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
|
272
|
+
<p className="text-xs text-destructive">
|
|
273
|
+
Type <span className="font-mono font-bold">{projectName}</span> to confirm deletion.
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
<input
|
|
277
|
+
type="text"
|
|
278
|
+
value={confirmName}
|
|
279
|
+
onChange={(e) => setConfirmName(e.target.value)}
|
|
280
|
+
placeholder={projectName}
|
|
281
|
+
className="w-full border border-destructive/30 bg-transparent px-3 py-2 text-sm font-mono focus:border-destructive focus:outline-none transition-colors"
|
|
282
|
+
autoFocus
|
|
283
|
+
/>
|
|
284
|
+
{deleteError && (
|
|
285
|
+
<p className="text-xs text-destructive">{deleteError}</p>
|
|
286
|
+
)}
|
|
287
|
+
<div className="flex items-center gap-3">
|
|
288
|
+
<button
|
|
289
|
+
onClick={() => deleteMutation.mutate()}
|
|
290
|
+
disabled={confirmName !== projectName || deleteMutation.isPending}
|
|
291
|
+
className="inline-flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-1.5 text-xs font-medium transition-all hover:opacity-90 disabled:opacity-30"
|
|
292
|
+
>
|
|
293
|
+
{deleteMutation.isPending ? (
|
|
294
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
295
|
+
) : (
|
|
296
|
+
"Delete permanently"
|
|
297
|
+
)}
|
|
298
|
+
</button>
|
|
299
|
+
<button
|
|
300
|
+
onClick={() => { setShowDeleteConfirm(false); setConfirmName(""); setDeleteError(null); }}
|
|
301
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
302
|
+
>
|
|
303
|
+
Cancel
|
|
304
|
+
</button>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
</section>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ── LLM Gateway Tab ─────────────────────────────────────── */
|
|
314
|
+
|
|
315
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
316
|
+
|
|
317
|
+
function GatewayTab({ projectId, initialSettings }: { projectId: string; initialSettings?: ProjectSettings | null }) {
|
|
318
|
+
const existingGateway = initialSettings?.gateway;
|
|
319
|
+
|
|
320
|
+
// Provider keys (request-scoped BYOK) — fetched on mount.
|
|
321
|
+
const [keys, setKeys] = useState<ByokEntry[]>([]);
|
|
322
|
+
const [keysLoaded, setKeysLoaded] = useState(false);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
let cancelled = false;
|
|
325
|
+
fetch(`${API_URL}/v1/byok/${projectId}`, { credentials: "include" })
|
|
326
|
+
.then((r) => (r.ok ? r.json() : { data: [] }))
|
|
327
|
+
.then((d) => { if (!cancelled) setKeys(d.data ?? []); })
|
|
328
|
+
.catch(() => {})
|
|
329
|
+
.finally(() => { if (!cancelled) setKeysLoaded(true); });
|
|
330
|
+
return () => { cancelled = true; };
|
|
331
|
+
}, [projectId]);
|
|
332
|
+
|
|
333
|
+
const providerKeys = keys.filter((k) => k.provider !== "gateway");
|
|
334
|
+
const hasCustomGateway = !!existingGateway?.url;
|
|
335
|
+
|
|
336
|
+
// Derived current mode → initial view.
|
|
337
|
+
function derive(): InferenceMode {
|
|
338
|
+
if (hasCustomGateway) return "gateway";
|
|
339
|
+
if (providerKeys.length > 0) return "byok";
|
|
340
|
+
return "managed";
|
|
341
|
+
}
|
|
342
|
+
const [viewMode, setViewMode] = useState<InferenceMode>(derive);
|
|
343
|
+
// Re-sync once keys arrive (so a project with keys lands on BYOK).
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (keysLoaded) setViewMode(derive());
|
|
346
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
347
|
+
}, [keysLoaded]);
|
|
348
|
+
|
|
349
|
+
// Custom gateway is Enterprise-gated: self-serve only when already
|
|
350
|
+
// configured (existing users keep editing); new projects see "Contact us".
|
|
351
|
+
const customGatewayEnabled = hasCustomGateway;
|
|
352
|
+
|
|
353
|
+
// ── Gateway form state ──
|
|
354
|
+
const [gatewayUrl, setGatewayUrl] = useState(existingGateway?.url ?? "");
|
|
355
|
+
const [gatewayApiKey, setGatewayApiKey] = useState("");
|
|
356
|
+
const [gatewayHeaders, setGatewayHeaders] = useState(
|
|
357
|
+
existingGateway?.headers ? JSON.stringify(existingGateway.headers, null, 2) : "",
|
|
358
|
+
);
|
|
359
|
+
const [savingGw, setSavingGw] = useState(false);
|
|
360
|
+
const [savedGw, setSavedGw] = useState(false);
|
|
361
|
+
const [gwError, setGwError] = useState<string | null>(null);
|
|
362
|
+
|
|
363
|
+
// ── Provider-key form state ──
|
|
364
|
+
const [keyProvider, setKeyProvider] = useState("openai");
|
|
365
|
+
const [keyValue, setKeyValue] = useState("");
|
|
366
|
+
const [showKeyForm, setShowKeyForm] = useState(false);
|
|
367
|
+
const [savingKey, setSavingKey] = useState(false);
|
|
368
|
+
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
|
369
|
+
const [keyError, setKeyError] = useState<string | null>(null);
|
|
370
|
+
|
|
371
|
+
// ── Managed switch state ──
|
|
372
|
+
const [switching, setSwitching] = useState(false);
|
|
373
|
+
|
|
374
|
+
const usedProviders = new Set(providerKeys.map((k) => k.provider));
|
|
375
|
+
const availableProviders = BYOK_PROVIDERS.filter((p) => !usedProviders.has(p.value));
|
|
376
|
+
|
|
377
|
+
async function refreshKeys() {
|
|
378
|
+
const r = await fetch(`${API_URL}/v1/byok/${projectId}`, { credentials: "include" });
|
|
379
|
+
const d = r.ok ? await r.json() : { data: [] };
|
|
380
|
+
setKeys(d.data ?? []);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function handleAddKey(e: React.FormEvent) {
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
setKeyError(null);
|
|
386
|
+
setSavingKey(true);
|
|
387
|
+
try {
|
|
388
|
+
const res = await fetch(`${API_URL}/v1/byok/${projectId}`, {
|
|
389
|
+
method: "POST",
|
|
390
|
+
credentials: "include",
|
|
391
|
+
headers: { "Content-Type": "application/json" },
|
|
392
|
+
body: JSON.stringify({ provider: keyProvider, key: keyValue.trim() }),
|
|
393
|
+
});
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
const data = await res.json().catch(() => ({}));
|
|
396
|
+
throw new Error(data.error ?? `Error ${res.status}`);
|
|
397
|
+
}
|
|
398
|
+
await refreshKeys();
|
|
399
|
+
setKeyValue("");
|
|
400
|
+
setShowKeyForm(false);
|
|
401
|
+
} catch (err: any) {
|
|
402
|
+
setKeyError(err.message);
|
|
403
|
+
} finally {
|
|
404
|
+
setSavingKey(false);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function handleDeleteKey(provider: string) {
|
|
409
|
+
setDeletingKey(provider);
|
|
410
|
+
try {
|
|
411
|
+
await fetch(`${API_URL}/v1/byok/${projectId}/${provider}`, {
|
|
412
|
+
method: "DELETE",
|
|
413
|
+
credentials: "include",
|
|
414
|
+
});
|
|
415
|
+
await refreshKeys();
|
|
416
|
+
} catch {} finally {
|
|
417
|
+
setDeletingKey(null);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handleSaveGateway(e: React.FormEvent) {
|
|
422
|
+
e.preventDefault();
|
|
423
|
+
setGwError(null);
|
|
424
|
+
setSavingGw(true);
|
|
425
|
+
setSavedGw(false);
|
|
426
|
+
try {
|
|
427
|
+
let headers: Record<string, string> | undefined;
|
|
428
|
+
if (gatewayHeaders.trim()) {
|
|
429
|
+
try { headers = JSON.parse(gatewayHeaders.trim()); }
|
|
430
|
+
catch { throw new Error("Invalid JSON in custom headers."); }
|
|
431
|
+
}
|
|
432
|
+
const settingsRes = await fetch(`${API_URL}/v1/projects/${projectId}/settings`, {
|
|
433
|
+
method: "PATCH",
|
|
434
|
+
credentials: "include",
|
|
435
|
+
headers: { "Content-Type": "application/json" },
|
|
436
|
+
body: JSON.stringify({ gateway: { url: gatewayUrl.trim(), headers } }),
|
|
437
|
+
});
|
|
438
|
+
if (!settingsRes.ok) {
|
|
439
|
+
const data = await settingsRes.json().catch(() => ({}));
|
|
440
|
+
throw new Error(data.error ?? `Error ${settingsRes.status}`);
|
|
441
|
+
}
|
|
442
|
+
if (gatewayApiKey.trim()) {
|
|
443
|
+
await fetch(`${API_URL}/v1/byok/${projectId}`, {
|
|
444
|
+
method: "POST",
|
|
445
|
+
credentials: "include",
|
|
446
|
+
headers: { "Content-Type": "application/json" },
|
|
447
|
+
body: JSON.stringify({ provider: "gateway", key: gatewayApiKey.trim(), label: "Gateway API Key" }),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
setSavedGw(true);
|
|
451
|
+
setGatewayApiKey("");
|
|
452
|
+
setTimeout(() => setSavedGw(false), 3000);
|
|
453
|
+
} catch (err: any) {
|
|
454
|
+
setGwError(err.message);
|
|
455
|
+
} finally {
|
|
456
|
+
setSavingGw(false);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Switch back to Polpo managed — clears the custom gateway + its key.
|
|
461
|
+
async function handleSwitchToManaged() {
|
|
462
|
+
setSwitching(true);
|
|
463
|
+
try {
|
|
464
|
+
await fetch(`${API_URL}/v1/projects/${projectId}/settings`, {
|
|
465
|
+
method: "PATCH",
|
|
466
|
+
credentials: "include",
|
|
467
|
+
headers: { "Content-Type": "application/json" },
|
|
468
|
+
body: JSON.stringify({ gateway: null }),
|
|
469
|
+
});
|
|
470
|
+
await fetch(`${API_URL}/v1/byok/${projectId}/gateway`, {
|
|
471
|
+
method: "DELETE",
|
|
472
|
+
credentials: "include",
|
|
473
|
+
}).catch(() => {});
|
|
474
|
+
window.location.reload();
|
|
475
|
+
} finally {
|
|
476
|
+
setSwitching(false);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<section>
|
|
482
|
+
<h2 className="text-lg font-semibold tracking-tight">AI Gateway</h2>
|
|
483
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
484
|
+
Choose how this project pays for inference.
|
|
485
|
+
</p>
|
|
486
|
+
|
|
487
|
+
<div className="mt-6">
|
|
488
|
+
<InferenceModeRadio
|
|
489
|
+
value={viewMode}
|
|
490
|
+
onChange={setViewMode}
|
|
491
|
+
customGatewayEnabled={customGatewayEnabled}
|
|
492
|
+
onContactSales={() => {
|
|
493
|
+
window.location.href = `mailto:${SALES_EMAIL}?subject=${encodeURIComponent("Custom gateway — Enterprise")}`;
|
|
494
|
+
}}
|
|
495
|
+
>
|
|
496
|
+
{/* ── Managed panel ── */}
|
|
497
|
+
{viewMode === "managed" && (
|
|
498
|
+
<div className="border border-border bg-card p-5">
|
|
499
|
+
<p className="text-sm font-medium">Polpo managed gateway</p>
|
|
500
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
501
|
+
Routes through the Vercel AI Gateway. Inference is deducted from
|
|
502
|
+
your credit balance at list price.
|
|
503
|
+
</p>
|
|
504
|
+
{hasCustomGateway && (
|
|
505
|
+
<button
|
|
506
|
+
onClick={handleSwitchToManaged}
|
|
507
|
+
disabled={switching}
|
|
508
|
+
className="mt-4 inline-flex items-center gap-2 border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:border-foreground/30 hover:bg-foreground/5 disabled:opacity-50"
|
|
509
|
+
>
|
|
510
|
+
{switching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
|
511
|
+
Switch to Polpo managed (clears custom gateway)
|
|
512
|
+
</button>
|
|
513
|
+
)}
|
|
514
|
+
{providerKeys.length > 0 && (
|
|
515
|
+
<p className="mt-4 border-t border-border pt-3 text-[11px] text-muted-foreground/70">
|
|
516
|
+
This project also has provider keys. Calls to those providers
|
|
517
|
+
use BYOK (free on Polpo); everything else is managed.
|
|
518
|
+
</p>
|
|
519
|
+
)}
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
{/* ── Provider keys (BYOK) panel ── */}
|
|
524
|
+
{viewMode === "byok" && (
|
|
525
|
+
<div className="border border-border bg-card p-5">
|
|
526
|
+
<p className="text-sm font-medium">Provider keys</p>
|
|
527
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
528
|
+
Encrypted per project. Calls to a provider with a key here are
|
|
529
|
+
billed by that provider — free on Polpo.
|
|
530
|
+
</p>
|
|
531
|
+
|
|
532
|
+
{providerKeys.length > 0 && (
|
|
533
|
+
<div className="mt-4 divide-y divide-border border-y border-border">
|
|
534
|
+
{providerKeys.map((k) => {
|
|
535
|
+
const label = BYOK_PROVIDERS.find((p) => p.value === k.provider)?.label ?? k.provider;
|
|
536
|
+
return (
|
|
537
|
+
<div key={k.provider} className="flex items-center justify-between gap-3 py-2.5">
|
|
538
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
539
|
+
<span className="text-sm font-medium">{label}</span>
|
|
540
|
+
<span className="font-mono text-[11px] text-muted-foreground/60 truncate">{k.maskedKey}</span>
|
|
541
|
+
</div>
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => handleDeleteKey(k.provider)}
|
|
544
|
+
disabled={deletingKey === k.provider}
|
|
545
|
+
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
|
546
|
+
aria-label={`Remove ${label} key`}
|
|
547
|
+
>
|
|
548
|
+
{deletingKey === k.provider ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
})}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
|
|
556
|
+
{keyError && (
|
|
557
|
+
<div className="mt-3 border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">{keyError}</div>
|
|
558
|
+
)}
|
|
559
|
+
|
|
560
|
+
{!showKeyForm ? (
|
|
561
|
+
<button
|
|
562
|
+
onClick={() => { setKeyProvider(availableProviders[0]?.value ?? "openai"); setShowKeyForm(true); }}
|
|
563
|
+
disabled={availableProviders.length === 0}
|
|
564
|
+
className="mt-4 inline-flex items-center gap-1.5 border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:border-foreground/30 hover:bg-foreground/5 disabled:opacity-50"
|
|
565
|
+
>
|
|
566
|
+
<Plus className="h-3.5 w-3.5" />
|
|
567
|
+
Add provider key
|
|
568
|
+
</button>
|
|
569
|
+
) : (
|
|
570
|
+
<form onSubmit={handleAddKey} className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
|
|
571
|
+
<div className="flex-1">
|
|
572
|
+
<label className="block text-[10px] font-mono uppercase tracking-wider text-muted-foreground/60 mb-1">Provider</label>
|
|
573
|
+
<select
|
|
574
|
+
value={keyProvider}
|
|
575
|
+
onChange={(e) => setKeyProvider(e.target.value)}
|
|
576
|
+
className="w-full border border-border bg-background px-2.5 py-2 text-sm focus:border-foreground/30 focus:outline-none appearance-none"
|
|
577
|
+
>
|
|
578
|
+
{availableProviders.map((p) => (
|
|
579
|
+
<option key={p.value} value={p.value}>{p.label}</option>
|
|
580
|
+
))}
|
|
581
|
+
</select>
|
|
582
|
+
</div>
|
|
583
|
+
<div className="flex-[2]">
|
|
584
|
+
<label className="block text-[10px] font-mono uppercase tracking-wider text-muted-foreground/60 mb-1">API key</label>
|
|
585
|
+
<input
|
|
586
|
+
type="password"
|
|
587
|
+
value={keyValue}
|
|
588
|
+
onChange={(e) => setKeyValue(e.target.value)}
|
|
589
|
+
placeholder="sk-…"
|
|
590
|
+
required
|
|
591
|
+
className="w-full border border-border bg-transparent px-2.5 py-2 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none"
|
|
592
|
+
/>
|
|
593
|
+
</div>
|
|
594
|
+
<div className="flex items-center gap-2">
|
|
595
|
+
<button
|
|
596
|
+
type="submit"
|
|
597
|
+
disabled={savingKey || !keyValue.trim()}
|
|
598
|
+
className="inline-flex items-center gap-1.5 bg-foreground text-background px-3 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
|
|
599
|
+
>
|
|
600
|
+
{savingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
|
|
601
|
+
</button>
|
|
602
|
+
<button
|
|
603
|
+
type="button"
|
|
604
|
+
onClick={() => { setShowKeyForm(false); setKeyError(null); setKeyValue(""); }}
|
|
605
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
606
|
+
>
|
|
607
|
+
Cancel
|
|
608
|
+
</button>
|
|
609
|
+
</div>
|
|
610
|
+
</form>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
|
|
615
|
+
{/* ── Custom gateway panel (enterprise) ── */}
|
|
616
|
+
{viewMode === "gateway" && customGatewayEnabled && (
|
|
617
|
+
<form onSubmit={handleSaveGateway} className="border border-border bg-card p-5 space-y-4 max-w-lg">
|
|
618
|
+
<div>
|
|
619
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1.5">Gateway URL</label>
|
|
620
|
+
<input
|
|
621
|
+
type="url"
|
|
622
|
+
value={gatewayUrl}
|
|
623
|
+
onChange={(e) => setGatewayUrl(e.target.value)}
|
|
624
|
+
placeholder="https://your-gateway.example.com/v1"
|
|
625
|
+
required
|
|
626
|
+
className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
|
|
627
|
+
/>
|
|
628
|
+
</div>
|
|
629
|
+
<div>
|
|
630
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1.5">API Key</label>
|
|
631
|
+
<input
|
|
632
|
+
type="password"
|
|
633
|
+
value={gatewayApiKey}
|
|
634
|
+
onChange={(e) => setGatewayApiKey(e.target.value)}
|
|
635
|
+
placeholder="Leave empty to keep existing key"
|
|
636
|
+
className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
|
|
637
|
+
/>
|
|
638
|
+
</div>
|
|
639
|
+
<div>
|
|
640
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1.5">Custom Headers (JSON, optional)</label>
|
|
641
|
+
<textarea
|
|
642
|
+
value={gatewayHeaders}
|
|
643
|
+
onChange={(e) => setGatewayHeaders(e.target.value)}
|
|
644
|
+
placeholder={'{"X-Custom-Header": "value"}'}
|
|
645
|
+
rows={3}
|
|
646
|
+
className="w-full border border-border bg-transparent px-3 py-2.5 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors resize-y"
|
|
647
|
+
/>
|
|
648
|
+
</div>
|
|
649
|
+
{gwError && (
|
|
650
|
+
<div className="border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">{gwError}</div>
|
|
651
|
+
)}
|
|
652
|
+
<div className="flex items-center gap-3">
|
|
653
|
+
<button
|
|
654
|
+
type="submit"
|
|
655
|
+
disabled={savingGw || !gatewayUrl.trim()}
|
|
656
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-4 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
|
|
657
|
+
>
|
|
658
|
+
{savingGw ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save gateway"}
|
|
659
|
+
</button>
|
|
660
|
+
{savedGw && <span className="text-xs text-muted-foreground">Saved.</span>}
|
|
661
|
+
</div>
|
|
662
|
+
</form>
|
|
663
|
+
)}
|
|
664
|
+
</InferenceModeRadio>
|
|
665
|
+
</div>
|
|
666
|
+
</section>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* ── Integrations Tab ─────────────────────────────────────── */
|
|
671
|
+
|
|
672
|
+
export interface AutumnStatusResponse {
|
|
673
|
+
connected: boolean;
|
|
674
|
+
status?: "active" | "error" | "revoked";
|
|
675
|
+
env?: "sandbox" | "live" | "unknown";
|
|
676
|
+
featureIds?: {
|
|
677
|
+
completions: string;
|
|
678
|
+
tasks: string;
|
|
679
|
+
inferenceUsd: string;
|
|
680
|
+
inputTokens?: string | null;
|
|
681
|
+
outputTokens?: string | null;
|
|
682
|
+
};
|
|
683
|
+
eventsCount?: number;
|
|
684
|
+
errorsCount?: number;
|
|
685
|
+
lastUsedAt?: string | null;
|
|
686
|
+
lastError?: string | null;
|
|
687
|
+
lastErrorAt?: string | null;
|
|
688
|
+
createdAt?: string;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Project integrations tab. Today only Autumn billing pass-through —
|
|
693
|
+
* future identity providers, Stripe direct, etc. live as additional cards
|
|
694
|
+
* here without changing the tab layout.
|
|
695
|
+
*
|
|
696
|
+
* The Autumn card has two states:
|
|
697
|
+
* - **Disconnected**: form to paste the builder's Autumn secret key.
|
|
698
|
+
* On submit, Polpo validates the key, creates the polpo_* features in
|
|
699
|
+
* the builder's Autumn (idempotent), and persists the encrypted secret.
|
|
700
|
+
* - **Active**: status + telemetry (events fired, errors, last error
|
|
701
|
+
* reason) + a "Disconnect" button. The plaintext key is never echoed
|
|
702
|
+
* back — once submitted, only counters and feature ids are visible.
|
|
703
|
+
*/
|
|
704
|
+
type ConnectionState = "disconnected" | "incomplete" | "active";
|
|
705
|
+
type WizardStep = "connect" | "plans" | "status";
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Derive the connection state from the raw status response. Today this is
|
|
709
|
+
* a client-side heuristic — once the backend grows a "verify plan
|
|
710
|
+
* attachment" endpoint, it can replace `incomplete` here without touching
|
|
711
|
+
* any of the UI below.
|
|
712
|
+
*
|
|
713
|
+
* TODO(verify-endpoint): swap `eventsCount === 0` for a real
|
|
714
|
+
* `plansAttached` boolean from the API.
|
|
715
|
+
*/
|
|
716
|
+
function deriveConnectionState(status: AutumnStatusResponse | undefined): ConnectionState {
|
|
717
|
+
if (!status?.connected) return "disconnected";
|
|
718
|
+
if (!status.eventsCount || status.eventsCount === 0) return "incomplete";
|
|
719
|
+
return "active";
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function IntegrationsTab({
|
|
723
|
+
projectId,
|
|
724
|
+
initialStatus,
|
|
725
|
+
}: {
|
|
726
|
+
projectId: string;
|
|
727
|
+
initialStatus?: AutumnStatusResponse | null;
|
|
728
|
+
}) {
|
|
729
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
730
|
+
const queryClient = useQueryClient();
|
|
731
|
+
|
|
732
|
+
const { data: status, isLoading } = useQuery<AutumnStatusResponse>({
|
|
733
|
+
queryKey: ["autumn-integration", projectId],
|
|
734
|
+
queryFn: async () => {
|
|
735
|
+
const res = await fetch(
|
|
736
|
+
`${API_URL}/v1/integrations/${projectId}/autumn`,
|
|
737
|
+
{ credentials: "include" },
|
|
738
|
+
);
|
|
739
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
740
|
+
const json = await res.json();
|
|
741
|
+
return json.data as AutumnStatusResponse;
|
|
742
|
+
},
|
|
743
|
+
// SSR-prefetched: the integration row was already loaded server-side
|
|
744
|
+
// alongside the project, so we hydrate without a loading flash. Keep it
|
|
745
|
+
// fresh for a minute, then revalidate in the background.
|
|
746
|
+
initialData: initialStatus ?? undefined,
|
|
747
|
+
staleTime: 60_000,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
751
|
+
|
|
752
|
+
const connectionState = deriveConnectionState(status);
|
|
753
|
+
|
|
754
|
+
// Pick the entry step based on the card's current state.
|
|
755
|
+
const initialStep: WizardStep =
|
|
756
|
+
connectionState === "disconnected"
|
|
757
|
+
? "connect"
|
|
758
|
+
: connectionState === "incomplete"
|
|
759
|
+
? "plans"
|
|
760
|
+
: "status";
|
|
761
|
+
|
|
762
|
+
return (
|
|
763
|
+
<section className="space-y-6">
|
|
764
|
+
<div>
|
|
765
|
+
<h2 className="text-base font-semibold tracking-tight">Integrations</h2>
|
|
766
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
767
|
+
Connect your stack to Polpo.
|
|
768
|
+
</p>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
{/* Autumn card */}
|
|
772
|
+
<div className="rounded-lg border border-border p-5">
|
|
773
|
+
<div className="flex items-start gap-4">
|
|
774
|
+
{/* Logo */}
|
|
775
|
+
<img
|
|
776
|
+
src="/logos/autumn-icon.svg"
|
|
777
|
+
alt="Autumn"
|
|
778
|
+
className="h-12 w-12 shrink-0 rounded"
|
|
779
|
+
/>
|
|
780
|
+
|
|
781
|
+
{/* Title + description + status pill */}
|
|
782
|
+
<div className="min-w-0 flex-1">
|
|
783
|
+
<h3 className="text-sm font-semibold leading-none">Autumn</h3>
|
|
784
|
+
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
785
|
+
Per-end-user billing pass-through. Polpo fires usage events to
|
|
786
|
+
your Autumn — you manage plans & pricing there.
|
|
787
|
+
</p>
|
|
788
|
+
{!isLoading && connectionState !== "disconnected" && (
|
|
789
|
+
<div className="mt-2.5">
|
|
790
|
+
<StatusPill state={connectionState} env={status?.env} />
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
{isLoading && (
|
|
794
|
+
<div className="mt-2.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
795
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
796
|
+
Loading status…
|
|
797
|
+
</div>
|
|
798
|
+
)}
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
{/* Action button */}
|
|
802
|
+
<div className="shrink-0">
|
|
803
|
+
{!isLoading && (
|
|
804
|
+
<button
|
|
805
|
+
type="button"
|
|
806
|
+
onClick={() => setDialogOpen(true)}
|
|
807
|
+
className={
|
|
808
|
+
connectionState === "disconnected"
|
|
809
|
+
? "inline-flex items-center gap-2 bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-all hover:opacity-90"
|
|
810
|
+
: "inline-flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-secondary transition-colors"
|
|
811
|
+
}
|
|
812
|
+
>
|
|
813
|
+
{connectionState === "disconnected"
|
|
814
|
+
? "Connect Autumn"
|
|
815
|
+
: connectionState === "incomplete"
|
|
816
|
+
? "Finish setup"
|
|
817
|
+
: "Manage"}
|
|
818
|
+
</button>
|
|
819
|
+
)}
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
|
|
824
|
+
<AutumnWizardDialog
|
|
825
|
+
open={dialogOpen}
|
|
826
|
+
onOpenChange={setDialogOpen}
|
|
827
|
+
initialStep={initialStep}
|
|
828
|
+
mode={connectionState === "active" ? "manage" : "wizard"}
|
|
829
|
+
projectId={projectId}
|
|
830
|
+
apiUrl={API_URL}
|
|
831
|
+
status={status}
|
|
832
|
+
onChanged={() =>
|
|
833
|
+
queryClient.invalidateQueries({
|
|
834
|
+
queryKey: ["autumn-integration", projectId],
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
/>
|
|
838
|
+
</section>
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/* ── Status pill (card) ─────────────────────────────────── */
|
|
843
|
+
|
|
844
|
+
function StatusPill({
|
|
845
|
+
state,
|
|
846
|
+
env,
|
|
847
|
+
}: {
|
|
848
|
+
state: ConnectionState;
|
|
849
|
+
env?: AutumnStatusResponse["env"];
|
|
850
|
+
}) {
|
|
851
|
+
if (state === "active") {
|
|
852
|
+
return (
|
|
853
|
+
<span className="inline-flex items-center gap-1.5 rounded bg-brand/10 px-2 py-0.5 text-[11px] font-medium text-brand">
|
|
854
|
+
<span className="relative flex h-1.5 w-1.5">
|
|
855
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-60" />
|
|
856
|
+
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-brand" />
|
|
857
|
+
</span>
|
|
858
|
+
Bridge active
|
|
859
|
+
{env && env !== "unknown" && (
|
|
860
|
+
<span className="ml-1 uppercase tracking-wide opacity-70">{env}</span>
|
|
861
|
+
)}
|
|
862
|
+
</span>
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (state === "incomplete") {
|
|
866
|
+
return (
|
|
867
|
+
<span className="inline-flex items-center gap-1.5 rounded bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">
|
|
868
|
+
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
|
869
|
+
Action required
|
|
870
|
+
</span>
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return (
|
|
874
|
+
<span className="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
875
|
+
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
|
876
|
+
Disconnected
|
|
877
|
+
</span>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/* ── Wizard Dialog ──────────────────────────────────────── */
|
|
882
|
+
|
|
883
|
+
const WIZARD_STEPS: {
|
|
884
|
+
id: WizardStep;
|
|
885
|
+
n: number;
|
|
886
|
+
title: string;
|
|
887
|
+
badge: string;
|
|
888
|
+
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
|
889
|
+
}[] = [
|
|
890
|
+
{ id: "connect", n: 1, title: "Connect", badge: "Step 1", icon: Plug },
|
|
891
|
+
{ id: "plans", n: 2, title: "Plans & verify", badge: "Step 2", icon: FileText },
|
|
892
|
+
{ id: "status", n: 3, title: "Test event", badge: "Step 3", icon: CheckCircle2 },
|
|
893
|
+
];
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Segmented step card — mirrors `OptionCard` in connect-dialog. Three of
|
|
897
|
+
* these glued together by `border + divide-x` form the navigation strip
|
|
898
|
+
* at the top of the wizard.
|
|
899
|
+
*/
|
|
900
|
+
function StepOptionCard({
|
|
901
|
+
icon: Icon,
|
|
902
|
+
title,
|
|
903
|
+
badge,
|
|
904
|
+
active,
|
|
905
|
+
completed,
|
|
906
|
+
disabled,
|
|
907
|
+
onClick,
|
|
908
|
+
}: {
|
|
909
|
+
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
|
910
|
+
title: string;
|
|
911
|
+
badge: string;
|
|
912
|
+
active: boolean;
|
|
913
|
+
completed: boolean;
|
|
914
|
+
disabled: boolean;
|
|
915
|
+
onClick: () => void;
|
|
916
|
+
}) {
|
|
917
|
+
// Visual hierarchy:
|
|
918
|
+
// - completed (and not the current step) → green wash + check overlay
|
|
919
|
+
// - active (current step) → secondary fill, normal icon
|
|
920
|
+
// - upcoming → dimmed, hover only
|
|
921
|
+
const tone = active
|
|
922
|
+
? "bg-secondary"
|
|
923
|
+
: completed
|
|
924
|
+
? "bg-emerald-500/8 hover:bg-emerald-500/12"
|
|
925
|
+
: "hover:bg-secondary/60";
|
|
926
|
+
|
|
927
|
+
return (
|
|
928
|
+
<button
|
|
929
|
+
type="button"
|
|
930
|
+
onClick={disabled ? undefined : onClick}
|
|
931
|
+
disabled={disabled}
|
|
932
|
+
className={`group relative flex flex-1 flex-col items-center justify-center gap-3 py-4 transition-colors ${tone} ${
|
|
933
|
+
disabled ? "cursor-not-allowed opacity-50 hover:bg-transparent" : ""
|
|
934
|
+
}`}
|
|
935
|
+
aria-current={active ? "step" : undefined}
|
|
936
|
+
>
|
|
937
|
+
{completed && !active && (
|
|
938
|
+
<span className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-background">
|
|
939
|
+
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
|
940
|
+
</span>
|
|
941
|
+
)}
|
|
942
|
+
<Icon
|
|
943
|
+
className={`h-6 w-6 ${
|
|
944
|
+
completed && !active
|
|
945
|
+
? "text-emerald-600 dark:text-emerald-400"
|
|
946
|
+
: active
|
|
947
|
+
? "text-muted-foreground"
|
|
948
|
+
: "text-muted-foreground/60 group-hover:text-muted-foreground"
|
|
949
|
+
}`}
|
|
950
|
+
strokeWidth={1.5}
|
|
951
|
+
/>
|
|
952
|
+
<div className="flex flex-col items-center gap-1.5">
|
|
953
|
+
<span
|
|
954
|
+
className={`text-[13px] leading-[18px] ${
|
|
955
|
+
active ? "text-foreground" : "text-foreground/90"
|
|
956
|
+
}`}
|
|
957
|
+
>
|
|
958
|
+
{title}
|
|
959
|
+
</span>
|
|
960
|
+
<span
|
|
961
|
+
className={`inline-flex items-center rounded px-2 py-0.5 text-[11px] font-medium ${
|
|
962
|
+
completed && !active
|
|
963
|
+
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
|
|
964
|
+
: "bg-brand/10 text-brand"
|
|
965
|
+
}`}
|
|
966
|
+
>
|
|
967
|
+
{completed && !active ? "Done" : badge}
|
|
968
|
+
</span>
|
|
969
|
+
</div>
|
|
970
|
+
</button>
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function AutumnWizardDialog({
|
|
975
|
+
open,
|
|
976
|
+
onOpenChange,
|
|
977
|
+
initialStep,
|
|
978
|
+
mode,
|
|
979
|
+
projectId,
|
|
980
|
+
apiUrl,
|
|
981
|
+
status,
|
|
982
|
+
onChanged,
|
|
983
|
+
}: {
|
|
984
|
+
open: boolean;
|
|
985
|
+
onOpenChange: (v: boolean) => void;
|
|
986
|
+
initialStep: WizardStep;
|
|
987
|
+
/**
|
|
988
|
+
* "wizard" → segmented step strip, used when the integration is being
|
|
989
|
+
* configured for the first time or finishing setup.
|
|
990
|
+
*
|
|
991
|
+
* "manage" → compact view (telemetry + disconnect only). The wizard would
|
|
992
|
+
* just be noise once the bridge is fully active.
|
|
993
|
+
*/
|
|
994
|
+
mode: "wizard" | "manage";
|
|
995
|
+
projectId: string;
|
|
996
|
+
apiUrl: string;
|
|
997
|
+
status: AutumnStatusResponse | undefined;
|
|
998
|
+
onChanged: () => void;
|
|
999
|
+
}) {
|
|
1000
|
+
const queryClient = useQueryClient();
|
|
1001
|
+
const [step, setStep] = useState<WizardStep>(initialStep);
|
|
1002
|
+
|
|
1003
|
+
// Reset step every time the dialog re-opens so the entry point is
|
|
1004
|
+
// recomputed against the latest connection state.
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
if (open) setStep(initialStep);
|
|
1007
|
+
}, [open, initialStep]);
|
|
1008
|
+
|
|
1009
|
+
const [apiKey, setApiKey] = useState("");
|
|
1010
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
1011
|
+
const [createdSummary, setCreatedSummary] = useState<{
|
|
1012
|
+
createdFeatures?: number;
|
|
1013
|
+
reusedFeatures?: number;
|
|
1014
|
+
} | null>(null);
|
|
1015
|
+
// Custom confirmation dialog state — replaces window.confirm() so we get
|
|
1016
|
+
// a styled, themed confirm UI instead of the browser's native modal.
|
|
1017
|
+
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
|
1018
|
+
|
|
1019
|
+
const connectMutation = useMutation({
|
|
1020
|
+
mutationFn: async (key: string) => {
|
|
1021
|
+
const res = await fetch(`${apiUrl}/v1/integrations/${projectId}/autumn`, {
|
|
1022
|
+
method: "POST",
|
|
1023
|
+
credentials: "include",
|
|
1024
|
+
headers: { "Content-Type": "application/json" },
|
|
1025
|
+
body: JSON.stringify({ apiKey: key }),
|
|
1026
|
+
});
|
|
1027
|
+
const json = await res.json().catch(() => ({}));
|
|
1028
|
+
if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
|
|
1029
|
+
return json.data as {
|
|
1030
|
+
status: string;
|
|
1031
|
+
featureIds: AutumnStatusResponse["featureIds"];
|
|
1032
|
+
env: AutumnStatusResponse["env"];
|
|
1033
|
+
createdFeatures?: number;
|
|
1034
|
+
reusedFeatures?: number;
|
|
1035
|
+
};
|
|
1036
|
+
},
|
|
1037
|
+
onSuccess: (data) => {
|
|
1038
|
+
setApiKey("");
|
|
1039
|
+
setSubmitError(null);
|
|
1040
|
+
setCreatedSummary({
|
|
1041
|
+
createdFeatures: data.createdFeatures,
|
|
1042
|
+
reusedFeatures: data.reusedFeatures,
|
|
1043
|
+
});
|
|
1044
|
+
onChanged();
|
|
1045
|
+
// Auto-advance to step 2 after a beat so the user sees the success state.
|
|
1046
|
+
const t = setTimeout(() => setStep("plans"), 1000);
|
|
1047
|
+
return () => clearTimeout(t);
|
|
1048
|
+
},
|
|
1049
|
+
onError: (err: Error) => {
|
|
1050
|
+
setSubmitError(err.message);
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const disconnectMutation = useMutation({
|
|
1055
|
+
mutationFn: async () => {
|
|
1056
|
+
const res = await fetch(`${apiUrl}/v1/integrations/${projectId}/autumn`, {
|
|
1057
|
+
method: "DELETE",
|
|
1058
|
+
credentials: "include",
|
|
1059
|
+
});
|
|
1060
|
+
if (!res.ok) throw new Error(`Failed (${res.status})`);
|
|
1061
|
+
},
|
|
1062
|
+
onSuccess: () => {
|
|
1063
|
+
onChanged();
|
|
1064
|
+
onOpenChange(false);
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Real verify call — asks Autumn whether ANY polpo_* feature is attached
|
|
1069
|
+
// to ANY of the builder's plans. The bridge is "ready" with as little as
|
|
1070
|
+
// one feature on one plan, by design (different products bill on different
|
|
1071
|
+
// dimensions). When the call comes back ready, we advance to step 3.
|
|
1072
|
+
const [verifyResult, setVerifyResult] = useState<VerifyData | null>(null);
|
|
1073
|
+
const verifyMutation = useMutation({
|
|
1074
|
+
mutationFn: async (): Promise<VerifyData> => {
|
|
1075
|
+
const res = await fetch(
|
|
1076
|
+
`${apiUrl}/v1/integrations/${projectId}/autumn/verify`,
|
|
1077
|
+
{ credentials: "include" },
|
|
1078
|
+
);
|
|
1079
|
+
const json = await res.json().catch(() => ({}));
|
|
1080
|
+
if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
|
|
1081
|
+
return json.data as VerifyData;
|
|
1082
|
+
},
|
|
1083
|
+
onSuccess: (data) => {
|
|
1084
|
+
setVerifyResult(data);
|
|
1085
|
+
onChanged();
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// Test event — fires a synthetic track call so the builder can prove the
|
|
1090
|
+
// bridge end-to-end before real users hit it.
|
|
1091
|
+
const testEventMutation = useMutation({
|
|
1092
|
+
mutationFn: async () => {
|
|
1093
|
+
const res = await fetch(
|
|
1094
|
+
`${apiUrl}/v1/integrations/${projectId}/autumn/test`,
|
|
1095
|
+
{ method: "POST", credentials: "include" },
|
|
1096
|
+
);
|
|
1097
|
+
const json = await res.json().catch(() => ({}));
|
|
1098
|
+
if (!res.ok) throw new Error(json?.error ?? `Failed (${res.status})`);
|
|
1099
|
+
return json.data as { customerId: string; featureId: string; value: number };
|
|
1100
|
+
},
|
|
1101
|
+
onSuccess: () => {
|
|
1102
|
+
onChanged();
|
|
1103
|
+
// Re-verify after a test event so the dashboard reflects new state.
|
|
1104
|
+
queryClient.invalidateQueries({ queryKey: ["autumn-integration", projectId] });
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Step navigation guard: never let the user jump forward past their
|
|
1109
|
+
// current progress (you can't "verify" before you "connect").
|
|
1110
|
+
// Going back to a completed step is fine — informational only.
|
|
1111
|
+
const order: WizardStep[] = ["connect", "plans", "status"];
|
|
1112
|
+
const connected = !!status?.connected;
|
|
1113
|
+
const eventsFired = (status?.eventsCount ?? 0) > 0;
|
|
1114
|
+
function canJumpTo(target: WizardStep): boolean {
|
|
1115
|
+
const targetIdx = order.indexOf(target);
|
|
1116
|
+
const currentIdx = order.indexOf(step);
|
|
1117
|
+
if (targetIdx <= currentIdx) return true;
|
|
1118
|
+
if (target === "plans") return connected || connectMutation.isSuccess;
|
|
1119
|
+
if (target === "status") return connected;
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Completion state per step — drives the green "Done" badge in the strip
|
|
1124
|
+
// and the Connect-step "already connected" panel.
|
|
1125
|
+
const completedSteps: Record<WizardStep, boolean> = {
|
|
1126
|
+
connect: connected || connectMutation.isSuccess,
|
|
1127
|
+
plans: !!verifyResult?.ready || eventsFired,
|
|
1128
|
+
status: eventsFired || testEventMutation.isSuccess,
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1133
|
+
<DialogContent className="sm:max-w-4xl bg-card ring-foreground/15 shadow-2xl">
|
|
1134
|
+
<DialogHeader className="pb-1">
|
|
1135
|
+
<DialogTitle className="text-base font-normal">
|
|
1136
|
+
<span className="inline-flex items-center gap-3">
|
|
1137
|
+
<img
|
|
1138
|
+
src="/logos/autumn-icon.svg"
|
|
1139
|
+
alt=""
|
|
1140
|
+
className="h-9 w-9 rounded"
|
|
1141
|
+
/>
|
|
1142
|
+
{mode === "manage" || connected ? (
|
|
1143
|
+
<span>
|
|
1144
|
+
Manage <span className="font-semibold">Autumn</span> integration
|
|
1145
|
+
</span>
|
|
1146
|
+
) : (
|
|
1147
|
+
<span>
|
|
1148
|
+
Connect <span className="font-semibold">Autumn</span>
|
|
1149
|
+
</span>
|
|
1150
|
+
)}
|
|
1151
|
+
</span>
|
|
1152
|
+
</DialogTitle>
|
|
1153
|
+
<DialogDescription className="text-sm">
|
|
1154
|
+
Per-end-user billing pass-through for your AI app. Plans, pricing,
|
|
1155
|
+
balance UIs stay in Autumn — Polpo is the rail.{" "}
|
|
1156
|
+
<a
|
|
1157
|
+
href="https://docs.useautumn.com"
|
|
1158
|
+
target="_blank"
|
|
1159
|
+
rel="noopener noreferrer"
|
|
1160
|
+
className="font-medium text-foreground hover:underline underline-offset-4"
|
|
1161
|
+
>
|
|
1162
|
+
Read the docs
|
|
1163
|
+
</a>
|
|
1164
|
+
</DialogDescription>
|
|
1165
|
+
</DialogHeader>
|
|
1166
|
+
|
|
1167
|
+
{mode === "manage" ? (
|
|
1168
|
+
<ManageView
|
|
1169
|
+
status={status}
|
|
1170
|
+
onSendTestEvent={() => testEventMutation.mutate()}
|
|
1171
|
+
testPending={testEventMutation.isPending}
|
|
1172
|
+
testError={
|
|
1173
|
+
testEventMutation.error instanceof Error
|
|
1174
|
+
? testEventMutation.error.message
|
|
1175
|
+
: null
|
|
1176
|
+
}
|
|
1177
|
+
testResult={testEventMutation.data ?? null}
|
|
1178
|
+
/>
|
|
1179
|
+
) : (
|
|
1180
|
+
<>
|
|
1181
|
+
{/* Segmented step strip — three squared cards glued together with
|
|
1182
|
+
a single outer border + vertical dividers. Same shape as the
|
|
1183
|
+
ConnectDialog tab strip. */}
|
|
1184
|
+
<div className="mt-2 flex items-stretch overflow-hidden border border-border divide-x divide-border">
|
|
1185
|
+
{WIZARD_STEPS.map((s) => (
|
|
1186
|
+
<StepOptionCard
|
|
1187
|
+
key={s.id}
|
|
1188
|
+
icon={s.icon}
|
|
1189
|
+
title={s.title}
|
|
1190
|
+
badge={s.badge}
|
|
1191
|
+
active={step === s.id}
|
|
1192
|
+
completed={completedSteps[s.id]}
|
|
1193
|
+
disabled={!canJumpTo(s.id)}
|
|
1194
|
+
onClick={() => setStep(s.id)}
|
|
1195
|
+
/>
|
|
1196
|
+
))}
|
|
1197
|
+
</div>
|
|
1198
|
+
|
|
1199
|
+
{/* Per-step content — 240px label column on the left, content
|
|
1200
|
+
on the right. min-h keeps dialog height stable across steps. */}
|
|
1201
|
+
<div className="mt-6 min-h-[160px]">
|
|
1202
|
+
{step === "connect" && (
|
|
1203
|
+
<ConnectStep
|
|
1204
|
+
apiKey={apiKey}
|
|
1205
|
+
setApiKey={setApiKey}
|
|
1206
|
+
submitError={submitError}
|
|
1207
|
+
isPending={connectMutation.isPending}
|
|
1208
|
+
isSuccess={connectMutation.isSuccess}
|
|
1209
|
+
alreadyConnected={connected && !connectMutation.isSuccess}
|
|
1210
|
+
env={status?.env}
|
|
1211
|
+
createdSummary={createdSummary}
|
|
1212
|
+
featureIds={status?.featureIds ?? connectMutation.data?.featureIds}
|
|
1213
|
+
onSubmit={(k) => connectMutation.mutate(k)}
|
|
1214
|
+
/>
|
|
1215
|
+
)}
|
|
1216
|
+
|
|
1217
|
+
{step === "plans" && (
|
|
1218
|
+
<PlansStep
|
|
1219
|
+
featureIds={status?.featureIds ?? connectMutation.data?.featureIds}
|
|
1220
|
+
onVerify={() => verifyMutation.mutate()}
|
|
1221
|
+
verifyPending={verifyMutation.isPending}
|
|
1222
|
+
verifyError={
|
|
1223
|
+
verifyMutation.error instanceof Error
|
|
1224
|
+
? verifyMutation.error.message
|
|
1225
|
+
: null
|
|
1226
|
+
}
|
|
1227
|
+
verifyResult={verifyResult}
|
|
1228
|
+
/>
|
|
1229
|
+
)}
|
|
1230
|
+
|
|
1231
|
+
{step === "status" && (
|
|
1232
|
+
<StatusStep
|
|
1233
|
+
status={status}
|
|
1234
|
+
onSendTestEvent={() => testEventMutation.mutate()}
|
|
1235
|
+
testPending={testEventMutation.isPending}
|
|
1236
|
+
testError={
|
|
1237
|
+
testEventMutation.error instanceof Error
|
|
1238
|
+
? testEventMutation.error.message
|
|
1239
|
+
: null
|
|
1240
|
+
}
|
|
1241
|
+
testResult={testEventMutation.data ?? null}
|
|
1242
|
+
/>
|
|
1243
|
+
)}
|
|
1244
|
+
</div>
|
|
1245
|
+
</>
|
|
1246
|
+
)}
|
|
1247
|
+
|
|
1248
|
+
<DialogFooter className="mt-2 flex items-center justify-between gap-3 sm:justify-between">
|
|
1249
|
+
{connected ? (
|
|
1250
|
+
<button
|
|
1251
|
+
type="button"
|
|
1252
|
+
onClick={() => setConfirmDisconnect(true)}
|
|
1253
|
+
disabled={disconnectMutation.isPending}
|
|
1254
|
+
className="inline-flex items-center gap-2 rounded border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-destructive hover:border-destructive/40 transition-colors disabled:opacity-50"
|
|
1255
|
+
>
|
|
1256
|
+
{disconnectMutation.isPending && (
|
|
1257
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
1258
|
+
)}
|
|
1259
|
+
Disconnect
|
|
1260
|
+
</button>
|
|
1261
|
+
) : (
|
|
1262
|
+
<span />
|
|
1263
|
+
)}
|
|
1264
|
+
<DialogClose className="rounded-md border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors">
|
|
1265
|
+
Close
|
|
1266
|
+
</DialogClose>
|
|
1267
|
+
</DialogFooter>
|
|
1268
|
+
</DialogContent>
|
|
1269
|
+
|
|
1270
|
+
{/* Custom confirmation modal for disconnect — replaces window.confirm */}
|
|
1271
|
+
<Dialog open={confirmDisconnect} onOpenChange={setConfirmDisconnect}>
|
|
1272
|
+
<DialogContent className="sm:max-w-md bg-card ring-foreground/15">
|
|
1273
|
+
<DialogHeader>
|
|
1274
|
+
<DialogTitle className="text-base">
|
|
1275
|
+
Disconnect Autumn?
|
|
1276
|
+
</DialogTitle>
|
|
1277
|
+
<DialogDescription>
|
|
1278
|
+
Polpo will stop firing usage events. Your plans, customers, and
|
|
1279
|
+
balances in Autumn are not affected — you can reconnect at any
|
|
1280
|
+
time by pasting the same key.
|
|
1281
|
+
</DialogDescription>
|
|
1282
|
+
</DialogHeader>
|
|
1283
|
+
<DialogFooter className="gap-2 sm:justify-end">
|
|
1284
|
+
<button
|
|
1285
|
+
type="button"
|
|
1286
|
+
onClick={() => setConfirmDisconnect(false)}
|
|
1287
|
+
className="rounded border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors"
|
|
1288
|
+
>
|
|
1289
|
+
Cancel
|
|
1290
|
+
</button>
|
|
1291
|
+
<button
|
|
1292
|
+
type="button"
|
|
1293
|
+
onClick={() => {
|
|
1294
|
+
setConfirmDisconnect(false);
|
|
1295
|
+
disconnectMutation.mutate();
|
|
1296
|
+
}}
|
|
1297
|
+
disabled={disconnectMutation.isPending}
|
|
1298
|
+
className="inline-flex items-center gap-2 rounded bg-destructive text-destructive-foreground px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
1299
|
+
>
|
|
1300
|
+
{disconnectMutation.isPending && (
|
|
1301
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
1302
|
+
)}
|
|
1303
|
+
Disconnect
|
|
1304
|
+
</button>
|
|
1305
|
+
</DialogFooter>
|
|
1306
|
+
</DialogContent>
|
|
1307
|
+
</Dialog>
|
|
1308
|
+
</Dialog>
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/* ── Manage view (compact, when integration is fully active) ─────── */
|
|
1313
|
+
|
|
1314
|
+
function ManageView({
|
|
1315
|
+
status,
|
|
1316
|
+
onSendTestEvent,
|
|
1317
|
+
testPending,
|
|
1318
|
+
testError,
|
|
1319
|
+
testResult,
|
|
1320
|
+
}: {
|
|
1321
|
+
status: AutumnStatusResponse | undefined;
|
|
1322
|
+
onSendTestEvent: () => void;
|
|
1323
|
+
testPending: boolean;
|
|
1324
|
+
testError: string | null;
|
|
1325
|
+
testResult: { customerId: string; featureId: string; value: number } | null;
|
|
1326
|
+
}) {
|
|
1327
|
+
return (
|
|
1328
|
+
<div className="mt-4 flex flex-col gap-4">
|
|
1329
|
+
<div className="flex items-center gap-2 border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5 text-xs text-emerald-700 dark:text-emerald-400">
|
|
1330
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
|
1331
|
+
<span className="font-medium">
|
|
1332
|
+
Bridge active
|
|
1333
|
+
{status?.env && status.env !== "unknown" ? ` · ${status.env}` : ""}
|
|
1334
|
+
</span>
|
|
1335
|
+
<span className="opacity-80">
|
|
1336
|
+
— Polpo is firing usage events to your Autumn account.
|
|
1337
|
+
</span>
|
|
1338
|
+
</div>
|
|
1339
|
+
|
|
1340
|
+
<div className="grid grid-cols-3 gap-3">
|
|
1341
|
+
<Stat label="Events fired" value={String(status?.eventsCount ?? 0)} />
|
|
1342
|
+
<Stat
|
|
1343
|
+
label="Errors"
|
|
1344
|
+
value={String(status?.errorsCount ?? 0)}
|
|
1345
|
+
tone={status?.errorsCount && status.errorsCount > 0 ? "warn" : "ok"}
|
|
1346
|
+
/>
|
|
1347
|
+
<Stat
|
|
1348
|
+
label="Last event"
|
|
1349
|
+
value={status?.lastUsedAt ? formatRelative(status.lastUsedAt) : "—"}
|
|
1350
|
+
/>
|
|
1351
|
+
</div>
|
|
1352
|
+
|
|
1353
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
1354
|
+
<button
|
|
1355
|
+
type="button"
|
|
1356
|
+
onClick={onSendTestEvent}
|
|
1357
|
+
disabled={testPending}
|
|
1358
|
+
className="inline-flex items-center gap-2 rounded border border-foreground/20 px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
|
|
1359
|
+
>
|
|
1360
|
+
{testPending ? (
|
|
1361
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
1362
|
+
) : (
|
|
1363
|
+
<Plug className="h-3.5 w-3.5" />
|
|
1364
|
+
)}
|
|
1365
|
+
{testResult ? "Send another" : "Send test event"}
|
|
1366
|
+
</button>
|
|
1367
|
+
<a
|
|
1368
|
+
href="https://app.useautumn.com/customers"
|
|
1369
|
+
target="_blank"
|
|
1370
|
+
rel="noreferrer"
|
|
1371
|
+
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
1372
|
+
>
|
|
1373
|
+
Open Autumn customers
|
|
1374
|
+
<ExternalLink className="h-3 w-3" />
|
|
1375
|
+
</a>
|
|
1376
|
+
</div>
|
|
1377
|
+
|
|
1378
|
+
{testError && (
|
|
1379
|
+
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
|
|
1380
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1381
|
+
<span>{testError}</span>
|
|
1382
|
+
</div>
|
|
1383
|
+
)}
|
|
1384
|
+
|
|
1385
|
+
{testResult && (
|
|
1386
|
+
<div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
|
|
1387
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1388
|
+
<div className="min-w-0">
|
|
1389
|
+
<div className="font-medium">Test event delivered</div>
|
|
1390
|
+
<div className="mt-0.5 font-mono opacity-80">
|
|
1391
|
+
{testResult.featureId} += {testResult.value} for{" "}
|
|
1392
|
+
{testResult.customerId}
|
|
1393
|
+
</div>
|
|
1394
|
+
</div>
|
|
1395
|
+
</div>
|
|
1396
|
+
)}
|
|
1397
|
+
|
|
1398
|
+
{status?.lastError && (
|
|
1399
|
+
<div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
|
|
1400
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1401
|
+
<div className="min-w-0">
|
|
1402
|
+
<div className="font-medium">Last error</div>
|
|
1403
|
+
<div className="mt-0.5 break-words font-mono text-[11px] opacity-80">
|
|
1404
|
+
{status.lastError}
|
|
1405
|
+
</div>
|
|
1406
|
+
{status.lastErrorAt && (
|
|
1407
|
+
<div className="mt-0.5 text-[11px] opacity-70">
|
|
1408
|
+
{formatRelative(status.lastErrorAt)}
|
|
1409
|
+
</div>
|
|
1410
|
+
)}
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
)}
|
|
1414
|
+
</div>
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/* ── Step 1: Connect ────────────────────────────────────── */
|
|
1419
|
+
|
|
1420
|
+
function ConnectStep({
|
|
1421
|
+
apiKey,
|
|
1422
|
+
setApiKey,
|
|
1423
|
+
submitError,
|
|
1424
|
+
isPending,
|
|
1425
|
+
isSuccess,
|
|
1426
|
+
alreadyConnected,
|
|
1427
|
+
env,
|
|
1428
|
+
createdSummary,
|
|
1429
|
+
featureIds,
|
|
1430
|
+
onSubmit,
|
|
1431
|
+
}: {
|
|
1432
|
+
apiKey: string;
|
|
1433
|
+
setApiKey: (v: string) => void;
|
|
1434
|
+
submitError: string | null;
|
|
1435
|
+
isPending: boolean;
|
|
1436
|
+
isSuccess: boolean;
|
|
1437
|
+
/** True when the dialog was opened on a project that's already connected
|
|
1438
|
+
* (status.connected) but the user navigated back to Step 1 to inspect or
|
|
1439
|
+
* rotate the key. We never expose the plaintext key. */
|
|
1440
|
+
alreadyConnected: boolean;
|
|
1441
|
+
env: AutumnStatusResponse["env"];
|
|
1442
|
+
createdSummary: { createdFeatures?: number; reusedFeatures?: number } | null;
|
|
1443
|
+
featureIds: AutumnStatusResponse["featureIds"];
|
|
1444
|
+
onSubmit: (key: string) => void;
|
|
1445
|
+
}) {
|
|
1446
|
+
// Already connected from a previous session — show a "Connected" panel
|
|
1447
|
+
// (we never fetched the plaintext key) instead of the empty form. The
|
|
1448
|
+
// disconnect lives in the dialog footer, so this view is purely
|
|
1449
|
+
// informational + a key-rotation affordance.
|
|
1450
|
+
if (alreadyConnected) {
|
|
1451
|
+
return (
|
|
1452
|
+
<div className="flex gap-6">
|
|
1453
|
+
<div className="flex w-[240px] shrink-0 flex-col gap-2">
|
|
1454
|
+
<p className="text-sm font-medium leading-6 text-foreground">Connected</p>
|
|
1455
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
1456
|
+
Your Autumn key is already linked. Drop a new key below to rotate
|
|
1457
|
+
it, or hit Disconnect at the bottom of this dialog.
|
|
1458
|
+
</p>
|
|
1459
|
+
<a
|
|
1460
|
+
href="https://app.useautumn.com/dev?tab=api_keys"
|
|
1461
|
+
target="_blank"
|
|
1462
|
+
rel="noreferrer"
|
|
1463
|
+
className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
|
|
1464
|
+
>
|
|
1465
|
+
Manage keys in Autumn →
|
|
1466
|
+
</a>
|
|
1467
|
+
</div>
|
|
1468
|
+
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
|
1469
|
+
<div className="flex items-start gap-2.5 border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-400">
|
|
1470
|
+
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
|
1471
|
+
<div className="min-w-0">
|
|
1472
|
+
<p className="font-medium">
|
|
1473
|
+
Connected{env && env !== "unknown" ? ` to ${env}` : ""}.
|
|
1474
|
+
</p>
|
|
1475
|
+
<p className="mt-0.5 text-xs opacity-80">
|
|
1476
|
+
For security we never display the saved key. Paste a new one
|
|
1477
|
+
below to overwrite it.
|
|
1478
|
+
</p>
|
|
1479
|
+
</div>
|
|
1480
|
+
</div>
|
|
1481
|
+
<form
|
|
1482
|
+
onSubmit={(e) => {
|
|
1483
|
+
e.preventDefault();
|
|
1484
|
+
if (!apiKey.trim()) return;
|
|
1485
|
+
onSubmit(apiKey.trim());
|
|
1486
|
+
}}
|
|
1487
|
+
className="flex items-stretch gap-2"
|
|
1488
|
+
>
|
|
1489
|
+
<input
|
|
1490
|
+
id="autumn-api-key"
|
|
1491
|
+
type="password"
|
|
1492
|
+
value={apiKey}
|
|
1493
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
1494
|
+
placeholder="am_sk_test_… or am_sk_live_… (rotate)"
|
|
1495
|
+
autoComplete="off"
|
|
1496
|
+
spellCheck={false}
|
|
1497
|
+
className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm font-mono focus:border-foreground/30 focus:outline-none transition-colors"
|
|
1498
|
+
/>
|
|
1499
|
+
<button
|
|
1500
|
+
type="submit"
|
|
1501
|
+
disabled={!apiKey.trim() || isPending}
|
|
1502
|
+
className="inline-flex items-center gap-2 rounded border border-border px-4 py-2.5 text-sm font-medium hover:border-foreground/30 transition-colors disabled:opacity-50"
|
|
1503
|
+
>
|
|
1504
|
+
{isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
1505
|
+
Rotate
|
|
1506
|
+
</button>
|
|
1507
|
+
</form>
|
|
1508
|
+
{submitError && (
|
|
1509
|
+
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
|
|
1510
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1511
|
+
<span>{submitError}</span>
|
|
1512
|
+
</div>
|
|
1513
|
+
)}
|
|
1514
|
+
{featureIds && <FeatureIdCards featureIds={featureIds} />}
|
|
1515
|
+
</div>
|
|
1516
|
+
</div>
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (isSuccess && createdSummary) {
|
|
1521
|
+
const total =
|
|
1522
|
+
(createdSummary.createdFeatures ?? 0) + (createdSummary.reusedFeatures ?? 0);
|
|
1523
|
+
return (
|
|
1524
|
+
<div className="flex gap-6">
|
|
1525
|
+
<div className="flex w-[240px] shrink-0 flex-col gap-2">
|
|
1526
|
+
<p className="text-sm font-medium leading-6 text-foreground">Connected</p>
|
|
1527
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
1528
|
+
Polpo is wired up to your Autumn account. The features below are ready
|
|
1529
|
+
to be attached to a plan.
|
|
1530
|
+
</p>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
|
1533
|
+
<div className="flex items-start gap-2.5 border border-brand/30 bg-brand/5 p-3 text-sm">
|
|
1534
|
+
<Check className="mt-0.5 h-4 w-4 shrink-0 text-brand" />
|
|
1535
|
+
<div className="min-w-0">
|
|
1536
|
+
<p className="font-medium text-foreground">
|
|
1537
|
+
Connected. {total > 0
|
|
1538
|
+
? `${total} feature${total === 1 ? "" : "s"} ready in your Autumn account.`
|
|
1539
|
+
: "Polpo is ready to fire events."}
|
|
1540
|
+
</p>
|
|
1541
|
+
{(createdSummary.createdFeatures ?? 0) > 0 && (
|
|
1542
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
1543
|
+
Created {createdSummary.createdFeatures} new
|
|
1544
|
+
{createdSummary.reusedFeatures
|
|
1545
|
+
? `, reused ${createdSummary.reusedFeatures}`
|
|
1546
|
+
: ""}
|
|
1547
|
+
.
|
|
1548
|
+
</p>
|
|
1549
|
+
)}
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
{featureIds && <FeatureIdCards featureIds={featureIds} />}
|
|
1553
|
+
<p className="text-xs text-muted-foreground">Moving to the next step…</p>
|
|
1554
|
+
</div>
|
|
1555
|
+
</div>
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
return (
|
|
1560
|
+
<form
|
|
1561
|
+
onSubmit={(e) => {
|
|
1562
|
+
e.preventDefault();
|
|
1563
|
+
if (!apiKey.trim()) return;
|
|
1564
|
+
onSubmit(apiKey.trim());
|
|
1565
|
+
}}
|
|
1566
|
+
className="flex gap-6"
|
|
1567
|
+
>
|
|
1568
|
+
<div className="flex w-[240px] shrink-0 flex-col gap-2">
|
|
1569
|
+
<p className="text-sm font-medium leading-6 text-foreground">Autumn secret key</p>
|
|
1570
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
1571
|
+
Polpo will fire usage events to your Autumn so you can charge your
|
|
1572
|
+
end-users. Plans, pricing, and balance UI stay in Autumn — Polpo is
|
|
1573
|
+
the rail.
|
|
1574
|
+
</p>
|
|
1575
|
+
<a
|
|
1576
|
+
href="https://app.useautumn.com/dev?tab=api_keys"
|
|
1577
|
+
target="_blank"
|
|
1578
|
+
rel="noreferrer"
|
|
1579
|
+
className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
|
|
1580
|
+
>
|
|
1581
|
+
Get your secret key →
|
|
1582
|
+
</a>
|
|
1583
|
+
</div>
|
|
1584
|
+
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
|
1585
|
+
<div className="flex items-stretch gap-2">
|
|
1586
|
+
<input
|
|
1587
|
+
id="autumn-api-key"
|
|
1588
|
+
type="password"
|
|
1589
|
+
value={apiKey}
|
|
1590
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
1591
|
+
placeholder="am_sk_test_… or am_sk_live_…"
|
|
1592
|
+
autoComplete="off"
|
|
1593
|
+
spellCheck={false}
|
|
1594
|
+
className="flex-1 border border-border bg-transparent px-3 py-2.5 text-sm font-mono focus:border-foreground/30 focus:outline-none transition-colors"
|
|
1595
|
+
/>
|
|
1596
|
+
<button
|
|
1597
|
+
type="submit"
|
|
1598
|
+
disabled={!apiKey.trim() || isPending}
|
|
1599
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
|
|
1600
|
+
>
|
|
1601
|
+
{isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
1602
|
+
Connect
|
|
1603
|
+
</button>
|
|
1604
|
+
</div>
|
|
1605
|
+
|
|
1606
|
+
{submitError && (
|
|
1607
|
+
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
|
|
1608
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1609
|
+
<span>{submitError}</span>
|
|
1610
|
+
</div>
|
|
1611
|
+
)}
|
|
1612
|
+
</div>
|
|
1613
|
+
</form>
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/* ── Step 2: Plans ──────────────────────────────────────── */
|
|
1618
|
+
|
|
1619
|
+
interface VerifyData {
|
|
1620
|
+
ready: boolean;
|
|
1621
|
+
totalPlans: number;
|
|
1622
|
+
attached: string[];
|
|
1623
|
+
unattached: string[];
|
|
1624
|
+
plansWithPolpoFeatures: Array<{ id: string; name: string; featureIds: string[] }>;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function PlansStep({
|
|
1628
|
+
featureIds,
|
|
1629
|
+
onVerify,
|
|
1630
|
+
verifyPending,
|
|
1631
|
+
verifyError,
|
|
1632
|
+
verifyResult,
|
|
1633
|
+
}: {
|
|
1634
|
+
featureIds: AutumnStatusResponse["featureIds"];
|
|
1635
|
+
onVerify: () => void;
|
|
1636
|
+
verifyPending: boolean;
|
|
1637
|
+
verifyError: string | null;
|
|
1638
|
+
verifyResult: VerifyData | null;
|
|
1639
|
+
}) {
|
|
1640
|
+
return (
|
|
1641
|
+
<div className="flex gap-6">
|
|
1642
|
+
<div className="flex w-[240px] shrink-0 flex-col gap-2">
|
|
1643
|
+
<p className="text-sm font-medium leading-6 text-foreground">Create a plan</p>
|
|
1644
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
1645
|
+
Polpo just created 3 features in your Autumn account. Attach{" "}
|
|
1646
|
+
<span className="font-medium text-foreground">at least one</span> of
|
|
1647
|
+
them to <span className="font-medium text-foreground">at least one</span>{" "}
|
|
1648
|
+
plan — that's enough to bridge billing.
|
|
1649
|
+
</p>
|
|
1650
|
+
<a
|
|
1651
|
+
href="https://docs.useautumn.com/products/create-product"
|
|
1652
|
+
target="_blank"
|
|
1653
|
+
rel="noreferrer"
|
|
1654
|
+
className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
|
|
1655
|
+
>
|
|
1656
|
+
Plan setup guide →
|
|
1657
|
+
</a>
|
|
1658
|
+
</div>
|
|
1659
|
+
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
|
1660
|
+
{featureIds && <FeatureIdCards featureIds={featureIds} />}
|
|
1661
|
+
{!featureIds && (
|
|
1662
|
+
<div className="border border-border bg-background p-6 text-center text-sm text-muted-foreground">
|
|
1663
|
+
Connect Autumn first to see the feature IDs.
|
|
1664
|
+
</div>
|
|
1665
|
+
)}
|
|
1666
|
+
|
|
1667
|
+
<div className="mt-2 flex flex-wrap items-center gap-3">
|
|
1668
|
+
<a
|
|
1669
|
+
href="https://app.useautumn.com/products?tab=products"
|
|
1670
|
+
target="_blank"
|
|
1671
|
+
rel="noreferrer"
|
|
1672
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
|
|
1673
|
+
>
|
|
1674
|
+
Open Autumn Plans
|
|
1675
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
1676
|
+
</a>
|
|
1677
|
+
<button
|
|
1678
|
+
type="button"
|
|
1679
|
+
onClick={onVerify}
|
|
1680
|
+
disabled={verifyPending}
|
|
1681
|
+
className="inline-flex items-center gap-2 rounded border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors disabled:opacity-50"
|
|
1682
|
+
>
|
|
1683
|
+
{verifyPending ? (
|
|
1684
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
1685
|
+
) : (
|
|
1686
|
+
<RefreshCw className="h-3.5 w-3.5" />
|
|
1687
|
+
)}
|
|
1688
|
+
Check setup
|
|
1689
|
+
</button>
|
|
1690
|
+
</div>
|
|
1691
|
+
|
|
1692
|
+
{verifyError && (
|
|
1693
|
+
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
|
|
1694
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1695
|
+
<span>{verifyError}</span>
|
|
1696
|
+
</div>
|
|
1697
|
+
)}
|
|
1698
|
+
|
|
1699
|
+
{verifyResult && !verifyResult.ready && (
|
|
1700
|
+
<div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
|
|
1701
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1702
|
+
<div className="min-w-0">
|
|
1703
|
+
<div className="font-medium">No polpo features attached yet</div>
|
|
1704
|
+
<div className="mt-0.5 opacity-80">
|
|
1705
|
+
Found {verifyResult.totalPlans} plan
|
|
1706
|
+
{verifyResult.totalPlans === 1 ? "" : "s"} in your Autumn
|
|
1707
|
+
account, but none of them include a{" "}
|
|
1708
|
+
<code className="font-mono">polpo_*</code> feature. Attach at
|
|
1709
|
+
least one and click Verify again.
|
|
1710
|
+
</div>
|
|
1711
|
+
</div>
|
|
1712
|
+
</div>
|
|
1713
|
+
)}
|
|
1714
|
+
|
|
1715
|
+
{verifyResult && verifyResult.ready && (
|
|
1716
|
+
<div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
|
|
1717
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1718
|
+
<div className="min-w-0">
|
|
1719
|
+
<div className="font-medium">
|
|
1720
|
+
Bridge ready — {verifyResult.attached.length} feature
|
|
1721
|
+
{verifyResult.attached.length === 1 ? "" : "s"} attached
|
|
1722
|
+
</div>
|
|
1723
|
+
{verifyResult.plansWithPolpoFeatures.length > 0 && (
|
|
1724
|
+
<div className="mt-0.5 opacity-80">
|
|
1725
|
+
On{" "}
|
|
1726
|
+
{verifyResult.plansWithPolpoFeatures
|
|
1727
|
+
.map((p) => p.name)
|
|
1728
|
+
.join(", ")}
|
|
1729
|
+
. Move to Step 3 to fire a test event.
|
|
1730
|
+
</div>
|
|
1731
|
+
)}
|
|
1732
|
+
</div>
|
|
1733
|
+
</div>
|
|
1734
|
+
)}
|
|
1735
|
+
</div>
|
|
1736
|
+
</div>
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/* ── Step 3: Status / Manage ────────────────────────────── */
|
|
1741
|
+
|
|
1742
|
+
function StatusStep({
|
|
1743
|
+
status,
|
|
1744
|
+
onSendTestEvent,
|
|
1745
|
+
testPending,
|
|
1746
|
+
testError,
|
|
1747
|
+
testResult,
|
|
1748
|
+
}: {
|
|
1749
|
+
status: AutumnStatusResponse | undefined;
|
|
1750
|
+
onSendTestEvent: () => void;
|
|
1751
|
+
testPending: boolean;
|
|
1752
|
+
testError: string | null;
|
|
1753
|
+
testResult: { customerId: string; featureId: string; value: number } | null;
|
|
1754
|
+
}) {
|
|
1755
|
+
return (
|
|
1756
|
+
<div className="flex gap-6">
|
|
1757
|
+
<div className="flex w-[240px] shrink-0 flex-col gap-2">
|
|
1758
|
+
<p className="text-sm font-medium leading-6 text-foreground">Test the bridge</p>
|
|
1759
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
1760
|
+
Fire a synthetic event to <span className="font-mono">polpo_completions</span> for{" "}
|
|
1761
|
+
<span className="font-mono">polpo-test-customer</span>. Proves the
|
|
1762
|
+
rail end-to-end before real users hit it.
|
|
1763
|
+
</p>
|
|
1764
|
+
<a
|
|
1765
|
+
href="https://app.useautumn.com/customers"
|
|
1766
|
+
target="_blank"
|
|
1767
|
+
rel="noreferrer"
|
|
1768
|
+
className="self-start text-xs font-medium text-foreground hover:underline underline-offset-4"
|
|
1769
|
+
>
|
|
1770
|
+
Open Autumn customers →
|
|
1771
|
+
</a>
|
|
1772
|
+
</div>
|
|
1773
|
+
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
|
1774
|
+
<div className="grid grid-cols-3 gap-3">
|
|
1775
|
+
<Stat label="Events fired" value={String(status?.eventsCount ?? 0)} />
|
|
1776
|
+
<Stat
|
|
1777
|
+
label="Errors"
|
|
1778
|
+
value={String(status?.errorsCount ?? 0)}
|
|
1779
|
+
tone={status?.errorsCount && status.errorsCount > 0 ? "warn" : "ok"}
|
|
1780
|
+
/>
|
|
1781
|
+
<Stat
|
|
1782
|
+
label="Last event"
|
|
1783
|
+
value={status?.lastUsedAt ? formatRelative(status.lastUsedAt) : "—"}
|
|
1784
|
+
/>
|
|
1785
|
+
</div>
|
|
1786
|
+
|
|
1787
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
1788
|
+
<button
|
|
1789
|
+
type="button"
|
|
1790
|
+
onClick={onSendTestEvent}
|
|
1791
|
+
disabled={testPending}
|
|
1792
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
1793
|
+
>
|
|
1794
|
+
{testPending ? (
|
|
1795
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
1796
|
+
) : (
|
|
1797
|
+
<Plug className="h-3.5 w-3.5" />
|
|
1798
|
+
)}
|
|
1799
|
+
Send test event
|
|
1800
|
+
</button>
|
|
1801
|
+
</div>
|
|
1802
|
+
|
|
1803
|
+
{testError && (
|
|
1804
|
+
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive">
|
|
1805
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1806
|
+
<span>{testError}</span>
|
|
1807
|
+
</div>
|
|
1808
|
+
)}
|
|
1809
|
+
|
|
1810
|
+
{testResult && (
|
|
1811
|
+
<div className="flex items-start gap-2 border border-emerald-500/30 bg-emerald-500/5 p-2.5 text-xs text-emerald-700 dark:text-emerald-400">
|
|
1812
|
+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1813
|
+
<div className="min-w-0">
|
|
1814
|
+
<div className="font-medium">Test event delivered</div>
|
|
1815
|
+
<div className="mt-0.5 font-mono opacity-80">
|
|
1816
|
+
{testResult.featureId} += {testResult.value} for{" "}
|
|
1817
|
+
{testResult.customerId}
|
|
1818
|
+
</div>
|
|
1819
|
+
</div>
|
|
1820
|
+
</div>
|
|
1821
|
+
)}
|
|
1822
|
+
|
|
1823
|
+
{status?.lastError && (
|
|
1824
|
+
<div className="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs text-amber-700 dark:text-amber-400">
|
|
1825
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
1826
|
+
<div className="min-w-0">
|
|
1827
|
+
<div className="font-medium">Last error</div>
|
|
1828
|
+
<div className="mt-0.5 break-words font-mono text-[11px] opacity-80">
|
|
1829
|
+
{status.lastError}
|
|
1830
|
+
</div>
|
|
1831
|
+
{status.lastErrorAt && (
|
|
1832
|
+
<div className="mt-0.5 text-[11px] opacity-70">
|
|
1833
|
+
{formatRelative(status.lastErrorAt)}
|
|
1834
|
+
</div>
|
|
1835
|
+
)}
|
|
1836
|
+
</div>
|
|
1837
|
+
</div>
|
|
1838
|
+
)}
|
|
1839
|
+
|
|
1840
|
+
{status?.featureIds && <FeatureIdCards featureIds={status.featureIds} />}
|
|
1841
|
+
</div>
|
|
1842
|
+
</div>
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/* ── Shared bits ────────────────────────────────────────── */
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* Stack of CopyCards — one per polpo_* feature ID. Same visual treatment
|
|
1850
|
+
* as connect-dialog's API key / URL row, swapping the value-list for the
|
|
1851
|
+
* three feature ids the user needs to wire up in Autumn.
|
|
1852
|
+
*/
|
|
1853
|
+
function FeatureIdCards({
|
|
1854
|
+
featureIds,
|
|
1855
|
+
}: {
|
|
1856
|
+
featureIds: NonNullable<AutumnStatusResponse["featureIds"]>;
|
|
1857
|
+
}) {
|
|
1858
|
+
return (
|
|
1859
|
+
<div className="flex flex-col gap-2">
|
|
1860
|
+
<CopyCard label="polpo_completions feature" value={featureIds.completions} />
|
|
1861
|
+
<CopyCard label="polpo_tasks feature" value={featureIds.tasks} />
|
|
1862
|
+
<CopyCard label="polpo_inference_usd feature" value={featureIds.inferenceUsd} />
|
|
1863
|
+
</div>
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** Compact stat tile for the integration telemetry panel. */
|
|
1868
|
+
function Stat({ label, value, tone = "ok" }: { label: string; value: string; tone?: "ok" | "warn" }) {
|
|
1869
|
+
return (
|
|
1870
|
+
<div className="border border-border bg-background p-3">
|
|
1871
|
+
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
1872
|
+
{label}
|
|
1873
|
+
</p>
|
|
1874
|
+
<p className={`mt-1 text-lg font-semibold ${tone === "warn" ? "text-amber-600 dark:text-amber-400" : "text-foreground"}`}>
|
|
1875
|
+
{value}
|
|
1876
|
+
</p>
|
|
1877
|
+
</div>
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/** Coarse relative-time format — matches the format used elsewhere in the dashboard. */
|
|
1882
|
+
function formatRelative(iso: string): string {
|
|
1883
|
+
const then = new Date(iso).getTime();
|
|
1884
|
+
if (Number.isNaN(then)) return "recently";
|
|
1885
|
+
const diffMs = Date.now() - then;
|
|
1886
|
+
const m = Math.floor(diffMs / 60_000);
|
|
1887
|
+
if (m < 1) return "just now";
|
|
1888
|
+
if (m < 60) return `${m}m ago`;
|
|
1889
|
+
const h = Math.floor(m / 60);
|
|
1890
|
+
if (h < 24) return `${h}h ago`;
|
|
1891
|
+
const d = Math.floor(h / 24);
|
|
1892
|
+
return `${d}d ago`;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/* ── Webhooks Tab ─────────────────────────────────────────── */
|
|
1896
|
+
|
|
1897
|
+
function WebhooksTab({ projectId }: { projectId: string }) {
|
|
1898
|
+
const queryClient = useQueryClient();
|
|
1899
|
+
const [showForm, setShowForm] = useState(false);
|
|
1900
|
+
const [formUrl, setFormUrl] = useState("");
|
|
1901
|
+
// Wire format: a Set of event patterns. May contain "*", "task:*", or
|
|
1902
|
+
// specific keys like "task:created". The handlers below maintain the
|
|
1903
|
+
// invariant that wildcards and their children are never both present.
|
|
1904
|
+
const [selectedEvents, setSelectedEvents] = useState<Set<string>>(
|
|
1905
|
+
() => new Set<string>(["*"]),
|
|
1906
|
+
);
|
|
1907
|
+
// Pending webhook deletion — null when no confirm is open. Mirrors the
|
|
1908
|
+
// Autumn disconnect flow so destructive actions always go through a
|
|
1909
|
+
// styled modal instead of window.confirm().
|
|
1910
|
+
const [pendingDelete, setPendingDelete] = useState<Webhook | null>(null);
|
|
1911
|
+
// Webhook currently open in the delivery inspector dialog. Same pattern as
|
|
1912
|
+
// pendingDelete — null means closed.
|
|
1913
|
+
const [pendingInspect, setPendingInspect] = useState<Webhook | null>(null);
|
|
1914
|
+
// When non-null, the form is in "edit" mode for an existing row. The URL
|
|
1915
|
+
// and event selection are pre-filled from `editing` and submit calls
|
|
1916
|
+
// PATCH instead of POST.
|
|
1917
|
+
const [editing, setEditing] = useState<Webhook | null>(null);
|
|
1918
|
+
|
|
1919
|
+
function openCreate() {
|
|
1920
|
+
setEditing(null);
|
|
1921
|
+
setFormUrl("");
|
|
1922
|
+
setSelectedEvents(new Set(["*"]));
|
|
1923
|
+
setShowForm(true);
|
|
1924
|
+
}
|
|
1925
|
+
function openEdit(wh: Webhook) {
|
|
1926
|
+
setEditing(wh);
|
|
1927
|
+
setFormUrl(wh.url);
|
|
1928
|
+
setSelectedEvents(new Set(wh.events));
|
|
1929
|
+
setShowForm(true);
|
|
1930
|
+
}
|
|
1931
|
+
function closeForm() {
|
|
1932
|
+
setShowForm(false);
|
|
1933
|
+
setEditing(null);
|
|
1934
|
+
setFormUrl("");
|
|
1935
|
+
setSelectedEvents(new Set(["*"]));
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const { data: webhooks = [], isLoading, isFetching, refetch } = useQuery({
|
|
1939
|
+
queryKey: ["webhooks", projectId],
|
|
1940
|
+
queryFn: () =>
|
|
1941
|
+
fetchDataPlane<{ ok: boolean; data: Webhook[] }>(projectId, "/v1/webhooks").then(
|
|
1942
|
+
(r) => r.data ?? []
|
|
1943
|
+
),
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// Single mutation that creates *or* updates depending on whether the form
|
|
1947
|
+
// was opened from an existing row. Keeps the success path identical so
|
|
1948
|
+
// the form clears and closes the same way in both flows.
|
|
1949
|
+
const upsertMutation = useMutation({
|
|
1950
|
+
mutationFn: (data: { url: string; events: string[]; editingId: string | null }) =>
|
|
1951
|
+
data.editingId
|
|
1952
|
+
? mutateDataPlane(projectId, `/v1/webhooks/${data.editingId}`, {
|
|
1953
|
+
method: "PATCH",
|
|
1954
|
+
body: { url: data.url, events: data.events },
|
|
1955
|
+
})
|
|
1956
|
+
: mutateDataPlane(projectId, "/v1/webhooks", {
|
|
1957
|
+
method: "POST",
|
|
1958
|
+
body: { url: data.url, events: data.events },
|
|
1959
|
+
}),
|
|
1960
|
+
onSuccess: () => {
|
|
1961
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks", projectId] });
|
|
1962
|
+
closeForm();
|
|
1963
|
+
},
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
const deleteMutation = useMutation({
|
|
1967
|
+
mutationFn: (webhookId: string) =>
|
|
1968
|
+
mutateDataPlane(projectId, `/v1/webhooks/${webhookId}`, { method: "DELETE" }),
|
|
1969
|
+
onSuccess: () => {
|
|
1970
|
+
queryClient.invalidateQueries({ queryKey: ["webhooks", projectId] });
|
|
1971
|
+
},
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
function handleSubmit(e: React.FormEvent) {
|
|
1975
|
+
e.preventDefault();
|
|
1976
|
+
const events = Array.from(selectedEvents);
|
|
1977
|
+
if (events.length === 0) return;
|
|
1978
|
+
upsertMutation.mutate({
|
|
1979
|
+
url: formUrl.trim(),
|
|
1980
|
+
events,
|
|
1981
|
+
editingId: editing?.id ?? null,
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
if (isLoading) {
|
|
1986
|
+
return (
|
|
1987
|
+
<div className="space-y-3">
|
|
1988
|
+
{[1, 2].map((i) => (
|
|
1989
|
+
<div key={i} className="h-14 rounded-lg bg-secondary/30 animate-pulse" />
|
|
1990
|
+
))}
|
|
1991
|
+
</div>
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
return (
|
|
1996
|
+
<section>
|
|
1997
|
+
<div className="flex items-center justify-between">
|
|
1998
|
+
<div>
|
|
1999
|
+
<h2 className="text-lg font-semibold tracking-tight">Webhooks</h2>
|
|
2000
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
2001
|
+
Receive HTTP POST notifications when events occur in this project.
|
|
2002
|
+
</p>
|
|
2003
|
+
</div>
|
|
2004
|
+
<div className="flex items-center gap-3">
|
|
2005
|
+
<button
|
|
2006
|
+
onClick={() => refetch()}
|
|
2007
|
+
disabled={isFetching}
|
|
2008
|
+
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
2009
|
+
>
|
|
2010
|
+
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
|
2011
|
+
Refresh
|
|
2012
|
+
</button>
|
|
2013
|
+
{!showForm && (
|
|
2014
|
+
<button
|
|
2015
|
+
onClick={openCreate}
|
|
2016
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
|
|
2017
|
+
>
|
|
2018
|
+
<Plus className="h-3 w-3" />
|
|
2019
|
+
Add webhook
|
|
2020
|
+
</button>
|
|
2021
|
+
)}
|
|
2022
|
+
</div>
|
|
2023
|
+
</div>
|
|
2024
|
+
|
|
2025
|
+
{/* Add form */}
|
|
2026
|
+
{showForm && (
|
|
2027
|
+
<form onSubmit={handleSubmit} className="mt-4 rounded-lg border border-border bg-card p-4 space-y-3">
|
|
2028
|
+
<div>
|
|
2029
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
|
2030
|
+
Endpoint URL
|
|
2031
|
+
</label>
|
|
2032
|
+
<input
|
|
2033
|
+
type="url"
|
|
2034
|
+
value={formUrl}
|
|
2035
|
+
onChange={(e) => setFormUrl(e.target.value)}
|
|
2036
|
+
placeholder="https://example.com/webhook"
|
|
2037
|
+
required
|
|
2038
|
+
className="w-full border border-border bg-transparent px-3 py-2 text-sm font-mono placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
|
|
2039
|
+
/>
|
|
2040
|
+
</div>
|
|
2041
|
+
<EventSelector
|
|
2042
|
+
selected={selectedEvents}
|
|
2043
|
+
onChange={setSelectedEvents}
|
|
2044
|
+
/>
|
|
2045
|
+
{upsertMutation.error && (
|
|
2046
|
+
<p className="text-xs text-destructive">
|
|
2047
|
+
{(upsertMutation.error as Error).message}
|
|
2048
|
+
</p>
|
|
2049
|
+
)}
|
|
2050
|
+
<div className="flex items-center gap-3 pt-1">
|
|
2051
|
+
<button
|
|
2052
|
+
type="submit"
|
|
2053
|
+
disabled={
|
|
2054
|
+
upsertMutation.isPending ||
|
|
2055
|
+
!formUrl.trim() ||
|
|
2056
|
+
selectedEvents.size === 0
|
|
2057
|
+
}
|
|
2058
|
+
className="inline-flex items-center gap-2 bg-foreground text-background px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90 disabled:opacity-50"
|
|
2059
|
+
>
|
|
2060
|
+
{upsertMutation.isPending ? (
|
|
2061
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
2062
|
+
) : editing ? (
|
|
2063
|
+
"Save changes"
|
|
2064
|
+
) : (
|
|
2065
|
+
"Create"
|
|
2066
|
+
)}
|
|
2067
|
+
</button>
|
|
2068
|
+
<button
|
|
2069
|
+
type="button"
|
|
2070
|
+
onClick={closeForm}
|
|
2071
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
2072
|
+
>
|
|
2073
|
+
Cancel
|
|
2074
|
+
</button>
|
|
2075
|
+
</div>
|
|
2076
|
+
</form>
|
|
2077
|
+
)}
|
|
2078
|
+
|
|
2079
|
+
{/* Webhooks list */}
|
|
2080
|
+
{webhooks.length > 0 ? (
|
|
2081
|
+
<div className="mt-4 rounded-lg border border-border overflow-hidden">
|
|
2082
|
+
{webhooks.map((wh) => (
|
|
2083
|
+
<div
|
|
2084
|
+
key={wh.id}
|
|
2085
|
+
className="flex items-center justify-between border-b border-border last:border-0 px-4 py-3"
|
|
2086
|
+
>
|
|
2087
|
+
<div className="min-w-0 flex-1">
|
|
2088
|
+
<p className="font-mono text-xs font-medium truncate">{wh.url}</p>
|
|
2089
|
+
<div className="mt-1 flex items-center gap-2">
|
|
2090
|
+
{wh.events.map((ev) => (
|
|
2091
|
+
<span
|
|
2092
|
+
key={ev}
|
|
2093
|
+
className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
|
|
2094
|
+
>
|
|
2095
|
+
{ev}
|
|
2096
|
+
</span>
|
|
2097
|
+
))}
|
|
2098
|
+
</div>
|
|
2099
|
+
</div>
|
|
2100
|
+
<div className="ml-4 flex shrink-0 items-center gap-3">
|
|
2101
|
+
<button
|
|
2102
|
+
onClick={() => setPendingInspect(wh)}
|
|
2103
|
+
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
2104
|
+
aria-label="Inspect deliveries"
|
|
2105
|
+
title="Inspect deliveries"
|
|
2106
|
+
>
|
|
2107
|
+
<Eye className="h-3.5 w-3.5" />
|
|
2108
|
+
</button>
|
|
2109
|
+
<button
|
|
2110
|
+
onClick={() => openEdit(wh)}
|
|
2111
|
+
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
|
2112
|
+
aria-label="Edit webhook"
|
|
2113
|
+
title="Edit webhook"
|
|
2114
|
+
>
|
|
2115
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
2116
|
+
</button>
|
|
2117
|
+
<button
|
|
2118
|
+
onClick={() => setPendingDelete(wh)}
|
|
2119
|
+
disabled={deleteMutation.isPending}
|
|
2120
|
+
className="text-muted-foreground/50 hover:text-destructive transition-colors"
|
|
2121
|
+
aria-label="Delete webhook"
|
|
2122
|
+
title="Delete webhook"
|
|
2123
|
+
>
|
|
2124
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
2125
|
+
</button>
|
|
2126
|
+
</div>
|
|
2127
|
+
</div>
|
|
2128
|
+
))}
|
|
2129
|
+
</div>
|
|
2130
|
+
) : !showForm ? (
|
|
2131
|
+
<div className="mt-4 rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
|
|
2132
|
+
No webhooks configured.
|
|
2133
|
+
</div>
|
|
2134
|
+
) : null}
|
|
2135
|
+
|
|
2136
|
+
{/* Confirm-delete modal — same pattern as the Autumn disconnect dialog. */}
|
|
2137
|
+
<Dialog
|
|
2138
|
+
open={!!pendingDelete}
|
|
2139
|
+
onOpenChange={(v) => !v && setPendingDelete(null)}
|
|
2140
|
+
>
|
|
2141
|
+
<DialogContent className="sm:max-w-md bg-card ring-foreground/15">
|
|
2142
|
+
<DialogHeader>
|
|
2143
|
+
<DialogTitle className="text-base">Delete webhook?</DialogTitle>
|
|
2144
|
+
<DialogDescription>
|
|
2145
|
+
<span className="block">
|
|
2146
|
+
Polpo will stop sending events to this endpoint. You can
|
|
2147
|
+
always re-add it.
|
|
2148
|
+
</span>
|
|
2149
|
+
{pendingDelete && (
|
|
2150
|
+
<span className="mt-2 block break-all rounded border border-border bg-secondary/40 px-2 py-1.5 font-mono text-[11px] text-foreground">
|
|
2151
|
+
{pendingDelete.url}
|
|
2152
|
+
</span>
|
|
2153
|
+
)}
|
|
2154
|
+
</DialogDescription>
|
|
2155
|
+
</DialogHeader>
|
|
2156
|
+
<DialogFooter className="gap-2 sm:justify-end">
|
|
2157
|
+
<button
|
|
2158
|
+
type="button"
|
|
2159
|
+
onClick={() => setPendingDelete(null)}
|
|
2160
|
+
className="rounded border border-border px-4 py-2 text-sm hover:border-foreground/30 transition-colors"
|
|
2161
|
+
>
|
|
2162
|
+
Cancel
|
|
2163
|
+
</button>
|
|
2164
|
+
<button
|
|
2165
|
+
type="button"
|
|
2166
|
+
onClick={() => {
|
|
2167
|
+
if (!pendingDelete) return;
|
|
2168
|
+
deleteMutation.mutate(pendingDelete.id);
|
|
2169
|
+
setPendingDelete(null);
|
|
2170
|
+
}}
|
|
2171
|
+
disabled={deleteMutation.isPending}
|
|
2172
|
+
className="inline-flex items-center gap-2 rounded bg-destructive text-destructive-foreground px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
2173
|
+
>
|
|
2174
|
+
{deleteMutation.isPending && (
|
|
2175
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
2176
|
+
)}
|
|
2177
|
+
Delete
|
|
2178
|
+
</button>
|
|
2179
|
+
</DialogFooter>
|
|
2180
|
+
</DialogContent>
|
|
2181
|
+
</Dialog>
|
|
2182
|
+
|
|
2183
|
+
{/* Delivery inspector — opens the new WebhookDeliveries panel. */}
|
|
2184
|
+
<Dialog
|
|
2185
|
+
open={!!pendingInspect}
|
|
2186
|
+
onOpenChange={(v) => !v && setPendingInspect(null)}
|
|
2187
|
+
>
|
|
2188
|
+
<DialogContent className="sm:max-w-3xl bg-card ring-foreground/15">
|
|
2189
|
+
<DialogHeader>
|
|
2190
|
+
<DialogTitle className="text-base">Webhook deliveries</DialogTitle>
|
|
2191
|
+
<DialogDescription>
|
|
2192
|
+
Last 50 attempts. Auto-refreshes every 5s.
|
|
2193
|
+
</DialogDescription>
|
|
2194
|
+
</DialogHeader>
|
|
2195
|
+
{pendingInspect && (
|
|
2196
|
+
<WebhookDeliveries
|
|
2197
|
+
projectId={projectId}
|
|
2198
|
+
webhook={pendingInspect}
|
|
2199
|
+
/>
|
|
2200
|
+
)}
|
|
2201
|
+
</DialogContent>
|
|
2202
|
+
</Dialog>
|
|
2203
|
+
</section>
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/* ── Webhook event selector (hierarchical multi-checkbox) ─────────── */
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Tree-shaped event picker: a top-level "All events" toggle, then one
|
|
2211
|
+
* collapsible group per namespace (task, mission, agent, …) with a "Select
|
|
2212
|
+
* all" mini-checkbox in its header and individual event checkboxes inside.
|
|
2213
|
+
*
|
|
2214
|
+
* The wire format is just an array of event patterns — the same one the
|
|
2215
|
+
* data plane already accepts (`task:*`, `mission:completed`, `*`, …) — so
|
|
2216
|
+
* we never have to model "expansion" on the backend. The state below
|
|
2217
|
+
* keeps wildcards and their children mutually exclusive, otherwise we'd
|
|
2218
|
+
* end up with confusingly-redundant payloads like `["task:*","task:created"]`.
|
|
2219
|
+
*/
|
|
2220
|
+
function EventSelector({
|
|
2221
|
+
selected,
|
|
2222
|
+
onChange,
|
|
2223
|
+
}: {
|
|
2224
|
+
selected: Set<string>;
|
|
2225
|
+
onChange: (next: Set<string>) => void;
|
|
2226
|
+
}) {
|
|
2227
|
+
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set());
|
|
2228
|
+
// Source of truth lives in `@polpo-ai/core`'s EVENT_CATALOG and is served
|
|
2229
|
+
// by the cloud at `/v1/events/catalog` (cached 1h). Fetching dynamically
|
|
2230
|
+
// keeps the picker in lockstep with whatever the runtime can actually
|
|
2231
|
+
// emit — no more hand-mirrored arrays here.
|
|
2232
|
+
const { data: catalog = [], isLoading } = useEventCatalog();
|
|
2233
|
+
const totalEvents = catalog.reduce((acc, g) => acc + g.events.length, 0);
|
|
2234
|
+
const allChecked = selected.has("*");
|
|
2235
|
+
|
|
2236
|
+
// Count concrete events covered by the current selection — used for the
|
|
2237
|
+
// "X of Y selected" hint and to decide group header tri-state.
|
|
2238
|
+
function countSelectedInGroup(ns: string, events: ReadonlyArray<{ key: string }>) {
|
|
2239
|
+
if (selected.has("*") || selected.has(`${ns}:*`)) return events.length;
|
|
2240
|
+
return events.filter((e) => selected.has(`${ns}:${e.key}`)).length;
|
|
2241
|
+
}
|
|
2242
|
+
const totalSelected = allChecked
|
|
2243
|
+
? totalEvents
|
|
2244
|
+
: catalog.reduce(
|
|
2245
|
+
(acc, g) => acc + countSelectedInGroup(g.ns, g.events),
|
|
2246
|
+
0,
|
|
2247
|
+
);
|
|
2248
|
+
|
|
2249
|
+
function toggleAll(next: boolean) {
|
|
2250
|
+
onChange(next ? new Set(["*"]) : new Set());
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function toggleGroup(ns: string, events: ReadonlyArray<{ key: string }>, next: boolean) {
|
|
2254
|
+
const out = new Set(selected);
|
|
2255
|
+
// Removing the global wildcard is implicit when we move from "everything"
|
|
2256
|
+
// to a finer selection — expand it into the *other* groups so the user
|
|
2257
|
+
// doesn't lose them.
|
|
2258
|
+
if (out.has("*")) {
|
|
2259
|
+
out.delete("*");
|
|
2260
|
+
for (const g of catalog) if (g.ns !== ns) out.add(`${g.ns}:*`);
|
|
2261
|
+
}
|
|
2262
|
+
// Drop any individual children we might have left behind, then set the
|
|
2263
|
+
// group wildcard (or remove it on the off-toggle).
|
|
2264
|
+
for (const e of events) out.delete(`${ns}:${e.key}`);
|
|
2265
|
+
out.delete(`${ns}:*`);
|
|
2266
|
+
if (next) out.add(`${ns}:*`);
|
|
2267
|
+
onChange(out);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function toggleEvent(ns: string, key: string, next: boolean) {
|
|
2271
|
+
const out = new Set(selected);
|
|
2272
|
+
// Same expansion logic as toggleGroup, then expand the group's own
|
|
2273
|
+
// wildcard into per-event entries so we can remove just one.
|
|
2274
|
+
if (out.has("*")) {
|
|
2275
|
+
out.delete("*");
|
|
2276
|
+
for (const g of catalog) if (g.ns !== ns) out.add(`${g.ns}:*`);
|
|
2277
|
+
}
|
|
2278
|
+
if (out.has(`${ns}:*`)) {
|
|
2279
|
+
out.delete(`${ns}:*`);
|
|
2280
|
+
const group = catalog.find((g) => g.ns === ns);
|
|
2281
|
+
if (group) for (const e of group.events) out.add(`${ns}:${e.key}`);
|
|
2282
|
+
}
|
|
2283
|
+
const fq = `${ns}:${key}`;
|
|
2284
|
+
if (next) out.add(fq);
|
|
2285
|
+
else out.delete(fq);
|
|
2286
|
+
onChange(out);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function toggleOpen(ns: string) {
|
|
2290
|
+
const next = new Set(openGroups);
|
|
2291
|
+
if (next.has(ns)) next.delete(ns);
|
|
2292
|
+
else next.add(ns);
|
|
2293
|
+
setOpenGroups(next);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
return (
|
|
2297
|
+
<div>
|
|
2298
|
+
<div className="mb-1.5 flex items-baseline justify-between">
|
|
2299
|
+
<label className="block text-xs font-medium text-muted-foreground">
|
|
2300
|
+
Events
|
|
2301
|
+
</label>
|
|
2302
|
+
<span className="text-[11px] text-muted-foreground">
|
|
2303
|
+
{totalSelected} of {totalEvents} selected
|
|
2304
|
+
</span>
|
|
2305
|
+
</div>
|
|
2306
|
+
|
|
2307
|
+
<div className="border border-border">
|
|
2308
|
+
{/* All events */}
|
|
2309
|
+
<label className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 transition-colors hover:bg-secondary/40">
|
|
2310
|
+
<Checkbox checked={allChecked} indeterminate={false} />
|
|
2311
|
+
<div className="min-w-0 flex-1">
|
|
2312
|
+
<p className="text-sm font-medium leading-none">All events</p>
|
|
2313
|
+
<p className="mt-1 text-[11px] text-muted-foreground">
|
|
2314
|
+
Receive every event Polpo emits — overrides the per-namespace
|
|
2315
|
+
selection below.
|
|
2316
|
+
</p>
|
|
2317
|
+
</div>
|
|
2318
|
+
<input
|
|
2319
|
+
type="checkbox"
|
|
2320
|
+
checked={allChecked}
|
|
2321
|
+
onChange={(e) => toggleAll(e.target.checked)}
|
|
2322
|
+
className="sr-only"
|
|
2323
|
+
aria-label="All events"
|
|
2324
|
+
/>
|
|
2325
|
+
</label>
|
|
2326
|
+
|
|
2327
|
+
{/* Namespace groups */}
|
|
2328
|
+
<div className="max-h-[320px] overflow-y-auto">
|
|
2329
|
+
{isLoading && catalog.length === 0 && (
|
|
2330
|
+
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
2331
|
+
Loading event catalog…
|
|
2332
|
+
</div>
|
|
2333
|
+
)}
|
|
2334
|
+
{catalog.map((group) => {
|
|
2335
|
+
const isOpen = openGroups.has(group.ns);
|
|
2336
|
+
const groupCount = countSelectedInGroup(group.ns, group.events);
|
|
2337
|
+
const groupAll = groupCount === group.events.length && groupCount > 0;
|
|
2338
|
+
const groupSome = groupCount > 0 && !groupAll;
|
|
2339
|
+
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
|
2340
|
+
return (
|
|
2341
|
+
<div key={group.ns} className="border-b border-border last:border-b-0">
|
|
2342
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
2343
|
+
<button
|
|
2344
|
+
type="button"
|
|
2345
|
+
onClick={() => toggleOpen(group.ns)}
|
|
2346
|
+
className="inline-flex items-center text-muted-foreground hover:text-foreground"
|
|
2347
|
+
aria-label={isOpen ? "Collapse" : "Expand"}
|
|
2348
|
+
>
|
|
2349
|
+
<Chevron className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
2350
|
+
</button>
|
|
2351
|
+
|
|
2352
|
+
<label className="flex flex-1 cursor-pointer items-center gap-3">
|
|
2353
|
+
<Checkbox
|
|
2354
|
+
checked={groupAll || allChecked}
|
|
2355
|
+
indeterminate={!allChecked && groupSome}
|
|
2356
|
+
/>
|
|
2357
|
+
<div className="min-w-0 flex-1">
|
|
2358
|
+
<p className="text-sm font-medium leading-none">
|
|
2359
|
+
{group.label}
|
|
2360
|
+
<span className="ml-2 font-mono text-[10px] font-normal text-muted-foreground">
|
|
2361
|
+
{group.ns}:*
|
|
2362
|
+
</span>
|
|
2363
|
+
</p>
|
|
2364
|
+
<p className="mt-1 text-[11px] text-muted-foreground">
|
|
2365
|
+
{group.description}
|
|
2366
|
+
</p>
|
|
2367
|
+
</div>
|
|
2368
|
+
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
|
2369
|
+
{allChecked
|
|
2370
|
+
? `${group.events.length}/${group.events.length}`
|
|
2371
|
+
: `${groupCount}/${group.events.length}`}
|
|
2372
|
+
</span>
|
|
2373
|
+
<input
|
|
2374
|
+
type="checkbox"
|
|
2375
|
+
checked={groupAll || allChecked}
|
|
2376
|
+
onChange={(e) =>
|
|
2377
|
+
toggleGroup(group.ns, group.events, e.target.checked)
|
|
2378
|
+
}
|
|
2379
|
+
disabled={allChecked}
|
|
2380
|
+
className="sr-only"
|
|
2381
|
+
aria-label={`Select all ${group.label} events`}
|
|
2382
|
+
/>
|
|
2383
|
+
</label>
|
|
2384
|
+
</div>
|
|
2385
|
+
|
|
2386
|
+
{isOpen && (
|
|
2387
|
+
<div className="grid grid-cols-1 gap-y-1 border-t border-border bg-secondary/20 px-3 py-2 sm:grid-cols-2">
|
|
2388
|
+
{group.events.map((ev) => {
|
|
2389
|
+
const checked =
|
|
2390
|
+
allChecked ||
|
|
2391
|
+
selected.has(`${group.ns}:*`) ||
|
|
2392
|
+
selected.has(`${group.ns}:${ev.key}`);
|
|
2393
|
+
return (
|
|
2394
|
+
<label
|
|
2395
|
+
key={ev.key}
|
|
2396
|
+
className="flex cursor-pointer items-start gap-2 px-1.5 py-1.5 text-xs hover:bg-secondary/60"
|
|
2397
|
+
title={ev.description}
|
|
2398
|
+
>
|
|
2399
|
+
<Checkbox checked={checked} indeterminate={false} compact />
|
|
2400
|
+
<span className="min-w-0 flex-1">
|
|
2401
|
+
<span className="block font-mono text-[12px] text-foreground">
|
|
2402
|
+
{group.ns}:{ev.key}
|
|
2403
|
+
</span>
|
|
2404
|
+
<span className="block truncate text-[11px] text-muted-foreground">
|
|
2405
|
+
{ev.description}
|
|
2406
|
+
</span>
|
|
2407
|
+
</span>
|
|
2408
|
+
<input
|
|
2409
|
+
type="checkbox"
|
|
2410
|
+
checked={checked}
|
|
2411
|
+
onChange={(e) =>
|
|
2412
|
+
toggleEvent(group.ns, ev.key, e.target.checked)
|
|
2413
|
+
}
|
|
2414
|
+
className="sr-only"
|
|
2415
|
+
/>
|
|
2416
|
+
</label>
|
|
2417
|
+
);
|
|
2418
|
+
})}
|
|
2419
|
+
</div>
|
|
2420
|
+
)}
|
|
2421
|
+
</div>
|
|
2422
|
+
);
|
|
2423
|
+
})}
|
|
2424
|
+
</div>
|
|
2425
|
+
</div>
|
|
2426
|
+
|
|
2427
|
+
{totalSelected === 0 && (
|
|
2428
|
+
<p className="mt-1.5 text-[11px] text-amber-600 dark:text-amber-400">
|
|
2429
|
+
Select at least one event to receive notifications.
|
|
2430
|
+
</p>
|
|
2431
|
+
)}
|
|
2432
|
+
</div>
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Minimal styled checkbox — supports tri-state (`indeterminate`) for the
|
|
2438
|
+
* group headers when a subset of children is selected. We render a custom
|
|
2439
|
+
* box because native indeterminate styling is browser-dependent and ugly.
|
|
2440
|
+
*/
|
|
2441
|
+
function Checkbox({
|
|
2442
|
+
checked,
|
|
2443
|
+
indeterminate,
|
|
2444
|
+
compact,
|
|
2445
|
+
}: {
|
|
2446
|
+
checked: boolean;
|
|
2447
|
+
indeterminate: boolean;
|
|
2448
|
+
compact?: boolean;
|
|
2449
|
+
}) {
|
|
2450
|
+
const size = compact ? "h-3.5 w-3.5" : "h-4 w-4";
|
|
2451
|
+
if (indeterminate) {
|
|
2452
|
+
return (
|
|
2453
|
+
<span
|
|
2454
|
+
aria-hidden
|
|
2455
|
+
className={`inline-flex shrink-0 items-center justify-center border ${size} border-foreground bg-foreground/10 text-foreground`}
|
|
2456
|
+
>
|
|
2457
|
+
<Minus className="h-2.5 w-2.5" strokeWidth={3} />
|
|
2458
|
+
</span>
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
if (checked) {
|
|
2462
|
+
return (
|
|
2463
|
+
<span
|
|
2464
|
+
aria-hidden
|
|
2465
|
+
className={`inline-flex shrink-0 items-center justify-center border ${size} border-foreground bg-foreground text-background`}
|
|
2466
|
+
>
|
|
2467
|
+
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
|
2468
|
+
</span>
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
return (
|
|
2472
|
+
<span
|
|
2473
|
+
aria-hidden
|
|
2474
|
+
className={`inline-flex shrink-0 ${size} border border-border bg-background`}
|
|
2475
|
+
/>
|
|
2476
|
+
);
|
|
2477
|
+
}
|