@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,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from "react";
|
|
4
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useParams, useRouter } from "next/navigation";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { RefreshCw, Search, Play, Loader2, Trash2, Workflow, AlertTriangle } from "lucide-react";
|
|
8
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogClose,
|
|
17
|
+
} from "#/components/ui/dialog";
|
|
18
|
+
|
|
19
|
+
interface PlaybookParam {
|
|
20
|
+
name: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
default?: unknown;
|
|
25
|
+
enum?: unknown[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Playbook {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
mission: {
|
|
32
|
+
prompt?: string;
|
|
33
|
+
tasks: { title: string; assignTo: string; description?: string; dependsOn?: string[] }[];
|
|
34
|
+
};
|
|
35
|
+
parameters?: PlaybookParam[];
|
|
36
|
+
version?: string;
|
|
37
|
+
author?: string;
|
|
38
|
+
tags?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function PlaybooksView() {
|
|
42
|
+
const { id } = useParams<{ id: string }>();
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
const [search, setSearch] = useState("");
|
|
46
|
+
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const polpo = usePolpoClient(id);
|
|
49
|
+
const { data: playbooks = [], isLoading, isFetching, refetch } = useQuery({
|
|
50
|
+
queryKey: ["playbooks", id],
|
|
51
|
+
queryFn: () => polpo.getPlaybooks() as unknown as Promise<Playbook[]>,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const deleteMutation = useMutation({
|
|
55
|
+
mutationFn: (name: string) => polpo.deletePlaybook(name),
|
|
56
|
+
onSuccess: () => {
|
|
57
|
+
queryClient.invalidateQueries({ queryKey: ["playbooks", id] });
|
|
58
|
+
setDeleteTarget(null);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const filtered = useMemo(() => {
|
|
63
|
+
if (!search) return playbooks;
|
|
64
|
+
const q = search.toLowerCase();
|
|
65
|
+
return playbooks.filter(p =>
|
|
66
|
+
p.name.toLowerCase().includes(q) ||
|
|
67
|
+
p.description?.toLowerCase().includes(q)
|
|
68
|
+
);
|
|
69
|
+
}, [playbooks, search]);
|
|
70
|
+
|
|
71
|
+
if (isLoading) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="space-y-3">
|
|
74
|
+
{[1, 2, 3].map(i => (
|
|
75
|
+
<div key={i} className="h-20 rounded-lg bg-secondary/30 animate-pulse" />
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div>
|
|
83
|
+
{/* Header */}
|
|
84
|
+
<div className="flex items-center justify-between">
|
|
85
|
+
<div>
|
|
86
|
+
<h2 className="text-lg font-semibold tracking-tight">Playbooks</h2>
|
|
87
|
+
<p className="mt-1 text-xs text-muted-foreground">{playbooks.length} total</p>
|
|
88
|
+
</div>
|
|
89
|
+
<button
|
|
90
|
+
onClick={() => refetch()}
|
|
91
|
+
disabled={isFetching}
|
|
92
|
+
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
93
|
+
>
|
|
94
|
+
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
|
95
|
+
Refresh
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Search */}
|
|
100
|
+
{playbooks.length > 0 && (
|
|
101
|
+
<div className="mt-3 relative">
|
|
102
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value={search}
|
|
106
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
107
|
+
placeholder="Search playbooks..."
|
|
108
|
+
className="h-8 w-full max-w-xs rounded-md border border-border bg-transparent pl-8 pr-3 text-xs focus:border-foreground/30 focus:outline-none transition-colors"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/* List */}
|
|
114
|
+
{filtered.length > 0 ? (
|
|
115
|
+
<div className="mt-4 space-y-2">
|
|
116
|
+
{filtered.map((playbook) => {
|
|
117
|
+
const params = playbook.parameters ?? [];
|
|
118
|
+
const taskCount = playbook.mission?.tasks?.length ?? 0;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
key={playbook.name}
|
|
123
|
+
className="border border-border bg-card px-4 py-3 hover:border-foreground/10 transition-colors"
|
|
124
|
+
>
|
|
125
|
+
<div className="flex items-start justify-between gap-3">
|
|
126
|
+
<div className="flex-1 min-w-0">
|
|
127
|
+
{/* Name + badges */}
|
|
128
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
129
|
+
<Workflow className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
130
|
+
<Link href={`/projects/${id}/playbooks/${encodeURIComponent(playbook.name)}`} className="font-mono text-sm font-semibold hover:underline underline-offset-2">{playbook.name}</Link>
|
|
131
|
+
<span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
|
132
|
+
{taskCount} task{taskCount !== 1 ? "s" : ""}
|
|
133
|
+
</span>
|
|
134
|
+
{params.length > 0 && (
|
|
135
|
+
<span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
|
136
|
+
{params.length} param{params.length !== 1 ? "s" : ""}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
{playbook.version && (
|
|
140
|
+
<span className="text-[10px] text-muted-foreground/50">v{playbook.version}</span>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
{/* Description */}
|
|
144
|
+
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
|
145
|
+
{playbook.description}
|
|
146
|
+
</p>
|
|
147
|
+
{/* Param tags */}
|
|
148
|
+
{params.length > 0 && (
|
|
149
|
+
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
|
150
|
+
{params.map(p => (
|
|
151
|
+
<span
|
|
152
|
+
key={p.name}
|
|
153
|
+
className="rounded border border-border px-1.5 py-0.5 text-[9px] font-mono text-muted-foreground"
|
|
154
|
+
>
|
|
155
|
+
{p.required && <span className="text-red-400 mr-0.5">*</span>}
|
|
156
|
+
{p.name}
|
|
157
|
+
{p.default !== undefined && (
|
|
158
|
+
<span className="text-muted-foreground/40">={String(p.default)}</span>
|
|
159
|
+
)}
|
|
160
|
+
</span>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{/* Tags */}
|
|
165
|
+
{playbook.tags && playbook.tags.length > 0 && (
|
|
166
|
+
<div className="mt-1.5 flex gap-1">
|
|
167
|
+
{playbook.tags.map(t => (
|
|
168
|
+
<span key={t} className="rounded bg-foreground/5 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
|
169
|
+
{t}
|
|
170
|
+
</span>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Actions */}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
) : playbooks.length > 0 ? (
|
|
183
|
+
<div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
|
|
184
|
+
No playbooks match your search.
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
|
|
188
|
+
<Workflow className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" />
|
|
189
|
+
No playbooks yet.
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{/* Delete dialog */}
|
|
193
|
+
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
|
194
|
+
<DialogContent showCloseButton={false}>
|
|
195
|
+
<DialogHeader>
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<AlertTriangle className="h-4 w-4 text-destructive" />
|
|
198
|
+
<DialogTitle>Delete playbook</DialogTitle>
|
|
199
|
+
</div>
|
|
200
|
+
<DialogDescription>
|
|
201
|
+
Are you sure you want to delete <span className="font-mono font-medium text-foreground">{deleteTarget}</span>? This cannot be undone.
|
|
202
|
+
</DialogDescription>
|
|
203
|
+
</DialogHeader>
|
|
204
|
+
<DialogFooter>
|
|
205
|
+
<DialogClose
|
|
206
|
+
render={<button className="px-4 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" />}
|
|
207
|
+
>
|
|
208
|
+
Cancel
|
|
209
|
+
</DialogClose>
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
|
212
|
+
disabled={deleteMutation.isPending}
|
|
213
|
+
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-50 rounded-md"
|
|
214
|
+
>
|
|
215
|
+
{deleteMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : "Delete"}
|
|
216
|
+
</button>
|
|
217
|
+
</DialogFooter>
|
|
218
|
+
</DialogContent>
|
|
219
|
+
</Dialog>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
2
|
+
import { dataApi } from "#/lib/api";
|
|
3
|
+
import type { Mission, ScheduleEntry } from "@polpo-ai/core";
|
|
4
|
+
import { SchedulesListSkeleton } from "#/components/dashboard/skeletons";
|
|
5
|
+
import SchedulesView from "./view";
|
|
6
|
+
|
|
7
|
+
async function SchedulesData({ id }: { id: string }) {
|
|
8
|
+
// Fetch schedules + missions in parallel so the join in the view is
|
|
9
|
+
// hydrated on first paint. Both endpoints are cheap (no per-row joins
|
|
10
|
+
// in the data plane), so the parallel cost is the slowest of the two.
|
|
11
|
+
let schedules: ScheduleEntry[] = [];
|
|
12
|
+
let missions: Mission[] = [];
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const [schedRes, missionRes] = await Promise.all([
|
|
16
|
+
dataApi<{ ok: boolean; data: ScheduleEntry[] }>(id, "/v1/schedules").catch(
|
|
17
|
+
() => ({ ok: false, data: [] as ScheduleEntry[] }),
|
|
18
|
+
),
|
|
19
|
+
dataApi<{ ok: boolean; data: Mission[] }>(id, "/v1/missions").catch(
|
|
20
|
+
() => ({ ok: false, data: [] as Mission[] }),
|
|
21
|
+
),
|
|
22
|
+
]);
|
|
23
|
+
schedules = schedRes.data ?? [];
|
|
24
|
+
missions = missionRes.data ?? [];
|
|
25
|
+
} catch {
|
|
26
|
+
// Fail open — render empty state rather than 500.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return <SchedulesView initialSchedules={schedules} initialMissions={missions} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default async function SchedulesPage({
|
|
33
|
+
params,
|
|
34
|
+
}: {
|
|
35
|
+
params: Promise<{ id: string }>;
|
|
36
|
+
}) {
|
|
37
|
+
const { id } = await params;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Suspense fallback={<SchedulesListSkeleton />}>
|
|
41
|
+
<SchedulesData id={id} />
|
|
42
|
+
</Suspense>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useParams } from "next/navigation";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import {
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Clock,
|
|
10
|
+
Loader2,
|
|
11
|
+
Pause,
|
|
12
|
+
Play,
|
|
13
|
+
Pencil,
|
|
14
|
+
Trash2,
|
|
15
|
+
AlertTriangle,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import type { Mission, ScheduleEntry, UpdateScheduleRequest } from "@polpo-ai/sdk";
|
|
18
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DialogDescription,
|
|
25
|
+
} from "#/components/ui/dialog";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Schedules — client + pure-dogfood list of the project's active schedules.
|
|
29
|
+
*
|
|
30
|
+
* Schedules are mission-bound: an entry exists for every mission with a
|
|
31
|
+
* `schedule` set. Reads + inline actions all go through the published SDK
|
|
32
|
+
* (`usePolpoClient`): `getSchedules`/`getMissions` for the list, and
|
|
33
|
+
* `updateSchedule`/`deleteSchedule` (keyed by mission id) for pause/resume,
|
|
34
|
+
* edit-cron and delete. No raw `dataApi`/`api`. A schedule has no standalone
|
|
35
|
+
* detail (it's a cron on a mission), so rows link to the mission's page.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const typeBadge: Record<string, { label: string; className: string }> = {
|
|
39
|
+
recurring: { label: "Recurring", className: "bg-blue-500/10 text-blue-400" },
|
|
40
|
+
"one-shot": { label: "One-shot", className: "bg-amber-500/10 text-amber-400" },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ScheduleRow = ScheduleEntry & {
|
|
44
|
+
missionName?: string;
|
|
45
|
+
executionCount?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function relativeTime(date?: string, mode: "past" | "future" = "past"): string {
|
|
49
|
+
if (!date) return "—";
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const target = new Date(date).getTime();
|
|
52
|
+
const diff = mode === "future" ? target - now : now - target;
|
|
53
|
+
if (diff < 0) return mode === "future" ? "due" : "—";
|
|
54
|
+
const min = Math.floor(diff / 60_000);
|
|
55
|
+
const prefix = mode === "future" ? "in " : "";
|
|
56
|
+
const suffix = mode === "past" ? " ago" : "";
|
|
57
|
+
if (min < 1) return mode === "future" ? "<1m" : "just now";
|
|
58
|
+
if (min < 60) return `${prefix}${min}m${suffix}`;
|
|
59
|
+
const hours = Math.floor(min / 60);
|
|
60
|
+
if (hours < 24) return `${prefix}${hours}h${suffix}`;
|
|
61
|
+
const days = Math.floor(hours / 24);
|
|
62
|
+
if (days < 30) return `${prefix}${days}d${suffix}`;
|
|
63
|
+
return new Date(date).toLocaleDateString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isoTooltip(date?: string): string {
|
|
67
|
+
return date ? new Date(date).toISOString() : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function SchedulesView({
|
|
71
|
+
initialSchedules,
|
|
72
|
+
initialMissions,
|
|
73
|
+
}: {
|
|
74
|
+
initialSchedules: ScheduleEntry[];
|
|
75
|
+
initialMissions: Mission[];
|
|
76
|
+
}) {
|
|
77
|
+
const { id } = useParams<{ id: string }>();
|
|
78
|
+
const queryClient = useQueryClient();
|
|
79
|
+
const polpo = usePolpoClient(id);
|
|
80
|
+
|
|
81
|
+
const { data: schedules = [], isFetching, refetch } = useQuery({
|
|
82
|
+
queryKey: ["schedules", id],
|
|
83
|
+
queryFn: () => polpo.getSchedules(),
|
|
84
|
+
initialData: initialSchedules,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { data: missions = [] } = useQuery({
|
|
88
|
+
queryKey: ["missions", id],
|
|
89
|
+
queryFn: () => polpo.getMissions(),
|
|
90
|
+
initialData: initialMissions,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const missionById = useMemo(
|
|
94
|
+
() => new Map(missions.map((m) => [m.id, m])),
|
|
95
|
+
[missions],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const rows: ScheduleRow[] = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
schedules.map((s) => {
|
|
101
|
+
const mission = missionById.get(s.missionId);
|
|
102
|
+
return { ...s, missionName: mission?.name, executionCount: mission?.executionCount };
|
|
103
|
+
}),
|
|
104
|
+
[schedules, missionById],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// ── Mutations (dogfood SDK) — keyed by mission id, per the SDK signature.
|
|
108
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["schedules", id] });
|
|
109
|
+
|
|
110
|
+
const updateMutation = useMutation({
|
|
111
|
+
mutationFn: ({ missionId, patch }: { missionId: string; patch: UpdateScheduleRequest }) =>
|
|
112
|
+
polpo.updateSchedule(missionId, patch),
|
|
113
|
+
onSuccess: invalidate,
|
|
114
|
+
});
|
|
115
|
+
const deleteMutation = useMutation({
|
|
116
|
+
mutationFn: (missionId: string) => polpo.deleteSchedule(missionId),
|
|
117
|
+
onSuccess: () => {
|
|
118
|
+
invalidate();
|
|
119
|
+
setDeleteTarget(null);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const [editTarget, setEditTarget] = useState<ScheduleRow | null>(null);
|
|
124
|
+
const [editExpression, setEditExpression] = useState("");
|
|
125
|
+
const [editRecurring, setEditRecurring] = useState(true);
|
|
126
|
+
const [deleteTarget, setDeleteTarget] = useState<ScheduleRow | null>(null);
|
|
127
|
+
const [togglingId, setTogglingId] = useState<string | null>(null);
|
|
128
|
+
|
|
129
|
+
function openEdit(row: ScheduleRow) {
|
|
130
|
+
setEditTarget(row);
|
|
131
|
+
setEditExpression(row.expression);
|
|
132
|
+
setEditRecurring(row.recurring);
|
|
133
|
+
}
|
|
134
|
+
function saveEdit() {
|
|
135
|
+
if (!editTarget) return;
|
|
136
|
+
updateMutation.mutate(
|
|
137
|
+
{ missionId: editTarget.missionId, patch: { expression: editExpression.trim(), recurring: editRecurring } },
|
|
138
|
+
{ onSuccess: () => setEditTarget(null) },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function toggleEnabled(row: ScheduleRow) {
|
|
142
|
+
setTogglingId(row.id);
|
|
143
|
+
updateMutation.mutate(
|
|
144
|
+
{ missionId: row.missionId, patch: { enabled: !row.enabled } },
|
|
145
|
+
{ onSettled: () => setTogglingId(null) },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
<div className="flex items-center justify-between">
|
|
152
|
+
<div>
|
|
153
|
+
<h2 className="text-lg font-semibold tracking-tight">Schedules</h2>
|
|
154
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
155
|
+
{schedules.length} active {schedules.length === 1 ? "schedule" : "schedules"}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => refetch()}
|
|
160
|
+
disabled={isFetching}
|
|
161
|
+
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
162
|
+
>
|
|
163
|
+
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
|
164
|
+
Refresh
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{rows.length > 0 ? (
|
|
169
|
+
<div className="mt-4 border border-border overflow-hidden overflow-x-auto">
|
|
170
|
+
<table className="w-full text-sm min-w-[860px]">
|
|
171
|
+
<thead>
|
|
172
|
+
<tr className="border-b border-border bg-secondary/50">
|
|
173
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-20">Status</th>
|
|
174
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Mission</th>
|
|
175
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-24">Type</th>
|
|
176
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-32">Expression</th>
|
|
177
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-28">Next run</th>
|
|
178
|
+
<th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-16">Runs</th>
|
|
179
|
+
<th className="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground w-28">Actions</th>
|
|
180
|
+
</tr>
|
|
181
|
+
</thead>
|
|
182
|
+
<tbody>
|
|
183
|
+
{rows.map((row) => {
|
|
184
|
+
const type = row.recurring ? "recurring" : "one-shot";
|
|
185
|
+
const badge = typeBadge[type];
|
|
186
|
+
const isOrphan = !row.missionName;
|
|
187
|
+
const isToggling = togglingId === row.id;
|
|
188
|
+
return (
|
|
189
|
+
<tr
|
|
190
|
+
key={row.id}
|
|
191
|
+
className="border-b border-border last:border-0 hover:bg-secondary/30 transition-colors"
|
|
192
|
+
>
|
|
193
|
+
<td className="px-4 py-3 w-20">
|
|
194
|
+
<span className="inline-flex items-center gap-1.5">
|
|
195
|
+
<span
|
|
196
|
+
className={`h-2 w-2 rounded-full ${row.enabled ? "bg-brand" : "bg-muted-foreground/30"}`}
|
|
197
|
+
/>
|
|
198
|
+
<span className="text-xs text-muted-foreground">
|
|
199
|
+
{row.enabled ? "Active" : "Paused"}
|
|
200
|
+
</span>
|
|
201
|
+
</span>
|
|
202
|
+
</td>
|
|
203
|
+
<td className="px-4 py-3">
|
|
204
|
+
{isOrphan ? (
|
|
205
|
+
<span className="font-mono text-xs italic text-muted-foreground">
|
|
206
|
+
mission {row.missionId.slice(0, 8)}… (deleted)
|
|
207
|
+
</span>
|
|
208
|
+
) : (
|
|
209
|
+
<Link
|
|
210
|
+
href={`/projects/${id}/missions/${row.missionId}`}
|
|
211
|
+
className="font-mono text-xs font-medium hover:underline underline-offset-2"
|
|
212
|
+
>
|
|
213
|
+
{row.missionName}
|
|
214
|
+
</Link>
|
|
215
|
+
)}
|
|
216
|
+
</td>
|
|
217
|
+
<td className="px-4 py-3 w-24">
|
|
218
|
+
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}>
|
|
219
|
+
{badge.label}
|
|
220
|
+
</span>
|
|
221
|
+
</td>
|
|
222
|
+
<td className="px-4 py-3 w-32 font-mono text-xs text-muted-foreground">{row.expression}</td>
|
|
223
|
+
<td
|
|
224
|
+
className="px-4 py-3 w-28 text-xs text-muted-foreground"
|
|
225
|
+
title={isoTooltip(row.nextRunAt)}
|
|
226
|
+
>
|
|
227
|
+
{relativeTime(row.nextRunAt, "future")}
|
|
228
|
+
</td>
|
|
229
|
+
<td className="px-4 py-3 w-16 text-xs text-muted-foreground">{row.executionCount ?? 0}</td>
|
|
230
|
+
<td className="px-4 py-3 w-28">
|
|
231
|
+
<div className="flex items-center justify-end gap-1">
|
|
232
|
+
<button
|
|
233
|
+
onClick={() => toggleEnabled(row)}
|
|
234
|
+
disabled={isToggling}
|
|
235
|
+
title={row.enabled ? "Pause schedule" : "Resume schedule"}
|
|
236
|
+
className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground disabled:opacity-50"
|
|
237
|
+
>
|
|
238
|
+
{isToggling ? (
|
|
239
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
240
|
+
) : row.enabled ? (
|
|
241
|
+
<Pause className="h-3.5 w-3.5" />
|
|
242
|
+
) : (
|
|
243
|
+
<Play className="h-3.5 w-3.5" />
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => openEdit(row)}
|
|
248
|
+
title="Edit cron expression"
|
|
249
|
+
className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
|
|
250
|
+
>
|
|
251
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
252
|
+
</button>
|
|
253
|
+
<button
|
|
254
|
+
onClick={() => setDeleteTarget(row)}
|
|
255
|
+
title="Delete schedule"
|
|
256
|
+
className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive"
|
|
257
|
+
>
|
|
258
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</td>
|
|
262
|
+
</tr>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</tbody>
|
|
266
|
+
</table>
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
<EmptyState />
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{/* Edit-cron dialog */}
|
|
273
|
+
<Dialog open={!!editTarget} onOpenChange={(open) => !open && setEditTarget(null)}>
|
|
274
|
+
<DialogContent className="sm:max-w-md">
|
|
275
|
+
<DialogHeader>
|
|
276
|
+
<DialogTitle>Edit schedule</DialogTitle>
|
|
277
|
+
<DialogDescription>
|
|
278
|
+
{editTarget?.missionName
|
|
279
|
+
? `Cron + recurrence for "${editTarget.missionName}".`
|
|
280
|
+
: "Update the cron expression and recurrence for this schedule."}
|
|
281
|
+
</DialogDescription>
|
|
282
|
+
</DialogHeader>
|
|
283
|
+
<div className="space-y-3 pt-1">
|
|
284
|
+
<div>
|
|
285
|
+
<label className="mb-1 block font-mono text-[10px] font-bold uppercase tracking-[0.14em] text-muted-foreground/60">
|
|
286
|
+
Cron expression
|
|
287
|
+
</label>
|
|
288
|
+
<input
|
|
289
|
+
value={editExpression}
|
|
290
|
+
onChange={(e) => setEditExpression(e.target.value)}
|
|
291
|
+
placeholder="0 9 * * *"
|
|
292
|
+
className="h-8 w-full border border-border bg-transparent px-3 font-mono text-xs placeholder:text-muted-foreground/30 focus:border-foreground/30 focus:outline-none"
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
296
|
+
<input
|
|
297
|
+
type="checkbox"
|
|
298
|
+
checked={editRecurring}
|
|
299
|
+
onChange={(e) => setEditRecurring(e.target.checked)}
|
|
300
|
+
className="h-3.5 w-3.5 accent-foreground"
|
|
301
|
+
/>
|
|
302
|
+
Recurring (re-run on every cron tick)
|
|
303
|
+
</label>
|
|
304
|
+
<div className="flex items-center justify-end gap-2 pt-1">
|
|
305
|
+
{updateMutation.isError && (
|
|
306
|
+
<span className="mr-auto text-[10px] text-destructive">Failed — check the expression.</span>
|
|
307
|
+
)}
|
|
308
|
+
<button
|
|
309
|
+
onClick={() => setEditTarget(null)}
|
|
310
|
+
className="px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
311
|
+
>
|
|
312
|
+
Cancel
|
|
313
|
+
</button>
|
|
314
|
+
<button
|
|
315
|
+
onClick={saveEdit}
|
|
316
|
+
disabled={!editExpression.trim() || updateMutation.isPending}
|
|
317
|
+
className="inline-flex items-center gap-1.5 border border-foreground bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-40"
|
|
318
|
+
>
|
|
319
|
+
{updateMutation.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
320
|
+
Save
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</DialogContent>
|
|
325
|
+
</Dialog>
|
|
326
|
+
|
|
327
|
+
{/* Delete-confirm dialog */}
|
|
328
|
+
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
329
|
+
<DialogContent className="sm:max-w-md">
|
|
330
|
+
<DialogHeader>
|
|
331
|
+
<DialogTitle className="flex items-center gap-2">
|
|
332
|
+
<AlertTriangle className="h-4 w-4 text-destructive" strokeWidth={1.5} />
|
|
333
|
+
Delete schedule
|
|
334
|
+
</DialogTitle>
|
|
335
|
+
<DialogDescription>
|
|
336
|
+
Remove the schedule for{" "}
|
|
337
|
+
<span className="font-mono font-medium text-foreground">
|
|
338
|
+
{deleteTarget?.missionName ?? `mission ${deleteTarget?.missionId.slice(0, 8)}…`}
|
|
339
|
+
</span>
|
|
340
|
+
? The mission itself is kept — only the cron is removed.
|
|
341
|
+
</DialogDescription>
|
|
342
|
+
</DialogHeader>
|
|
343
|
+
<div className="flex items-center justify-end gap-2 pt-1">
|
|
344
|
+
{deleteMutation.isError && (
|
|
345
|
+
<span className="mr-auto text-[10px] text-destructive">Failed to delete.</span>
|
|
346
|
+
)}
|
|
347
|
+
<button
|
|
348
|
+
onClick={() => setDeleteTarget(null)}
|
|
349
|
+
className="px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
350
|
+
>
|
|
351
|
+
Cancel
|
|
352
|
+
</button>
|
|
353
|
+
<button
|
|
354
|
+
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.missionId)}
|
|
355
|
+
disabled={deleteMutation.isPending}
|
|
356
|
+
className="inline-flex items-center gap-1.5 border border-destructive bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
357
|
+
>
|
|
358
|
+
{deleteMutation.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
359
|
+
Delete
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
</DialogContent>
|
|
363
|
+
</Dialog>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function EmptyState() {
|
|
369
|
+
return (
|
|
370
|
+
<div className="mt-4 border border-border p-10 text-center">
|
|
371
|
+
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-secondary/50">
|
|
372
|
+
<Clock className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
|
|
373
|
+
</div>
|
|
374
|
+
<h3 className="text-sm font-medium">No schedules yet</h3>
|
|
375
|
+
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-muted-foreground">
|
|
376
|
+
Schedule a mission to run automatically on a cron expression or a one-shot date.
|
|
377
|
+
Currently supported: <span className="font-medium text-foreground">missions</span>.
|
|
378
|
+
Coming soon: scheduled completions and channel delivery.
|
|
379
|
+
</p>
|
|
380
|
+
<p className="mt-3 text-[11px] text-muted-foreground/60">
|
|
381
|
+
Create via <code className="rounded bg-secondary/50 px-1 py-0.5 font-mono">POST /v1/schedules</code>
|
|
382
|
+
</p>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|