thepopebot 1.1.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +1 -1
  2. package/api/index.js +72 -165
  3. package/bin/cli.js +36 -6
  4. package/bin/local.sh +31 -0
  5. package/bin/postinstall.js +6 -2
  6. package/config/index.js +2 -11
  7. package/config/instrumentation.js +17 -5
  8. package/lib/actions.js +7 -6
  9. package/lib/ai/agent.js +36 -0
  10. package/lib/ai/index.js +274 -0
  11. package/lib/ai/model.js +67 -0
  12. package/lib/ai/tools.js +49 -0
  13. package/lib/auth/actions.js +28 -0
  14. package/lib/auth/config.js +45 -0
  15. package/lib/auth/index.js +27 -0
  16. package/lib/auth/middleware.js +30 -0
  17. package/lib/channels/base.js +56 -0
  18. package/lib/channels/index.js +15 -0
  19. package/lib/channels/telegram.js +146 -0
  20. package/lib/chat/actions.js +239 -0
  21. package/lib/chat/api.js +103 -0
  22. package/lib/chat/components/app-sidebar.js +161 -0
  23. package/lib/chat/components/app-sidebar.jsx +214 -0
  24. package/lib/chat/components/chat-header.js +9 -0
  25. package/lib/chat/components/chat-header.jsx +14 -0
  26. package/lib/chat/components/chat-input.js +230 -0
  27. package/lib/chat/components/chat-input.jsx +232 -0
  28. package/lib/chat/components/chat-nav-context.js +11 -0
  29. package/lib/chat/components/chat-nav-context.jsx +11 -0
  30. package/lib/chat/components/chat-page.js +70 -0
  31. package/lib/chat/components/chat-page.jsx +89 -0
  32. package/lib/chat/components/chat.js +78 -0
  33. package/lib/chat/components/chat.jsx +91 -0
  34. package/lib/chat/components/chats-page.js +170 -0
  35. package/lib/chat/components/chats-page.jsx +203 -0
  36. package/lib/chat/components/crons-page.js +144 -0
  37. package/lib/chat/components/crons-page.jsx +204 -0
  38. package/lib/chat/components/greeting.js +11 -0
  39. package/lib/chat/components/greeting.jsx +14 -0
  40. package/lib/chat/components/icons.js +518 -0
  41. package/lib/chat/components/icons.jsx +482 -0
  42. package/lib/chat/components/index.js +19 -0
  43. package/lib/chat/components/message.js +66 -0
  44. package/lib/chat/components/message.jsx +92 -0
  45. package/lib/chat/components/messages.js +63 -0
  46. package/lib/chat/components/messages.jsx +72 -0
  47. package/lib/chat/components/notifications-page.js +54 -0
  48. package/lib/chat/components/notifications-page.jsx +83 -0
  49. package/lib/chat/components/page-layout.js +21 -0
  50. package/lib/chat/components/page-layout.jsx +28 -0
  51. package/lib/chat/components/settings-layout.js +37 -0
  52. package/lib/chat/components/settings-layout.jsx +51 -0
  53. package/lib/chat/components/settings-secrets-page.js +216 -0
  54. package/lib/chat/components/settings-secrets-page.jsx +264 -0
  55. package/lib/chat/components/sidebar-history-item.js +54 -0
  56. package/lib/chat/components/sidebar-history-item.jsx +50 -0
  57. package/lib/chat/components/sidebar-history.js +92 -0
  58. package/lib/chat/components/sidebar-history.jsx +132 -0
  59. package/lib/chat/components/sidebar-user-nav.js +59 -0
  60. package/lib/chat/components/sidebar-user-nav.jsx +69 -0
  61. package/lib/chat/components/swarm-page.js +250 -0
  62. package/lib/chat/components/swarm-page.jsx +356 -0
  63. package/lib/chat/components/triggers-page.js +121 -0
  64. package/lib/chat/components/triggers-page.jsx +177 -0
  65. package/lib/chat/components/ui/dropdown-menu.js +98 -0
  66. package/lib/chat/components/ui/dropdown-menu.jsx +116 -0
  67. package/lib/chat/components/ui/scroll-area.js +13 -0
  68. package/lib/chat/components/ui/scroll-area.jsx +17 -0
  69. package/lib/chat/components/ui/separator.js +21 -0
  70. package/lib/chat/components/ui/separator.jsx +18 -0
  71. package/lib/chat/components/ui/sheet.js +75 -0
  72. package/lib/chat/components/ui/sheet.jsx +95 -0
  73. package/lib/chat/components/ui/sidebar.js +227 -0
  74. package/lib/chat/components/ui/sidebar.jsx +245 -0
  75. package/lib/chat/components/ui/tooltip.js +56 -0
  76. package/lib/chat/components/ui/tooltip.jsx +66 -0
  77. package/lib/chat/utils.js +11 -0
  78. package/lib/cron.js +7 -8
  79. package/lib/db/api-keys.js +160 -0
  80. package/lib/db/chats.js +129 -0
  81. package/lib/db/index.js +106 -0
  82. package/lib/db/notifications.js +99 -0
  83. package/lib/db/schema.js +51 -0
  84. package/lib/db/users.js +89 -0
  85. package/lib/paths.js +23 -17
  86. package/lib/tools/create-job.js +3 -3
  87. package/lib/tools/github.js +145 -1
  88. package/lib/tools/openai.js +1 -1
  89. package/lib/tools/telegram.js +4 -3
  90. package/lib/triggers.js +6 -7
  91. package/lib/utils/render-md.js +6 -6
  92. package/package.json +43 -6
  93. package/setup/lib/auth.mjs +22 -9
  94. package/setup/lib/prerequisites.mjs +10 -3
  95. package/setup/lib/telegram-verify.mjs +3 -16
  96. package/setup/setup-telegram.mjs +31 -62
  97. package/setup/setup.mjs +58 -98
  98. package/templates/.dockerignore +5 -0
  99. package/templates/.env.example +18 -2
  100. package/templates/.github/workflows/auto-merge.yml +1 -1
  101. package/templates/.github/workflows/build-image.yml +6 -4
  102. package/templates/.github/workflows/notify-job-failed.yml +2 -2
  103. package/templates/.github/workflows/notify-pr-complete.yml +2 -2
  104. package/templates/.github/workflows/run-job.yml +24 -10
  105. package/templates/CLAUDE.md +5 -3
  106. package/templates/app/api/auth/[...nextauth]/route.js +1 -0
  107. package/templates/app/api/chat/route.js +1 -0
  108. package/templates/app/chat/[chatId]/page.js +8 -0
  109. package/templates/app/chats/page.js +7 -0
  110. package/templates/app/components/ascii-logo.jsx +10 -0
  111. package/templates/app/components/login-form.jsx +81 -0
  112. package/templates/app/components/setup-form.jsx +82 -0
  113. package/templates/app/components/theme-provider.jsx +11 -0
  114. package/templates/app/components/theme-toggle.jsx +38 -0
  115. package/templates/app/components/ui/button.jsx +21 -0
  116. package/templates/app/components/ui/card.jsx +23 -0
  117. package/templates/app/components/ui/input.jsx +10 -0
  118. package/templates/app/components/ui/label.jsx +10 -0
  119. package/templates/app/crons/page.js +7 -0
  120. package/templates/app/globals.css +66 -0
  121. package/templates/app/layout.js +9 -2
  122. package/templates/app/login/page.js +15 -0
  123. package/templates/app/notifications/page.js +7 -0
  124. package/templates/app/page.js +6 -30
  125. package/templates/app/settings/layout.js +7 -0
  126. package/templates/app/settings/page.js +5 -0
  127. package/templates/app/settings/secrets/page.js +5 -0
  128. package/templates/app/swarm/page.js +7 -0
  129. package/templates/app/triggers/page.js +7 -0
  130. package/templates/config/CRONS.json +2 -2
  131. package/templates/config/TRIGGERS.json +2 -2
  132. package/templates/docker/event_handler/Dockerfile +19 -0
  133. package/templates/docker/{entrypoint.sh → job/entrypoint.sh} +4 -4
  134. package/templates/docker/runner/Dockerfile +38 -0
  135. package/templates/docker/runner/entrypoint.sh +41 -0
  136. package/templates/docker-compose.yml +52 -0
  137. package/templates/instrumentation.js +6 -1
  138. package/templates/middleware.js +1 -0
  139. package/templates/postcss.config.mjs +5 -0
  140. package/lib/claude/conversation.js +0 -76
  141. package/lib/claude/index.js +0 -142
  142. package/lib/claude/tools.js +0 -54
  143. /package/templates/docker/{Dockerfile → job/Dockerfile} +0 -0
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { signOut } from 'next-auth/react';
4
+ import { useTheme } from 'next-themes';
5
+ import { useEffect, useState } from 'react';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from './ui/dropdown-menu.js';
13
+ import { SidebarMenu, SidebarMenuItem, SidebarMenuButton } from './ui/sidebar.js';
14
+ import { SettingsIcon, SunIcon, MoonIcon, LogOutIcon } from './icons.js';
15
+ import { cn } from '../utils.js';
16
+
17
+ export function SidebarUserNav({ user, collapsed }) {
18
+ const { theme, setTheme } = useTheme();
19
+ const [mounted, setMounted] = useState(false);
20
+
21
+ useEffect(() => setMounted(true), []);
22
+
23
+ return (
24
+ <SidebarMenu>
25
+ <SidebarMenuItem>
26
+ <DropdownMenu>
27
+ <DropdownMenuTrigger asChild>
28
+ <SidebarMenuButton className={cn(collapsed ? 'justify-center' : 'justify-between')}>
29
+ <div className={cn('flex items-center overflow-hidden', collapsed ? '' : 'gap-2')}>
30
+ <div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
31
+ {(user?.email?.[0] || 'U').toUpperCase()}
32
+ </div>
33
+ {!collapsed && (
34
+ <span className="truncate text-sm">{user?.email || 'User'}</span>
35
+ )}
36
+ </div>
37
+ {!collapsed && (
38
+ <svg className="size-4 text-muted-foreground" width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
39
+ <path d="m7 15 5 5 5-5" />
40
+ <path d="m7 9 5-5 5 5" />
41
+ </svg>
42
+ )}
43
+ </SidebarMenuButton>
44
+ </DropdownMenuTrigger>
45
+ <DropdownMenuContent align="start" side="top" className="w-56">
46
+ <DropdownMenuItem onClick={() => { window.location.href = '/settings'; }}>
47
+ <SettingsIcon size={14} />
48
+ <span className="ml-2">Settings</span>
49
+ </DropdownMenuItem>
50
+ {mounted && (
51
+ <DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
52
+ {theme === 'dark' ? <SunIcon size={14} /> : <MoonIcon size={14} />}
53
+ <span className="ml-2">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
54
+ </DropdownMenuItem>
55
+ )}
56
+ <DropdownMenuSeparator />
57
+ <DropdownMenuItem
58
+ onClick={() => signOut({ callbackUrl: '/' })}
59
+ className="text-destructive"
60
+ >
61
+ <LogOutIcon size={14} />
62
+ <span className="ml-2">Sign Out</span>
63
+ </DropdownMenuItem>
64
+ </DropdownMenuContent>
65
+ </DropdownMenu>
66
+ </SidebarMenuItem>
67
+ </SidebarMenu>
68
+ );
69
+ }
@@ -0,0 +1,250 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { PageLayout } from "./page-layout.js";
5
+ import { StopIcon, SpinnerIcon, RefreshIcon } from "./icons.js";
6
+ import { getSwarmStatus, cancelSwarmJob, rerunSwarmJob } from "../actions.js";
7
+ function formatDuration(seconds) {
8
+ if (seconds < 60) return `${seconds}s`;
9
+ const minutes = Math.floor(seconds / 60);
10
+ const secs = seconds % 60;
11
+ if (minutes < 60) return `${minutes}m ${secs}s`;
12
+ const hours = Math.floor(minutes / 60);
13
+ const mins = minutes % 60;
14
+ return `${hours}h ${mins}m`;
15
+ }
16
+ function timeAgo(timestamp) {
17
+ const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1e3);
18
+ if (seconds < 60) return "just now";
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60) return `${minutes}m ago`;
21
+ const hours = Math.floor(minutes / 60);
22
+ if (hours < 24) return `${hours}h ago`;
23
+ const days = Math.floor(hours / 24);
24
+ if (days < 30) return `${days}d ago`;
25
+ const months = Math.floor(days / 30);
26
+ return `${months}mo ago`;
27
+ }
28
+ function LoadingSkeleton() {
29
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4", children: [
30
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 md:grid-cols-4 gap-3", children: [...Array(4)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-20 animate-pulse rounded-md bg-border/50" }, i)) }),
31
+ /* @__PURE__ */ jsx("div", { className: "h-8 w-32 animate-pulse rounded-md bg-border/50" }),
32
+ [...Array(3)].map((_, i) => /* @__PURE__ */ jsx("div", { className: "h-24 animate-pulse rounded-md bg-border/50" }, i))
33
+ ] });
34
+ }
35
+ function SwarmSummaryCards({ counts }) {
36
+ const cards = [
37
+ { label: "Running", value: counts.running, color: "border-l-green-500", text: "text-green-500" },
38
+ { label: "Queued", value: counts.queued, color: "border-l-yellow-500", text: "text-yellow-500" },
39
+ { label: "Succeeded", value: counts.succeeded, color: "border-l-blue-500", text: "text-blue-500" },
40
+ { label: "Failed", value: counts.failed, color: "border-l-red-500", text: "text-red-500" }
41
+ ];
42
+ return /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 md:grid-cols-4 gap-3", children: cards.map((card) => /* @__PURE__ */ jsxs(
43
+ "div",
44
+ {
45
+ className: `rounded-md border border-l-4 ${card.color} bg-card p-4`,
46
+ children: [
47
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground uppercase tracking-wide", children: card.label }),
48
+ /* @__PURE__ */ jsx("p", { className: `text-2xl font-bold mt-1 ${card.text}`, children: card.value })
49
+ ]
50
+ },
51
+ card.label
52
+ )) });
53
+ }
54
+ function SwarmActiveJobs({ jobs, onCancel }) {
55
+ if (!jobs || jobs.length === 0) {
56
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground py-4 text-center", children: "No active jobs." });
57
+ }
58
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3", children: jobs.map((job) => {
59
+ const isRunning = job.status === "in_progress";
60
+ const progress = job.steps_total > 0 ? Math.round(job.steps_completed / job.steps_total * 100) : 0;
61
+ return /* @__PURE__ */ jsxs("div", { className: "rounded-md border bg-card p-4", children: [
62
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-2", children: [
63
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
64
+ /* @__PURE__ */ jsx(
65
+ "span",
66
+ {
67
+ className: `inline-block h-2.5 w-2.5 rounded-full ${isRunning ? "bg-green-500 animate-pulse" : "bg-yellow-500"}`
68
+ }
69
+ ),
70
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-sm font-medium", children: job.job_id.slice(0, 8) }),
71
+ job.workflow_name && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: job.workflow_name })
72
+ ] }),
73
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
74
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: formatDuration(job.duration_seconds) }),
75
+ job.html_url && /* @__PURE__ */ jsx(
76
+ "a",
77
+ {
78
+ href: job.html_url,
79
+ target: "_blank",
80
+ rel: "noopener noreferrer",
81
+ className: "text-xs text-blue-500 hover:underline",
82
+ children: "View"
83
+ }
84
+ ),
85
+ /* @__PURE__ */ jsx(
86
+ "button",
87
+ {
88
+ onClick: () => onCancel(job.run_id),
89
+ className: "inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
90
+ title: "Cancel job",
91
+ children: /* @__PURE__ */ jsx(StopIcon, { size: 14 })
92
+ }
93
+ )
94
+ ] })
95
+ ] }),
96
+ job.current_step && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-2 truncate", children: job.current_step }),
97
+ job.steps_total > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
98
+ /* @__PURE__ */ jsx("div", { className: "flex-1 h-1.5 rounded-full bg-border overflow-hidden", children: /* @__PURE__ */ jsx(
99
+ "div",
100
+ {
101
+ className: "h-full rounded-full bg-green-500 transition-all duration-500",
102
+ style: { width: `${progress}%` }
103
+ }
104
+ ) }),
105
+ /* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground shrink-0", children: [
106
+ job.steps_completed,
107
+ "/",
108
+ job.steps_total
109
+ ] })
110
+ ] })
111
+ ] }, job.run_id);
112
+ }) });
113
+ }
114
+ function SwarmJobHistory({ jobs, onRerun }) {
115
+ if (!jobs || jobs.length === 0) {
116
+ return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground py-4 text-center", children: "No completed jobs." });
117
+ }
118
+ const badgeStyles = {
119
+ success: "bg-green-500/10 text-green-500",
120
+ failure: "bg-red-500/10 text-red-500",
121
+ cancelled: "bg-yellow-500/10 text-yellow-500"
122
+ };
123
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col divide-y divide-border", children: jobs.map((job) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 py-3 px-1", children: [
124
+ /* @__PURE__ */ jsx(
125
+ "span",
126
+ {
127
+ className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${badgeStyles[job.conclusion] || "bg-muted text-muted-foreground"}`,
128
+ children: job.conclusion || "unknown"
129
+ }
130
+ ),
131
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-sm", children: job.job_id.slice(0, 8) }),
132
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: timeAgo(job.updated_at || job.started_at) }),
133
+ /* @__PURE__ */ jsx("div", { className: "flex-1" }),
134
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
135
+ job.html_url && /* @__PURE__ */ jsx(
136
+ "a",
137
+ {
138
+ href: job.html_url,
139
+ target: "_blank",
140
+ rel: "noopener noreferrer",
141
+ className: "text-xs text-blue-500 hover:underline",
142
+ children: "View"
143
+ }
144
+ ),
145
+ /* @__PURE__ */ jsx(
146
+ "button",
147
+ {
148
+ onClick: () => onRerun(job.run_id, false),
149
+ className: "text-xs rounded-md px-2 py-1 border hover:bg-accent",
150
+ children: "Rerun"
151
+ }
152
+ ),
153
+ job.conclusion === "failure" && /* @__PURE__ */ jsx(
154
+ "button",
155
+ {
156
+ onClick: () => onRerun(job.run_id, true),
157
+ className: "text-xs rounded-md px-2 py-1 border text-red-500 hover:bg-red-500/10",
158
+ children: "Rerun failed"
159
+ }
160
+ )
161
+ ] })
162
+ ] }, job.run_id)) });
163
+ }
164
+ function SwarmPage({ session }) {
165
+ const [swarmData, setSwarmData] = useState(null);
166
+ const [loading, setLoading] = useState(true);
167
+ const [refreshing, setRefreshing] = useState(false);
168
+ const fetchStatus = useCallback(async () => {
169
+ try {
170
+ const data = await getSwarmStatus();
171
+ setSwarmData(data);
172
+ } catch (err) {
173
+ console.error("Failed to fetch swarm status:", err);
174
+ } finally {
175
+ setLoading(false);
176
+ }
177
+ }, []);
178
+ useEffect(() => {
179
+ fetchStatus();
180
+ const interval = setInterval(fetchStatus, 1e4);
181
+ return () => clearInterval(interval);
182
+ }, [fetchStatus]);
183
+ const handleRefresh = async () => {
184
+ setRefreshing(true);
185
+ await fetchStatus();
186
+ setRefreshing(false);
187
+ };
188
+ const handleCancel = async (runId) => {
189
+ try {
190
+ await cancelSwarmJob(runId);
191
+ await fetchStatus();
192
+ } catch (err) {
193
+ console.error("Failed to cancel job:", err);
194
+ }
195
+ };
196
+ const handleRerun = async (runId, failedOnly) => {
197
+ try {
198
+ await rerunSwarmJob(runId, failedOnly);
199
+ await fetchStatus();
200
+ } catch (err) {
201
+ console.error("Failed to rerun job:", err);
202
+ }
203
+ };
204
+ return /* @__PURE__ */ jsxs(PageLayout, { session, children: [
205
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mb-6", children: [
206
+ /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold", children: "Swarm" }),
207
+ !loading && /* @__PURE__ */ jsx(
208
+ "button",
209
+ {
210
+ onClick: handleRefresh,
211
+ disabled: refreshing,
212
+ className: "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:pointer-events-none",
213
+ children: refreshing ? /* @__PURE__ */ jsxs(Fragment, { children: [
214
+ /* @__PURE__ */ jsx(SpinnerIcon, { size: 14 }),
215
+ "Refreshing..."
216
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
217
+ /* @__PURE__ */ jsx(RefreshIcon, { size: 14 }),
218
+ "Refresh"
219
+ ] })
220
+ }
221
+ )
222
+ ] }),
223
+ loading ? /* @__PURE__ */ jsx(LoadingSkeleton, {}) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-6", children: [
224
+ swarmData?.counts && /* @__PURE__ */ jsx(SwarmSummaryCards, { counts: swarmData.counts }),
225
+ /* @__PURE__ */ jsxs("div", { children: [
226
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3", children: "Active Jobs" }),
227
+ /* @__PURE__ */ jsx(
228
+ SwarmActiveJobs,
229
+ {
230
+ jobs: swarmData?.active,
231
+ onCancel: handleCancel
232
+ }
233
+ )
234
+ ] }),
235
+ /* @__PURE__ */ jsxs("div", { children: [
236
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3", children: "Recent History" }),
237
+ /* @__PURE__ */ jsx(
238
+ SwarmJobHistory,
239
+ {
240
+ jobs: swarmData?.completed,
241
+ onRerun: handleRerun
242
+ }
243
+ )
244
+ ] })
245
+ ] })
246
+ ] });
247
+ }
248
+ export {
249
+ SwarmPage
250
+ };
@@ -0,0 +1,356 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { PageLayout } from './page-layout.js';
5
+ import { StopIcon, SpinnerIcon, RefreshIcon } from './icons.js';
6
+ import { getSwarmStatus, cancelSwarmJob, rerunSwarmJob } from '../actions.js';
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Utilities
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ function formatDuration(seconds) {
13
+ if (seconds < 60) return `${seconds}s`;
14
+ const minutes = Math.floor(seconds / 60);
15
+ const secs = seconds % 60;
16
+ if (minutes < 60) return `${minutes}m ${secs}s`;
17
+ const hours = Math.floor(minutes / 60);
18
+ const mins = minutes % 60;
19
+ return `${hours}h ${mins}m`;
20
+ }
21
+
22
+ function timeAgo(timestamp) {
23
+ const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
24
+ if (seconds < 60) return 'just now';
25
+ const minutes = Math.floor(seconds / 60);
26
+ if (minutes < 60) return `${minutes}m ago`;
27
+ const hours = Math.floor(minutes / 60);
28
+ if (hours < 24) return `${hours}h ago`;
29
+ const days = Math.floor(hours / 24);
30
+ if (days < 30) return `${days}d ago`;
31
+ const months = Math.floor(days / 30);
32
+ return `${months}mo ago`;
33
+ }
34
+
35
+ function LoadingSkeleton() {
36
+ return (
37
+ <div className="flex flex-col gap-4">
38
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
39
+ {[...Array(4)].map((_, i) => (
40
+ <div key={i} className="h-20 animate-pulse rounded-md bg-border/50" />
41
+ ))}
42
+ </div>
43
+ <div className="h-8 w-32 animate-pulse rounded-md bg-border/50" />
44
+ {[...Array(3)].map((_, i) => (
45
+ <div key={i} className="h-24 animate-pulse rounded-md bg-border/50" />
46
+ ))}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Summary Cards
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ function SwarmSummaryCards({ counts }) {
56
+ const cards = [
57
+ { label: 'Running', value: counts.running, color: 'border-l-green-500', text: 'text-green-500' },
58
+ { label: 'Queued', value: counts.queued, color: 'border-l-yellow-500', text: 'text-yellow-500' },
59
+ { label: 'Succeeded', value: counts.succeeded, color: 'border-l-blue-500', text: 'text-blue-500' },
60
+ { label: 'Failed', value: counts.failed, color: 'border-l-red-500', text: 'text-red-500' },
61
+ ];
62
+
63
+ return (
64
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
65
+ {cards.map((card) => (
66
+ <div
67
+ key={card.label}
68
+ className={`rounded-md border border-l-4 ${card.color} bg-card p-4`}
69
+ >
70
+ <p className="text-xs text-muted-foreground uppercase tracking-wide">{card.label}</p>
71
+ <p className={`text-2xl font-bold mt-1 ${card.text}`}>{card.value}</p>
72
+ </div>
73
+ ))}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+ // Active Jobs
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+
82
+ function SwarmActiveJobs({ jobs, onCancel }) {
83
+ if (!jobs || jobs.length === 0) {
84
+ return (
85
+ <div className="text-sm text-muted-foreground py-4 text-center">
86
+ No active jobs.
87
+ </div>
88
+ );
89
+ }
90
+
91
+ return (
92
+ <div className="flex flex-col gap-3">
93
+ {jobs.map((job) => {
94
+ const isRunning = job.status === 'in_progress';
95
+ const progress = job.steps_total > 0
96
+ ? Math.round((job.steps_completed / job.steps_total) * 100)
97
+ : 0;
98
+
99
+ return (
100
+ <div key={job.run_id} className="rounded-md border bg-card p-4">
101
+ <div className="flex items-center justify-between mb-2">
102
+ <div className="flex items-center gap-2">
103
+ {/* Status dot */}
104
+ <span
105
+ className={`inline-block h-2.5 w-2.5 rounded-full ${
106
+ isRunning ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'
107
+ }`}
108
+ />
109
+ <span className="font-mono text-sm font-medium">
110
+ {job.job_id.slice(0, 8)}
111
+ </span>
112
+ {job.workflow_name && (
113
+ <span className="text-xs text-muted-foreground">
114
+ {job.workflow_name}
115
+ </span>
116
+ )}
117
+ </div>
118
+ <div className="flex items-center gap-2">
119
+ <span className="text-xs text-muted-foreground">
120
+ {formatDuration(job.duration_seconds)}
121
+ </span>
122
+ {job.html_url && (
123
+ <a
124
+ href={job.html_url}
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ className="text-xs text-blue-500 hover:underline"
128
+ >
129
+ View
130
+ </a>
131
+ )}
132
+ <button
133
+ onClick={() => onCancel(job.run_id)}
134
+ className="inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
135
+ title="Cancel job"
136
+ >
137
+ <StopIcon size={14} />
138
+ </button>
139
+ </div>
140
+ </div>
141
+
142
+ {/* Current step */}
143
+ {job.current_step && (
144
+ <p className="text-xs text-muted-foreground mb-2 truncate">
145
+ {job.current_step}
146
+ </p>
147
+ )}
148
+
149
+ {/* Progress bar */}
150
+ {job.steps_total > 0 && (
151
+ <div className="flex items-center gap-2">
152
+ <div className="flex-1 h-1.5 rounded-full bg-border overflow-hidden">
153
+ <div
154
+ className="h-full rounded-full bg-green-500 transition-all duration-500"
155
+ style={{ width: `${progress}%` }}
156
+ />
157
+ </div>
158
+ <span className="text-xs text-muted-foreground shrink-0">
159
+ {job.steps_completed}/{job.steps_total}
160
+ </span>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ // Job History
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+
174
+ function SwarmJobHistory({ jobs, onRerun }) {
175
+ if (!jobs || jobs.length === 0) {
176
+ return (
177
+ <div className="text-sm text-muted-foreground py-4 text-center">
178
+ No completed jobs.
179
+ </div>
180
+ );
181
+ }
182
+
183
+ const badgeStyles = {
184
+ success: 'bg-green-500/10 text-green-500',
185
+ failure: 'bg-red-500/10 text-red-500',
186
+ cancelled: 'bg-yellow-500/10 text-yellow-500',
187
+ };
188
+
189
+ return (
190
+ <div className="flex flex-col divide-y divide-border">
191
+ {jobs.map((job) => (
192
+ <div key={job.run_id} className="flex items-center gap-3 py-3 px-1">
193
+ {/* Conclusion badge */}
194
+ <span
195
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${
196
+ badgeStyles[job.conclusion] || 'bg-muted text-muted-foreground'
197
+ }`}
198
+ >
199
+ {job.conclusion || 'unknown'}
200
+ </span>
201
+
202
+ {/* Job ID */}
203
+ <span className="font-mono text-sm">{job.job_id.slice(0, 8)}</span>
204
+
205
+ {/* Time ago */}
206
+ <span className="text-xs text-muted-foreground">
207
+ {timeAgo(job.updated_at || job.started_at)}
208
+ </span>
209
+
210
+ {/* Spacer */}
211
+ <div className="flex-1" />
212
+
213
+ {/* Actions */}
214
+ <div className="flex items-center gap-2">
215
+ {job.html_url && (
216
+ <a
217
+ href={job.html_url}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ className="text-xs text-blue-500 hover:underline"
221
+ >
222
+ View
223
+ </a>
224
+ )}
225
+ <button
226
+ onClick={() => onRerun(job.run_id, false)}
227
+ className="text-xs rounded-md px-2 py-1 border hover:bg-accent"
228
+ >
229
+ Rerun
230
+ </button>
231
+ {job.conclusion === 'failure' && (
232
+ <button
233
+ onClick={() => onRerun(job.run_id, true)}
234
+ className="text-xs rounded-md px-2 py-1 border text-red-500 hover:bg-red-500/10"
235
+ >
236
+ Rerun failed
237
+ </button>
238
+ )}
239
+ </div>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ // ─────────────────────────────────────────────────────────────────────────────
247
+ // Main Page
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+
250
+ export function SwarmPage({ session }) {
251
+ const [swarmData, setSwarmData] = useState(null);
252
+ const [loading, setLoading] = useState(true);
253
+ const [refreshing, setRefreshing] = useState(false);
254
+
255
+ const fetchStatus = useCallback(async () => {
256
+ try {
257
+ const data = await getSwarmStatus();
258
+ setSwarmData(data);
259
+ } catch (err) {
260
+ console.error('Failed to fetch swarm status:', err);
261
+ } finally {
262
+ setLoading(false);
263
+ }
264
+ }, []);
265
+
266
+ useEffect(() => {
267
+ fetchStatus();
268
+ const interval = setInterval(fetchStatus, 10000);
269
+ return () => clearInterval(interval);
270
+ }, [fetchStatus]);
271
+
272
+ const handleRefresh = async () => {
273
+ setRefreshing(true);
274
+ await fetchStatus();
275
+ setRefreshing(false);
276
+ };
277
+
278
+ const handleCancel = async (runId) => {
279
+ try {
280
+ await cancelSwarmJob(runId);
281
+ await fetchStatus();
282
+ } catch (err) {
283
+ console.error('Failed to cancel job:', err);
284
+ }
285
+ };
286
+
287
+ const handleRerun = async (runId, failedOnly) => {
288
+ try {
289
+ await rerunSwarmJob(runId, failedOnly);
290
+ await fetchStatus();
291
+ } catch (err) {
292
+ console.error('Failed to rerun job:', err);
293
+ }
294
+ };
295
+
296
+ return (
297
+ <PageLayout session={session}>
298
+ {/* Header */}
299
+ <div className="flex items-center justify-between mb-6">
300
+ <h1 className="text-2xl font-semibold">Swarm</h1>
301
+ {!loading && (
302
+ <button
303
+ onClick={handleRefresh}
304
+ disabled={refreshing}
305
+ className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:pointer-events-none"
306
+ >
307
+ {refreshing ? (
308
+ <>
309
+ <SpinnerIcon size={14} />
310
+ Refreshing...
311
+ </>
312
+ ) : (
313
+ <>
314
+ <RefreshIcon size={14} />
315
+ Refresh
316
+ </>
317
+ )}
318
+ </button>
319
+ )}
320
+ </div>
321
+
322
+ {loading ? (
323
+ <LoadingSkeleton />
324
+ ) : (
325
+ <div className="flex flex-col gap-6">
326
+ {/* Summary Cards */}
327
+ {swarmData?.counts && (
328
+ <SwarmSummaryCards counts={swarmData.counts} />
329
+ )}
330
+
331
+ {/* Active Jobs */}
332
+ <div>
333
+ <h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
334
+ Active Jobs
335
+ </h2>
336
+ <SwarmActiveJobs
337
+ jobs={swarmData?.active}
338
+ onCancel={handleCancel}
339
+ />
340
+ </div>
341
+
342
+ {/* Job History */}
343
+ <div>
344
+ <h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
345
+ Recent History
346
+ </h2>
347
+ <SwarmJobHistory
348
+ jobs={swarmData?.completed}
349
+ onRerun={handleRerun}
350
+ />
351
+ </div>
352
+ </div>
353
+ )}
354
+ </PageLayout>
355
+ );
356
+ }