@aion0/forge 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/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import MarkdownContent from './MarkdownContent';
|
|
5
|
+
import type { Task, TaskLogEntry } from '@/src/types';
|
|
6
|
+
|
|
7
|
+
export default function TaskDetail({
|
|
8
|
+
task,
|
|
9
|
+
onRefresh,
|
|
10
|
+
onFollowUp,
|
|
11
|
+
}: {
|
|
12
|
+
task: Task;
|
|
13
|
+
onRefresh: () => void;
|
|
14
|
+
onFollowUp?: (data: { projectName: string; prompt: string; conversationId: string }) => void;
|
|
15
|
+
}) {
|
|
16
|
+
const [liveLog, setLiveLog] = useState<TaskLogEntry[]>(task.log);
|
|
17
|
+
const [liveStatus, setLiveStatus] = useState(task.status);
|
|
18
|
+
const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
|
|
19
|
+
const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
|
|
20
|
+
const [followUpText, setFollowUpText] = useState('');
|
|
21
|
+
const logEndRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
// SSE stream for running tasks
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (task.status !== 'running' && task.status !== 'queued') {
|
|
26
|
+
setLiveLog(task.log);
|
|
27
|
+
setLiveStatus(task.status);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setLiveLog([]);
|
|
32
|
+
const es = new EventSource(`/api/tasks/${task.id}/stream`);
|
|
33
|
+
|
|
34
|
+
es.onmessage = (event) => {
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(event.data);
|
|
37
|
+
if (data.type === 'log') {
|
|
38
|
+
setLiveLog(prev => [...prev, data.entry]);
|
|
39
|
+
} else if (data.type === 'status') {
|
|
40
|
+
setLiveStatus(data.status);
|
|
41
|
+
if (data.status === 'done' || data.status === 'failed' || data.status === 'cancelled') {
|
|
42
|
+
onRefresh();
|
|
43
|
+
}
|
|
44
|
+
} else if (data.type === 'complete' && data.task) {
|
|
45
|
+
setLiveLog(data.task.log);
|
|
46
|
+
setLiveStatus(data.task.status);
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
es.onerror = () => {
|
|
52
|
+
es.close();
|
|
53
|
+
onRefresh();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return () => es.close();
|
|
57
|
+
}, [task.id, task.status, onRefresh]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
61
|
+
}, [liveLog]);
|
|
62
|
+
|
|
63
|
+
const handleAction = async (action: string) => {
|
|
64
|
+
await fetch(`/api/tasks/${task.id}`, {
|
|
65
|
+
method: action === 'delete' ? 'DELETE' : 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: action !== 'delete' ? JSON.stringify({ action }) : undefined,
|
|
68
|
+
});
|
|
69
|
+
onRefresh();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const toggleTool = (i: number) => {
|
|
73
|
+
setExpandedTools(prev => {
|
|
74
|
+
const next = new Set(prev);
|
|
75
|
+
next.has(i) ? next.delete(i) : next.add(i);
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const displayLog = liveLog.length > 0 ? liveLog : task.log;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex flex-col h-full">
|
|
84
|
+
{/* Header */}
|
|
85
|
+
<div className="border-b border-[var(--border)] px-4 py-2 shrink-0">
|
|
86
|
+
<div className="flex items-center justify-between mb-1">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<StatusBadge status={liveStatus} />
|
|
89
|
+
<span className="text-sm font-semibold">{task.projectName}</span>
|
|
90
|
+
<span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex items-center gap-2">
|
|
93
|
+
{(liveStatus === 'running' || liveStatus === 'queued') && (
|
|
94
|
+
<button onClick={() => handleAction('cancel')} className="text-[10px] px-2 py-0.5 text-[var(--red)] border border-[var(--red)]/30 rounded hover:bg-[var(--red)] hover:text-white">
|
|
95
|
+
Cancel
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
{(liveStatus === 'failed' || liveStatus === 'cancelled') && (
|
|
99
|
+
<button onClick={() => handleAction('retry')} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
|
|
100
|
+
Retry
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
<button onClick={() => handleAction('delete')} className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--red)]">
|
|
104
|
+
Delete
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<p className="text-xs text-[var(--text-secondary)] mb-2">{task.prompt}</p>
|
|
109
|
+
<div className="flex items-center gap-3 text-[10px] text-[var(--text-secondary)]">
|
|
110
|
+
<span>Created: {new Date(task.createdAt).toLocaleString()}</span>
|
|
111
|
+
{task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
|
|
112
|
+
{task.completedAt && <span>Completed: {new Date(task.completedAt).toLocaleString()}</span>}
|
|
113
|
+
{task.costUSD != null && <span>Cost: ${task.costUSD.toFixed(4)}</span>}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Tabs */}
|
|
118
|
+
<div className="border-b border-[var(--border)] px-4 flex gap-0.5 shrink-0">
|
|
119
|
+
{(['log', 'result', 'diff'] as const).map(t => (
|
|
120
|
+
<button
|
|
121
|
+
key={t}
|
|
122
|
+
onClick={() => setTab(t)}
|
|
123
|
+
className={`text-[11px] px-3 py-1.5 border-b-2 transition-colors ${
|
|
124
|
+
tab === t ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
{t === 'log' ? `Log (${displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Content */}
|
|
133
|
+
<div className="flex-1 overflow-y-auto p-4 text-sm">
|
|
134
|
+
{tab === 'log' && (
|
|
135
|
+
<div className="space-y-2">
|
|
136
|
+
{displayLog.map((entry, i) => (
|
|
137
|
+
<LogEntry key={i} entry={entry} index={i} expanded={expandedTools.has(i)} onToggle={() => toggleTool(i)} />
|
|
138
|
+
))}
|
|
139
|
+
{liveStatus === 'running' && (
|
|
140
|
+
<div className="text-[var(--accent)] animate-pulse py-1 text-xs">working...</div>
|
|
141
|
+
)}
|
|
142
|
+
<div ref={logEndRef} />
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{tab === 'result' && (
|
|
147
|
+
<div className="prose-container">
|
|
148
|
+
{task.resultSummary ? (
|
|
149
|
+
<MarkdownContent content={task.resultSummary} />
|
|
150
|
+
) : task.error ? (
|
|
151
|
+
<div className="p-3 bg-red-900/10 border border-red-800/20 rounded">
|
|
152
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--red)] text-xs font-mono">{task.error}</pre>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<p className="text-[var(--text-secondary)] text-xs">
|
|
156
|
+
{liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : 'No result'}
|
|
157
|
+
</p>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{tab === 'diff' && (
|
|
163
|
+
<div>
|
|
164
|
+
{task.gitDiff ? (
|
|
165
|
+
<div className="bg-[var(--bg-tertiary)] rounded border border-[var(--border)] overflow-hidden">
|
|
166
|
+
<pre className="p-3 text-xs font-mono overflow-x-auto">
|
|
167
|
+
{task.gitDiff.split('\n').map((line, i) => (
|
|
168
|
+
<div key={i} className={`px-2 ${
|
|
169
|
+
line.startsWith('+++') || line.startsWith('---') ? 'text-[var(--text-secondary)] font-semibold' :
|
|
170
|
+
line.startsWith('+') ? 'text-[var(--green)] bg-green-500/5' :
|
|
171
|
+
line.startsWith('-') ? 'text-[var(--red)] bg-red-500/5' :
|
|
172
|
+
line.startsWith('@@') ? 'text-[var(--accent)] bg-[var(--accent)]/5 font-semibold' :
|
|
173
|
+
line.startsWith('diff ') ? 'text-[var(--text-primary)] font-bold border-t border-[var(--border)] pt-2 mt-2' :
|
|
174
|
+
'text-[var(--text-secondary)]'
|
|
175
|
+
}`}>
|
|
176
|
+
{line}
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</pre>
|
|
180
|
+
</div>
|
|
181
|
+
) : (
|
|
182
|
+
<p className="text-[var(--text-secondary)] text-xs">
|
|
183
|
+
{liveStatus === 'running' ? 'Diff will be captured after completion' : 'No changes detected'}
|
|
184
|
+
</p>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Follow-up input for completed tasks */}
|
|
191
|
+
{liveStatus === 'done' && task.conversationId && onFollowUp && (
|
|
192
|
+
<div className="border-t border-[var(--border)] px-4 py-2 shrink-0">
|
|
193
|
+
<form
|
|
194
|
+
onSubmit={(e) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
if (!followUpText.trim()) return;
|
|
197
|
+
onFollowUp({
|
|
198
|
+
projectName: task.projectName,
|
|
199
|
+
prompt: followUpText.trim(),
|
|
200
|
+
conversationId: task.conversationId!,
|
|
201
|
+
});
|
|
202
|
+
setFollowUpText('');
|
|
203
|
+
}}
|
|
204
|
+
className="flex gap-2"
|
|
205
|
+
>
|
|
206
|
+
<input
|
|
207
|
+
type="text"
|
|
208
|
+
value={followUpText}
|
|
209
|
+
onChange={e => setFollowUpText(e.target.value)}
|
|
210
|
+
placeholder="Send follow-up message (continues this session)..."
|
|
211
|
+
className="flex-1 px-3 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
212
|
+
/>
|
|
213
|
+
<button
|
|
214
|
+
type="submit"
|
|
215
|
+
disabled={!followUpText.trim()}
|
|
216
|
+
className="text-xs px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
217
|
+
>
|
|
218
|
+
Send
|
|
219
|
+
</button>
|
|
220
|
+
</form>
|
|
221
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-1">
|
|
222
|
+
Session <span className="font-mono">{task.conversationId.slice(0, 12)}...</span> — creates a new task continuing this conversation
|
|
223
|
+
</p>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function StatusBadge({ status }: { status: string }) {
|
|
231
|
+
const colors: Record<string, string> = {
|
|
232
|
+
queued: 'bg-yellow-500/20 text-yellow-500',
|
|
233
|
+
running: 'bg-green-500/20 text-[var(--green)]',
|
|
234
|
+
done: 'bg-blue-500/20 text-blue-400',
|
|
235
|
+
failed: 'bg-red-500/20 text-[var(--red)]',
|
|
236
|
+
cancelled: 'bg-gray-500/20 text-[var(--text-secondary)]',
|
|
237
|
+
};
|
|
238
|
+
return (
|
|
239
|
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${colors[status] || ''}`}>
|
|
240
|
+
{status}
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function LogEntry({ entry, index, expanded, onToggle }: {
|
|
246
|
+
entry: TaskLogEntry;
|
|
247
|
+
index: number;
|
|
248
|
+
expanded: boolean;
|
|
249
|
+
onToggle: () => void;
|
|
250
|
+
}) {
|
|
251
|
+
// System init
|
|
252
|
+
if (entry.type === 'system' && entry.subtype === 'init') {
|
|
253
|
+
return (
|
|
254
|
+
<div className="text-[10px] text-[var(--text-secondary)] py-0.5 flex items-center gap-1">
|
|
255
|
+
<span className="opacity-50">⚙</span> {entry.content}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Error
|
|
261
|
+
if (entry.subtype === 'error') {
|
|
262
|
+
return (
|
|
263
|
+
<div className="p-2 bg-red-900/10 border border-red-800/20 rounded text-xs">
|
|
264
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--red)] font-mono">{entry.content}</pre>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Tool use — collapsible
|
|
270
|
+
if (entry.subtype === 'tool_use') {
|
|
271
|
+
const toolContent = formatToolContent(entry.content);
|
|
272
|
+
const isLong = toolContent.length > 80;
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
276
|
+
<button
|
|
277
|
+
onClick={onToggle}
|
|
278
|
+
className="w-full flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] hover:bg-[var(--border)]/30 transition-colors text-left"
|
|
279
|
+
>
|
|
280
|
+
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded font-medium font-mono">
|
|
281
|
+
{entry.tool || 'tool'}
|
|
282
|
+
</span>
|
|
283
|
+
<span className="text-[11px] text-[var(--text-secondary)] truncate flex-1 font-mono">
|
|
284
|
+
{isLong && !expanded ? toolContent.slice(0, 80) + '...' : (!isLong ? toolContent : '')}
|
|
285
|
+
</span>
|
|
286
|
+
{isLong && (
|
|
287
|
+
<span className="text-[9px] text-[var(--text-secondary)] shrink-0">{expanded ? '▲' : '▼'}</span>
|
|
288
|
+
)}
|
|
289
|
+
</button>
|
|
290
|
+
{(expanded || !isLong) && isLong && (
|
|
291
|
+
<pre className="px-3 py-2 text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words max-h-60 overflow-y-auto border-t border-[var(--border)]">
|
|
292
|
+
{toolContent}
|
|
293
|
+
</pre>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Tool result — collapsible with border accent
|
|
300
|
+
if (entry.subtype === 'tool_result') {
|
|
301
|
+
const content = formatToolContent(entry.content);
|
|
302
|
+
const isLong = content.length > 150;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="ml-4 border-l-2 border-[var(--accent)]/30 pl-3">
|
|
306
|
+
<pre className={`text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words ${isLong && !expanded ? 'max-h-16 overflow-hidden' : 'max-h-80 overflow-y-auto'}`}>
|
|
307
|
+
{content}
|
|
308
|
+
</pre>
|
|
309
|
+
{isLong && !expanded && (
|
|
310
|
+
<button onClick={onToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
|
|
311
|
+
show more
|
|
312
|
+
</button>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Final result
|
|
319
|
+
if (entry.type === 'result') {
|
|
320
|
+
return (
|
|
321
|
+
<div className="p-3 bg-green-900/5 border border-green-800/15 rounded">
|
|
322
|
+
<MarkdownContent content={entry.content} />
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Assistant text — render as markdown
|
|
328
|
+
return (
|
|
329
|
+
<div className="py-1">
|
|
330
|
+
<MarkdownContent content={entry.content} />
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MarkdownContent is now imported from ./MarkdownContent
|
|
336
|
+
|
|
337
|
+
function formatToolContent(content: string): string {
|
|
338
|
+
try {
|
|
339
|
+
const parsed = JSON.parse(content);
|
|
340
|
+
if (typeof parsed === 'object') {
|
|
341
|
+
// For common tool patterns, show a cleaner format
|
|
342
|
+
if (parsed.command) return `$ ${parsed.command}`;
|
|
343
|
+
if (parsed.file_path) return parsed.file_path;
|
|
344
|
+
if (parsed.pattern) return `/${parsed.pattern}/`;
|
|
345
|
+
return JSON.stringify(parsed, null, 2);
|
|
346
|
+
}
|
|
347
|
+
return content;
|
|
348
|
+
} catch {
|
|
349
|
+
return content;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface TunnelStatus {
|
|
6
|
+
status: 'stopped' | 'starting' | 'running' | 'error';
|
|
7
|
+
url: string | null;
|
|
8
|
+
error: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function TunnelToggle() {
|
|
12
|
+
const [tunnel, setTunnel] = useState<TunnelStatus>({ status: 'stopped', url: null, error: null });
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
const [isRemote, setIsRemote] = useState(false);
|
|
16
|
+
const [confirmStop, setConfirmStop] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
const refresh = useCallback(() => {
|
|
23
|
+
fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
refresh();
|
|
28
|
+
const id = setInterval(refresh, 5000);
|
|
29
|
+
return () => clearInterval(id);
|
|
30
|
+
}, [refresh]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (tunnel.status !== 'starting') return;
|
|
34
|
+
const id = setInterval(refresh, 2000);
|
|
35
|
+
return () => clearInterval(id);
|
|
36
|
+
}, [tunnel.status, refresh]);
|
|
37
|
+
|
|
38
|
+
const doStop = async () => {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch('/api/tunnel', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ action: 'stop' }),
|
|
45
|
+
});
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
setTunnel(data);
|
|
48
|
+
} catch {}
|
|
49
|
+
setLoading(false);
|
|
50
|
+
setConfirmStop(false);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const doStart = async () => {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch('/api/tunnel', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ action: 'start' }),
|
|
60
|
+
});
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
setTunnel(data);
|
|
63
|
+
} catch {}
|
|
64
|
+
setLoading(false);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const copyUrl = () => {
|
|
68
|
+
if (tunnel.url) {
|
|
69
|
+
navigator.clipboard.writeText(tunnel.url);
|
|
70
|
+
setCopied(true);
|
|
71
|
+
setTimeout(() => setCopied(false), 2000);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Hide tunnel controls when accessing remotely
|
|
76
|
+
if (isRemote) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stop confirmation dialog
|
|
81
|
+
if (confirmStop) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-center gap-1.5">
|
|
84
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Stop tunnel?</span>
|
|
85
|
+
<button
|
|
86
|
+
onClick={doStop}
|
|
87
|
+
disabled={loading}
|
|
88
|
+
className="text-[10px] px-2 py-0.5 bg-[var(--red)] text-white rounded hover:opacity-90"
|
|
89
|
+
>
|
|
90
|
+
Confirm
|
|
91
|
+
</button>
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => setConfirmStop(false)}
|
|
94
|
+
className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
95
|
+
>
|
|
96
|
+
Cancel
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (tunnel.status === 'stopped' && !tunnel.error) {
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
onClick={doStart}
|
|
106
|
+
disabled={loading}
|
|
107
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors disabled:opacity-50"
|
|
108
|
+
title="Start Cloudflare Tunnel for remote access"
|
|
109
|
+
>
|
|
110
|
+
{loading ? 'Starting...' : 'Tunnel'}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (tunnel.status === 'starting') {
|
|
116
|
+
return (
|
|
117
|
+
<span className="text-[10px] px-2 py-0.5 border border-[var(--yellow)] rounded text-[var(--yellow)]">
|
|
118
|
+
Tunnel starting...
|
|
119
|
+
</span>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (tunnel.status === 'running' && tunnel.url) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex items-center gap-1.5">
|
|
126
|
+
<button
|
|
127
|
+
onClick={copyUrl}
|
|
128
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--green)] rounded text-[var(--green)] hover:bg-[var(--green)] hover:text-black transition-colors truncate max-w-[200px]"
|
|
129
|
+
title={`Click to copy: ${tunnel.url}`}
|
|
130
|
+
>
|
|
131
|
+
{copied ? 'Copied!' : tunnel.url.replace('https://', '')}
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => setConfirmStop(true)}
|
|
135
|
+
disabled={loading}
|
|
136
|
+
className="text-[10px] px-1.5 py-0.5 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
|
|
137
|
+
title="Stop tunnel"
|
|
138
|
+
>
|
|
139
|
+
Stop
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (tunnel.status === 'error') {
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex items-center gap-1.5">
|
|
148
|
+
<span className="text-[10px] text-[var(--red)] truncate max-w-[200px]" title={tunnel.error || ''}>
|
|
149
|
+
Tunnel error
|
|
150
|
+
</span>
|
|
151
|
+
<button
|
|
152
|
+
onClick={doStart}
|
|
153
|
+
disabled={loading}
|
|
154
|
+
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
155
|
+
>
|
|
156
|
+
Retry
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|