@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,224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Task Studio — the task detail surface as a single fast client component.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors AgentStudio: a server shell fetches the initial task (and activity)
|
|
7
|
+
* once, hands it to this ONE client component, which renders client-switched
|
|
8
|
+
* tabs (Overview / Activity / Output / Assessment) via `useState` + a URL
|
|
9
|
+
* `?tab=` param (instant, deep-linkable, no per-tab navigation).
|
|
10
|
+
*
|
|
11
|
+
* Pure dogfood: task data is fetched through the published SDK via
|
|
12
|
+
* `usePolpoClient` (getTask + getTaskActivityFull), hydrated from the
|
|
13
|
+
* server shell's `initialData`. No raw `dataApi`/`api` in the client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useState } from "react";
|
|
17
|
+
import Link from "next/link";
|
|
18
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
19
|
+
import {
|
|
20
|
+
ListChecks,
|
|
21
|
+
FileText,
|
|
22
|
+
Activity,
|
|
23
|
+
Terminal,
|
|
24
|
+
ClipboardCheck,
|
|
25
|
+
type LucideIcon,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
import { useQuery } from "@tanstack/react-query";
|
|
28
|
+
import type { Task } from "@polpo-ai/core";
|
|
29
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
30
|
+
import { Breadcrumb } from "#/components/dashboard/breadcrumb";
|
|
31
|
+
import type { BlueprintContext } from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint";
|
|
32
|
+
import TaskOverviewView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/view";
|
|
33
|
+
import TaskOutputView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view";
|
|
34
|
+
import TaskAssessmentView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view";
|
|
35
|
+
import TaskActivityView, {
|
|
36
|
+
type TaskActivityPayload,
|
|
37
|
+
} from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view";
|
|
38
|
+
|
|
39
|
+
const statusColor: Record<string, string> = {
|
|
40
|
+
done: "bg-brand",
|
|
41
|
+
in_progress: "bg-foreground animate-pulse",
|
|
42
|
+
pending: "bg-muted-foreground/30",
|
|
43
|
+
failed: "bg-destructive",
|
|
44
|
+
review: "bg-yellow-500",
|
|
45
|
+
assigned: "bg-muted-foreground/50",
|
|
46
|
+
draft: "bg-muted-foreground/20",
|
|
47
|
+
awaiting_approval: "bg-yellow-500",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ACTIVE_TASK_STATUSES = new Set([
|
|
51
|
+
"pending",
|
|
52
|
+
"assigned",
|
|
53
|
+
"in_progress",
|
|
54
|
+
"review",
|
|
55
|
+
"awaiting_approval",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
type StudioTab = "overview" | "activity" | "output" | "assessment";
|
|
59
|
+
|
|
60
|
+
const TABS: { key: StudioTab; label: string; icon: LucideIcon }[] = [
|
|
61
|
+
{ key: "overview", label: "Overview", icon: FileText },
|
|
62
|
+
{ key: "activity", label: "Activity", icon: Activity },
|
|
63
|
+
{ key: "output", label: "Output", icon: Terminal },
|
|
64
|
+
{ key: "assessment", label: "Assessment", icon: ClipboardCheck },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export interface TaskStudioInitialData {
|
|
68
|
+
task: Task | null;
|
|
69
|
+
activity: TaskActivityPayload;
|
|
70
|
+
blueprint: BlueprintContext | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function TaskStudio({
|
|
74
|
+
projectId,
|
|
75
|
+
taskId,
|
|
76
|
+
initialData,
|
|
77
|
+
}: {
|
|
78
|
+
projectId: string;
|
|
79
|
+
taskId: string;
|
|
80
|
+
initialData: TaskStudioInitialData;
|
|
81
|
+
}) {
|
|
82
|
+
const router = useRouter();
|
|
83
|
+
const pathname = usePathname();
|
|
84
|
+
const searchParams = useSearchParams();
|
|
85
|
+
const polpo = usePolpoClient(projectId);
|
|
86
|
+
|
|
87
|
+
// Tab is URL-driven (?tab=) so deep links work and the copilot can point
|
|
88
|
+
// straight at a tab. Falls back to "overview".
|
|
89
|
+
const urlTab = searchParams.get("tab") as StudioTab | null;
|
|
90
|
+
const validUrlTab = urlTab && TABS.some((t) => t.key === urlTab) ? urlTab : null;
|
|
91
|
+
const [tab, setTab] = useState<StudioTab>(validUrlTab ?? "overview");
|
|
92
|
+
|
|
93
|
+
// React to external URL changes (back/forward, copilot navigation).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (validUrlTab && validUrlTab !== tab) setTab(validUrlTab);
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, [validUrlTab]);
|
|
98
|
+
|
|
99
|
+
function selectTab(key: StudioTab) {
|
|
100
|
+
setTab(key);
|
|
101
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
102
|
+
if (key === "overview") params.delete("tab");
|
|
103
|
+
else params.set("tab", key);
|
|
104
|
+
const qs = params.toString();
|
|
105
|
+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Single source of truth for the task — dogfood via SDK getTask, hydrated
|
|
109
|
+
// from the server shell. When the task is a blueprint (no real instance yet)
|
|
110
|
+
// there's nothing to fetch, so we keep the synthetic initialData.
|
|
111
|
+
const isBlueprint = Boolean(initialData.blueprint) && !initialData.activity.task;
|
|
112
|
+
const { data: task = initialData.task } = useQuery<Task | null>({
|
|
113
|
+
queryKey: ["task", projectId, taskId],
|
|
114
|
+
// SDK getTask returns its own re-exported Task shape; the views consume
|
|
115
|
+
// the core Task. They're structurally equivalent — cast through unknown
|
|
116
|
+
// to bridge the SDK/core type-export skew (see missions/view.tsx note).
|
|
117
|
+
queryFn: () => polpo.getTask(taskId) as unknown as Promise<Task | null>,
|
|
118
|
+
initialData: initialData.task,
|
|
119
|
+
enabled: !isBlueprint,
|
|
120
|
+
refetchInterval: (query) => {
|
|
121
|
+
const t = query.state.data;
|
|
122
|
+
return t && ACTIVE_TASK_STATUSES.has(t.status) ? 3000 : false;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const blueprint = initialData.blueprint;
|
|
127
|
+
|
|
128
|
+
if (!task) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex h-[calc(100vh-7rem)] min-h-[520px] flex-col overflow-hidden">
|
|
131
|
+
<div className="flex shrink-0 items-center gap-2 pb-3">
|
|
132
|
+
<Breadcrumb
|
|
133
|
+
items={[
|
|
134
|
+
{ label: "Tasks", href: `/projects/${projectId}/tasks`, icon: ListChecks },
|
|
135
|
+
{ label: taskId },
|
|
136
|
+
]}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="border border-border p-10 text-center">
|
|
140
|
+
<FileText
|
|
141
|
+
className="mx-auto h-5 w-5 text-muted-foreground/40"
|
|
142
|
+
strokeWidth={1.5}
|
|
143
|
+
/>
|
|
144
|
+
<p className="mt-2 text-sm text-muted-foreground">Task not found.</p>
|
|
145
|
+
<Link
|
|
146
|
+
href={`/projects/${projectId}/tasks`}
|
|
147
|
+
className="mt-3 inline-block text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
148
|
+
>
|
|
149
|
+
Back to tasks
|
|
150
|
+
</Link>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
// Fills the viewport (no page scroll); only the inner pane scrolls.
|
|
158
|
+
<div className="flex h-[calc(100vh-7rem)] min-h-[520px] flex-col overflow-hidden">
|
|
159
|
+
{/* Top bar — breadcrumb + status. Fixed (does not scroll). */}
|
|
160
|
+
<div className="flex shrink-0 items-center gap-2 pb-3">
|
|
161
|
+
<Breadcrumb
|
|
162
|
+
items={[
|
|
163
|
+
{ label: "Tasks", href: `/projects/${projectId}/tasks`, icon: ListChecks },
|
|
164
|
+
{ label: task.title || taskId },
|
|
165
|
+
]}
|
|
166
|
+
/>
|
|
167
|
+
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
|
168
|
+
<span
|
|
169
|
+
className={`h-2 w-2 rounded-full ${statusColor[task.status ?? "pending"]}`}
|
|
170
|
+
/>
|
|
171
|
+
<span className="font-medium text-foreground">{task.status}</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Sticky tab nav (does not scroll) */}
|
|
176
|
+
<div className="shrink-0 pr-4">
|
|
177
|
+
<div className="flex flex-wrap gap-1 border-b border-border">
|
|
178
|
+
{TABS.map((t) => {
|
|
179
|
+
const Icon = t.icon;
|
|
180
|
+
const active = t.key === tab;
|
|
181
|
+
return (
|
|
182
|
+
<button
|
|
183
|
+
key={t.key}
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => selectTab(t.key)}
|
|
186
|
+
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm transition-colors ${
|
|
187
|
+
active
|
|
188
|
+
? "font-medium text-foreground"
|
|
189
|
+
: "text-muted-foreground hover:text-foreground"
|
|
190
|
+
}`}
|
|
191
|
+
>
|
|
192
|
+
<Icon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
193
|
+
{t.label}
|
|
194
|
+
{active && (
|
|
195
|
+
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
|
|
196
|
+
)}
|
|
197
|
+
</button>
|
|
198
|
+
);
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Only the tab content scrolls */}
|
|
204
|
+
<div className="min-h-0 flex-1 overflow-auto py-4 pr-4">
|
|
205
|
+
{tab === "overview" && (
|
|
206
|
+
<TaskOverviewView task={task} blueprint={blueprint ?? undefined} />
|
|
207
|
+
)}
|
|
208
|
+
{tab === "activity" && (
|
|
209
|
+
<TaskActivityView
|
|
210
|
+
projectId={projectId}
|
|
211
|
+
taskId={taskId}
|
|
212
|
+
initialActivity={initialData.activity}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
{tab === "output" && (
|
|
216
|
+
<TaskOutputView task={task} blueprint={blueprint} />
|
|
217
|
+
)}
|
|
218
|
+
{tab === "assessment" && (
|
|
219
|
+
<TaskAssessmentView task={task} blueprint={blueprint} />
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import {
|
|
8
|
+
ArrowLeft,
|
|
9
|
+
BookOpen,
|
|
10
|
+
ChevronDown,
|
|
11
|
+
Check,
|
|
12
|
+
Loader2,
|
|
13
|
+
SquarePen,
|
|
14
|
+
Settings,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import { ConnectButton } from "#/components/dashboard/connect-dialog";
|
|
17
|
+
import { Popover, PopoverContent, PopoverTrigger } from "#/components/ui/popover";
|
|
18
|
+
|
|
19
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Single-tenant top header: brandmark + Connect + Docs. No org breadcrumb,
|
|
23
|
+
* plan badge, credits or user menu — those are cloud-only surfaces.
|
|
24
|
+
*/
|
|
25
|
+
export function TopHeader() {
|
|
26
|
+
return (
|
|
27
|
+
<header className="flex h-12 items-center justify-between border-b border-border bg-card px-4 md:px-5 shrink-0">
|
|
28
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
29
|
+
<Link href="/projects/local" data-testid="header-logo" className="flex items-center shrink-0">
|
|
30
|
+
<Image
|
|
31
|
+
src="/polpo-brandmark.svg"
|
|
32
|
+
alt="Polpo"
|
|
33
|
+
width={24}
|
|
34
|
+
height={24}
|
|
35
|
+
priority
|
|
36
|
+
className="h-[18px] w-[18px]"
|
|
37
|
+
/>
|
|
38
|
+
</Link>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
42
|
+
<ConnectButton />
|
|
43
|
+
<a
|
|
44
|
+
href="https://docs.polpo.sh"
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noopener noreferrer"
|
|
47
|
+
data-testid="header-docs"
|
|
48
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
|
|
49
|
+
>
|
|
50
|
+
<BookOpen className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
51
|
+
<span className="hidden sm:inline">Docs</span>
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
</header>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Project name → lightweight rename popover. Same data-plane endpoint as
|
|
60
|
+
* settings-form. `orgId` is accepted (optional, unused) for call-site parity
|
|
61
|
+
* with the cloud sidebar.
|
|
62
|
+
*/
|
|
63
|
+
type Mode = "menu" | "rename";
|
|
64
|
+
|
|
65
|
+
export function ProjectSwitcher({
|
|
66
|
+
projectId,
|
|
67
|
+
projectName,
|
|
68
|
+
variant = "header",
|
|
69
|
+
}: {
|
|
70
|
+
projectId: string;
|
|
71
|
+
projectName: string;
|
|
72
|
+
orgId?: string;
|
|
73
|
+
variant?: "header" | "sidebar";
|
|
74
|
+
}) {
|
|
75
|
+
const queryClient = useQueryClient();
|
|
76
|
+
const [open, setOpen] = useState(false);
|
|
77
|
+
const [mode, setMode] = useState<Mode>("menu");
|
|
78
|
+
const [name, setName] = useState(projectName);
|
|
79
|
+
const [saved, setSaved] = useState(false);
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
|
|
82
|
+
const renameMutation = useMutation({
|
|
83
|
+
mutationFn: async (newName: string) => {
|
|
84
|
+
const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
|
|
85
|
+
method: "PATCH",
|
|
86
|
+
credentials: "include",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ name: newName }),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const d = await res.json().catch(() => ({}));
|
|
92
|
+
throw new Error(d?.error ?? `Failed (${res.status})`);
|
|
93
|
+
}
|
|
94
|
+
return res.json();
|
|
95
|
+
},
|
|
96
|
+
onSuccess: () => {
|
|
97
|
+
setError(null);
|
|
98
|
+
setSaved(true);
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
setSaved(false);
|
|
101
|
+
setOpen(false);
|
|
102
|
+
}, 1200);
|
|
103
|
+
queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
|
104
|
+
},
|
|
105
|
+
onError: (err: Error) => setError(err.message),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const changed = name.trim() !== projectName && name.trim().length > 0;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Popover
|
|
112
|
+
open={open}
|
|
113
|
+
onOpenChange={(o) => {
|
|
114
|
+
setOpen(o);
|
|
115
|
+
if (o) {
|
|
116
|
+
setMode("menu");
|
|
117
|
+
setName(projectName);
|
|
118
|
+
setError(null);
|
|
119
|
+
setSaved(false);
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<PopoverTrigger
|
|
124
|
+
data-testid={variant === "sidebar" ? "sidebar-project-switcher" : "header-project-switcher"}
|
|
125
|
+
className={
|
|
126
|
+
variant === "sidebar"
|
|
127
|
+
? "group flex w-full items-center justify-between gap-1.5 border border-border bg-card px-2.5 py-1.5 text-sm font-medium truncate text-foreground hover:border-foreground/30 data-[popup-open]:border-foreground/40 data-[popup-open]:bg-secondary/50 transition-colors"
|
|
128
|
+
: "group inline-flex items-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium truncate text-foreground hover:border-border hover:bg-secondary/70 data-[popup-open]:border-border data-[popup-open]:bg-secondary transition-colors"
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
<span className="truncate">{projectName}</span>
|
|
132
|
+
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
133
|
+
</PopoverTrigger>
|
|
134
|
+
<PopoverContent align="start" sideOffset={6} className="w-72 p-2">
|
|
135
|
+
{mode === "menu" ? (
|
|
136
|
+
<div className="flex flex-col">
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => setMode("rename")}
|
|
140
|
+
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm leading-5 text-muted-foreground hover:bg-secondary/50 hover:text-foreground transition-colors"
|
|
141
|
+
>
|
|
142
|
+
<SquarePen className="h-3.5 w-3.5 shrink-0" />
|
|
143
|
+
Rename project
|
|
144
|
+
</button>
|
|
145
|
+
<Link
|
|
146
|
+
href={`/projects/${projectId}/settings`}
|
|
147
|
+
onClick={() => setOpen(false)}
|
|
148
|
+
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm leading-5 text-muted-foreground hover:bg-secondary/50 hover:text-foreground transition-colors"
|
|
149
|
+
>
|
|
150
|
+
<Settings className="h-3.5 w-3.5 shrink-0" />
|
|
151
|
+
Project settings
|
|
152
|
+
</Link>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<form
|
|
156
|
+
onSubmit={(e) => {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
if (changed) renameMutation.mutate(name.trim());
|
|
159
|
+
}}
|
|
160
|
+
className="flex flex-col gap-2 p-1"
|
|
161
|
+
>
|
|
162
|
+
<div className="flex items-center justify-between">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => setMode("menu")}
|
|
166
|
+
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
167
|
+
>
|
|
168
|
+
<ArrowLeft className="h-3 w-3" />
|
|
169
|
+
Back
|
|
170
|
+
</button>
|
|
171
|
+
<span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground">
|
|
172
|
+
Rename
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex items-stretch gap-2">
|
|
176
|
+
<input
|
|
177
|
+
type="text"
|
|
178
|
+
value={name}
|
|
179
|
+
autoFocus
|
|
180
|
+
onChange={(e) => setName(e.target.value)}
|
|
181
|
+
className="flex-1 min-w-0 border border-border bg-transparent px-2.5 py-1.5 text-sm focus:border-foreground/30 focus:outline-none transition-colors"
|
|
182
|
+
/>
|
|
183
|
+
<button
|
|
184
|
+
type="submit"
|
|
185
|
+
disabled={!changed || renameMutation.isPending}
|
|
186
|
+
className="rounded border border-border bg-foreground/5 px-3 py-1.5 text-xs font-medium text-foreground hover:bg-foreground hover:text-background transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
187
|
+
>
|
|
188
|
+
{renameMutation.isPending ? (
|
|
189
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
190
|
+
) : saved ? (
|
|
191
|
+
<Check className="h-3.5 w-3.5" />
|
|
192
|
+
) : (
|
|
193
|
+
"Save"
|
|
194
|
+
)}
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
198
|
+
</form>
|
|
199
|
+
)}
|
|
200
|
+
</PopoverContent>
|
|
201
|
+
</Popover>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webhook delivery inspector.
|
|
5
|
+
*
|
|
6
|
+
* Renders a paginated, auto-refreshing table of webhook delivery attempts
|
|
7
|
+
* for a single webhook. Each row can be expanded to show the request body
|
|
8
|
+
* and (truncated) response body, and re-fired with the "Redeliver" button.
|
|
9
|
+
*
|
|
10
|
+
* Auto-refresh is intentionally chunky (5s). Webhooks are not chat — the
|
|
11
|
+
* user opens this panel to debug, not to watch a live stream. SSE would be
|
|
12
|
+
* overkill and 5s polling is cheap on the data plane.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
17
|
+
import { ChevronDown, ChevronRight, Loader2, RefreshCw } from "lucide-react";
|
|
18
|
+
import { fetchDataPlane, mutateDataPlane } from "#/lib/data-client";
|
|
19
|
+
|
|
20
|
+
interface Webhook {
|
|
21
|
+
id: string;
|
|
22
|
+
url: string;
|
|
23
|
+
events: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Delivery {
|
|
27
|
+
id: string;
|
|
28
|
+
webhook_id: string;
|
|
29
|
+
event: string;
|
|
30
|
+
attempt: number;
|
|
31
|
+
status: "pending" | "success" | "failed" | "retrying";
|
|
32
|
+
status_code: number | null;
|
|
33
|
+
response_body: string | null;
|
|
34
|
+
error: string | null;
|
|
35
|
+
duration_ms: number | null;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
next_retry_at: string | null;
|
|
38
|
+
created_at: string;
|
|
39
|
+
completed_at: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DeliveriesResponse {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
data: Delivery[];
|
|
45
|
+
next_cursor: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const STATUS_STYLES: Record<Delivery["status"], string> = {
|
|
49
|
+
success: "bg-emerald-500/10 text-emerald-600 ring-1 ring-emerald-500/20",
|
|
50
|
+
failed: "bg-red-500/10 text-red-600 ring-1 ring-red-500/20",
|
|
51
|
+
retrying: "bg-amber-500/10 text-amber-600 ring-1 ring-amber-500/20",
|
|
52
|
+
pending: "bg-secondary text-muted-foreground ring-1 ring-border",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function WebhookDeliveries({
|
|
56
|
+
projectId,
|
|
57
|
+
webhook,
|
|
58
|
+
}: {
|
|
59
|
+
projectId: string;
|
|
60
|
+
webhook: Webhook;
|
|
61
|
+
}) {
|
|
62
|
+
const queryClient = useQueryClient();
|
|
63
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
64
|
+
|
|
65
|
+
const { data, isLoading, isFetching, refetch } = useQuery({
|
|
66
|
+
queryKey: ["webhook-deliveries", projectId, webhook.id],
|
|
67
|
+
queryFn: () =>
|
|
68
|
+
fetchDataPlane<DeliveriesResponse>(
|
|
69
|
+
projectId,
|
|
70
|
+
`/v1/webhooks/${webhook.id}/deliveries?limit=50`,
|
|
71
|
+
),
|
|
72
|
+
// 5s auto-refresh while the panel is mounted. React Query pauses
|
|
73
|
+
// polling when the tab loses focus, which is what we want.
|
|
74
|
+
refetchInterval: 5000,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const redeliver = useMutation({
|
|
78
|
+
mutationFn: (deliveryId: string) =>
|
|
79
|
+
mutateDataPlane(
|
|
80
|
+
projectId,
|
|
81
|
+
`/v1/webhooks/${webhook.id}/deliveries/${deliveryId}/redeliver`,
|
|
82
|
+
{ method: "POST" },
|
|
83
|
+
),
|
|
84
|
+
onSuccess: () => {
|
|
85
|
+
queryClient.invalidateQueries({
|
|
86
|
+
queryKey: ["webhook-deliveries", projectId, webhook.id],
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const deliveries = data?.data ?? [];
|
|
92
|
+
|
|
93
|
+
function toggle(id: string) {
|
|
94
|
+
setExpanded((prev) => {
|
|
95
|
+
const next = new Set(prev);
|
|
96
|
+
if (next.has(id)) next.delete(id);
|
|
97
|
+
else next.add(id);
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-3">
|
|
104
|
+
<header className="flex items-start justify-between gap-4">
|
|
105
|
+
<div className="min-w-0">
|
|
106
|
+
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
107
|
+
Endpoint
|
|
108
|
+
</p>
|
|
109
|
+
<p className="font-mono text-sm break-all">{webhook.url}</p>
|
|
110
|
+
</div>
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => refetch()}
|
|
113
|
+
className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1 text-xs hover:border-foreground/30 transition-colors"
|
|
114
|
+
disabled={isFetching}
|
|
115
|
+
title="Refresh"
|
|
116
|
+
>
|
|
117
|
+
<RefreshCw
|
|
118
|
+
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
|
|
119
|
+
/>
|
|
120
|
+
Refresh
|
|
121
|
+
</button>
|
|
122
|
+
</header>
|
|
123
|
+
|
|
124
|
+
{isLoading ? (
|
|
125
|
+
<div className="rounded border border-border p-6 text-center text-sm text-muted-foreground">
|
|
126
|
+
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
|
127
|
+
</div>
|
|
128
|
+
) : deliveries.length === 0 ? (
|
|
129
|
+
<div className="rounded border border-border p-8 text-center text-sm text-muted-foreground">
|
|
130
|
+
No deliveries yet. Trigger an event to see attempts here.
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="rounded border border-border overflow-hidden">
|
|
134
|
+
<div className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto_auto_auto] items-center gap-3 border-b border-border bg-secondary/40 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
135
|
+
<span />
|
|
136
|
+
<span>Event</span>
|
|
137
|
+
<span>Status</span>
|
|
138
|
+
<span>Code</span>
|
|
139
|
+
<span>Latency</span>
|
|
140
|
+
<span />
|
|
141
|
+
</div>
|
|
142
|
+
{deliveries.map((d) => {
|
|
143
|
+
const isOpen = expanded.has(d.id);
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
key={d.id}
|
|
147
|
+
className="border-b border-border last:border-0"
|
|
148
|
+
>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => toggle(d.id)}
|
|
151
|
+
className="grid w-full grid-cols-[1.5rem_minmax(0,1fr)_auto_auto_auto_auto] items-center gap-3 px-3 py-2 text-left text-sm hover:bg-secondary/30 transition-colors"
|
|
152
|
+
>
|
|
153
|
+
{isOpen ? (
|
|
154
|
+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
155
|
+
) : (
|
|
156
|
+
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
157
|
+
)}
|
|
158
|
+
<div className="min-w-0">
|
|
159
|
+
<p className="font-mono text-xs truncate">{d.event}</p>
|
|
160
|
+
<p className="text-[11px] text-muted-foreground">
|
|
161
|
+
{new Date(d.created_at).toLocaleString()} · attempt{" "}
|
|
162
|
+
{d.attempt}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
<span
|
|
166
|
+
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_STYLES[d.status]}`}
|
|
167
|
+
>
|
|
168
|
+
{d.status}
|
|
169
|
+
</span>
|
|
170
|
+
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
|
171
|
+
{d.status_code ?? "—"}
|
|
172
|
+
</span>
|
|
173
|
+
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
|
174
|
+
{d.duration_ms != null ? `${d.duration_ms}ms` : "—"}
|
|
175
|
+
</span>
|
|
176
|
+
<span
|
|
177
|
+
role="button"
|
|
178
|
+
tabIndex={0}
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
redeliver.mutate(d.id);
|
|
182
|
+
}}
|
|
183
|
+
onKeyDown={(e) => {
|
|
184
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
redeliver.mutate(d.id);
|
|
188
|
+
}
|
|
189
|
+
}}
|
|
190
|
+
aria-disabled={redeliver.isPending}
|
|
191
|
+
className={`inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] cursor-pointer hover:border-foreground/30 transition-colors ${
|
|
192
|
+
redeliver.isPending ? "opacity-50 pointer-events-none" : ""
|
|
193
|
+
}`}
|
|
194
|
+
>
|
|
195
|
+
Redeliver
|
|
196
|
+
</span>
|
|
197
|
+
</button>
|
|
198
|
+
|
|
199
|
+
{isOpen && (
|
|
200
|
+
<div className="grid gap-3 border-t border-border bg-secondary/20 px-6 py-3 md:grid-cols-2">
|
|
201
|
+
<div>
|
|
202
|
+
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
|
203
|
+
Request payload
|
|
204
|
+
</p>
|
|
205
|
+
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-2 font-mono text-[11px]">
|
|
206
|
+
{JSON.stringify(d.payload, null, 2)}
|
|
207
|
+
</pre>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
|
211
|
+
Response
|
|
212
|
+
</p>
|
|
213
|
+
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap break-all">
|
|
214
|
+
{d.response_body ??
|
|
215
|
+
d.error ??
|
|
216
|
+
(d.status === "pending" ? "pending…" : "(empty)")}
|
|
217
|
+
</pre>
|
|
218
|
+
{d.next_retry_at && (
|
|
219
|
+
<p className="mt-1 text-[11px] text-muted-foreground">
|
|
220
|
+
Next retry:{" "}
|
|
221
|
+
{new Date(d.next_retry_at).toLocaleString()}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|