@aion0/forge 0.2.19 → 0.2.21
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/app/api/notifications/route.ts +42 -0
- package/app/api/upgrade/route.ts +14 -11
- package/components/Dashboard.tsx +140 -2
- package/components/SessionView.tsx +1 -59
- package/components/SettingsModal.tsx +23 -0
- package/lib/init.ts +6 -0
- package/lib/notifications.ts +75 -0
- package/lib/notify.ts +30 -5
- package/lib/settings.ts +2 -0
- package/package.json +1 -1
- package/src/core/db/database.ts +13 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
getNotifications,
|
|
4
|
+
getUnreadCount,
|
|
5
|
+
markRead,
|
|
6
|
+
markAllRead,
|
|
7
|
+
deleteNotification,
|
|
8
|
+
} from '@/lib/notifications';
|
|
9
|
+
|
|
10
|
+
// GET /api/notifications — list notifications + unread count
|
|
11
|
+
export async function GET(req: Request) {
|
|
12
|
+
const { searchParams } = new URL(req.url);
|
|
13
|
+
const limit = parseInt(searchParams.get('limit') || '50');
|
|
14
|
+
const offset = parseInt(searchParams.get('offset') || '0');
|
|
15
|
+
|
|
16
|
+
const notifications = getNotifications(limit, offset);
|
|
17
|
+
const unread = getUnreadCount();
|
|
18
|
+
|
|
19
|
+
return NextResponse.json({ notifications, unread });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// POST /api/notifications — actions: markRead, markAllRead, delete
|
|
23
|
+
export async function POST(req: Request) {
|
|
24
|
+
const body = await req.json();
|
|
25
|
+
|
|
26
|
+
if (body.action === 'markRead' && body.id) {
|
|
27
|
+
markRead(body.id);
|
|
28
|
+
return NextResponse.json({ ok: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (body.action === 'markAllRead') {
|
|
32
|
+
markAllRead();
|
|
33
|
+
return NextResponse.json({ ok: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (body.action === 'delete' && body.id) {
|
|
37
|
+
deleteNotification(body.id);
|
|
38
|
+
return NextResponse.json({ ok: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
42
|
+
}
|
package/app/api/upgrade/route.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
4
6
|
|
|
5
7
|
export async function POST() {
|
|
6
8
|
try {
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
'cd /tmp && npm install -g @aion0/forge@latest --prefer-online 2>&1',
|
|
10
|
-
{ encoding: 'utf-8', timeout: 120000 }
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
// Verify the installed version
|
|
14
|
-
const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
9
|
+
// Get global npm root first (before any cwd changes)
|
|
10
|
+
const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000, cwd: homedir() }).trim();
|
|
15
11
|
const forgeRoot = join(pkgRoot, '@aion0', 'forge');
|
|
16
12
|
|
|
17
|
-
//
|
|
13
|
+
// Upgrade from npm — use cwd instead of cd
|
|
14
|
+
execSync('npm install -g @aion0/forge@latest --prefer-online 2>&1', {
|
|
15
|
+
encoding: 'utf-8',
|
|
16
|
+
timeout: 120000,
|
|
17
|
+
cwd: homedir(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Install devDependencies for build
|
|
18
21
|
try {
|
|
19
22
|
execSync('npm install --include=dev 2>&1', { cwd: forgeRoot, timeout: 120000 });
|
|
20
23
|
} catch {}
|
|
@@ -22,7 +25,7 @@ export async function POST() {
|
|
|
22
25
|
// Read installed version
|
|
23
26
|
let installedVersion = '';
|
|
24
27
|
try {
|
|
25
|
-
const pkg = JSON.parse(
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(forgeRoot, 'package.json'), 'utf-8'));
|
|
26
29
|
installedVersion = pkg.version;
|
|
27
30
|
} catch {}
|
|
28
31
|
|
|
@@ -34,7 +37,7 @@ export async function POST() {
|
|
|
34
37
|
const msg = e instanceof Error ? e.message : String(e);
|
|
35
38
|
return NextResponse.json({
|
|
36
39
|
ok: false,
|
|
37
|
-
error: `Upgrade failed: ${msg.slice(0,
|
|
40
|
+
error: `Upgrade failed: ${msg.slice(0, 300)}`,
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import SessionView from './SessionView';
|
|
|
8
8
|
import NewTaskModal from './NewTaskModal';
|
|
9
9
|
import SettingsModal from './SettingsModal';
|
|
10
10
|
import TunnelToggle from './TunnelToggle';
|
|
11
|
+
import MonitorPanel from './MonitorPanel';
|
|
11
12
|
import type { Task } from '@/src/types';
|
|
12
13
|
import type { WebTerminalHandle } from './WebTerminal';
|
|
13
14
|
|
|
@@ -44,6 +45,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
44
45
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
45
46
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
46
47
|
const [showSettings, setShowSettings] = useState(false);
|
|
48
|
+
const [showMonitor, setShowMonitor] = useState(false);
|
|
47
49
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
48
50
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
49
51
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
@@ -51,6 +53,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
51
53
|
const [versionInfo, setVersionInfo] = useState<{ current: string; latest: string; hasUpdate: boolean } | null>(null);
|
|
52
54
|
const [upgrading, setUpgrading] = useState(false);
|
|
53
55
|
const [upgradeResult, setUpgradeResult] = useState<string | null>(null);
|
|
56
|
+
const [notifications, setNotifications] = useState<any[]>([]);
|
|
57
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
58
|
+
const [showNotifications, setShowNotifications] = useState(false);
|
|
54
59
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
55
60
|
|
|
56
61
|
// Version check (on mount + every 10 min)
|
|
@@ -61,6 +66,20 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
61
66
|
return () => clearInterval(id);
|
|
62
67
|
}, []);
|
|
63
68
|
|
|
69
|
+
// Notification polling
|
|
70
|
+
const fetchNotifications = useCallback(() => {
|
|
71
|
+
fetch('/api/notifications').then(r => r.json()).then(data => {
|
|
72
|
+
setNotifications(data.notifications || []);
|
|
73
|
+
setUnreadCount(data.unread || 0);
|
|
74
|
+
}).catch(() => {});
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
fetchNotifications();
|
|
79
|
+
const id = setInterval(fetchNotifications, 10000);
|
|
80
|
+
return () => clearInterval(id);
|
|
81
|
+
}, [fetchNotifications]);
|
|
82
|
+
|
|
64
83
|
// Heartbeat for online user tracking
|
|
65
84
|
useEffect(() => {
|
|
66
85
|
const ping = () => {
|
|
@@ -200,6 +219,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
200
219
|
>
|
|
201
220
|
Pipelines
|
|
202
221
|
</button>
|
|
222
|
+
<button
|
|
223
|
+
onClick={() => setViewMode('sessions')}
|
|
224
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
225
|
+
viewMode === 'sessions'
|
|
226
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
227
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
228
|
+
}`}
|
|
229
|
+
>
|
|
230
|
+
Sessions
|
|
231
|
+
</button>
|
|
203
232
|
<button
|
|
204
233
|
onClick={() => setViewMode('preview')}
|
|
205
234
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -237,9 +266,116 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
237
266
|
)}
|
|
238
267
|
</span>
|
|
239
268
|
)}
|
|
269
|
+
<div className="relative">
|
|
270
|
+
<button
|
|
271
|
+
onClick={() => { setShowNotifications(v => !v); }}
|
|
272
|
+
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative"
|
|
273
|
+
>
|
|
274
|
+
Alerts
|
|
275
|
+
{unreadCount > 0 && (
|
|
276
|
+
<span className="absolute -top-1.5 -right-2.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
|
|
277
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</button>
|
|
281
|
+
{showNotifications && (
|
|
282
|
+
<div className="absolute right-0 top-8 w-[360px] max-h-[480px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 flex flex-col">
|
|
283
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]">
|
|
284
|
+
<span className="text-xs font-bold text-[var(--text-primary)]">Notifications</span>
|
|
285
|
+
<div className="flex items-center gap-2">
|
|
286
|
+
{unreadCount > 0 && (
|
|
287
|
+
<button
|
|
288
|
+
onClick={async () => {
|
|
289
|
+
await fetch('/api/notifications', {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: { 'Content-Type': 'application/json' },
|
|
292
|
+
body: JSON.stringify({ action: 'markAllRead' }),
|
|
293
|
+
});
|
|
294
|
+
fetchNotifications();
|
|
295
|
+
}}
|
|
296
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
297
|
+
>
|
|
298
|
+
Mark all read
|
|
299
|
+
</button>
|
|
300
|
+
)}
|
|
301
|
+
<button
|
|
302
|
+
onClick={() => setShowNotifications(false)}
|
|
303
|
+
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
304
|
+
>
|
|
305
|
+
Close
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="flex-1 overflow-y-auto">
|
|
310
|
+
{notifications.length === 0 ? (
|
|
311
|
+
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
|
|
312
|
+
) : (
|
|
313
|
+
notifications.map((n: any) => (
|
|
314
|
+
<div
|
|
315
|
+
key={n.id}
|
|
316
|
+
className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
|
|
317
|
+
>
|
|
318
|
+
<div className="flex items-start gap-2">
|
|
319
|
+
<span className="text-[10px] mt-0.5 shrink-0">
|
|
320
|
+
{n.type === 'task_done' ? '✅' : n.type === 'task_failed' ? '❌' : n.type === 'pipeline_done' ? '🔗' : n.type === 'pipeline_failed' ? '💔' : n.type === 'tunnel' ? '🌐' : 'ℹ️'}
|
|
321
|
+
</span>
|
|
322
|
+
<div className="flex-1 min-w-0">
|
|
323
|
+
<div className="flex items-center gap-1">
|
|
324
|
+
<span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
|
325
|
+
{n.title}
|
|
326
|
+
</span>
|
|
327
|
+
{!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
|
|
328
|
+
</div>
|
|
329
|
+
{n.body && (
|
|
330
|
+
<p className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5">{n.body}</p>
|
|
331
|
+
)}
|
|
332
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
333
|
+
{new Date(n.createdAt).toLocaleString()}
|
|
334
|
+
</span>
|
|
335
|
+
</div>
|
|
336
|
+
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
|
337
|
+
{!n.read && (
|
|
338
|
+
<button
|
|
339
|
+
onClick={async (e) => {
|
|
340
|
+
e.stopPropagation();
|
|
341
|
+
await fetch('/api/notifications', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({ action: 'markRead', id: n.id }),
|
|
345
|
+
});
|
|
346
|
+
fetchNotifications();
|
|
347
|
+
}}
|
|
348
|
+
className="text-[8px] px-1 py-0.5 text-[var(--accent)] hover:underline"
|
|
349
|
+
>
|
|
350
|
+
read
|
|
351
|
+
</button>
|
|
352
|
+
)}
|
|
353
|
+
<button
|
|
354
|
+
onClick={async (e) => {
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
await fetch('/api/notifications', {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: { 'Content-Type': 'application/json' },
|
|
359
|
+
body: JSON.stringify({ action: 'delete', id: n.id }),
|
|
360
|
+
});
|
|
361
|
+
fetchNotifications();
|
|
362
|
+
}}
|
|
363
|
+
className="text-[8px] px-1 py-0.5 text-red-400 hover:underline"
|
|
364
|
+
>
|
|
365
|
+
del
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
))
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
240
376
|
<button
|
|
241
|
-
onClick={() =>
|
|
242
|
-
className=
|
|
377
|
+
onClick={() => setShowMonitor(true)}
|
|
378
|
+
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
243
379
|
>
|
|
244
380
|
Monitor
|
|
245
381
|
</button>
|
|
@@ -414,6 +550,8 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
414
550
|
/>
|
|
415
551
|
)}
|
|
416
552
|
|
|
553
|
+
{showMonitor && <MonitorPanel onClose={() => setShowMonitor(false)} />}
|
|
554
|
+
|
|
417
555
|
{showSettings && (
|
|
418
556
|
<SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
|
|
419
557
|
)}
|
|
@@ -32,16 +32,6 @@ interface Watcher {
|
|
|
32
32
|
createdAt: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
interface MonitorData {
|
|
36
|
-
processes: {
|
|
37
|
-
nextjs: { running: boolean; pid: string };
|
|
38
|
-
terminal: { running: boolean; pid: string };
|
|
39
|
-
telegram: { running: boolean; pid: string };
|
|
40
|
-
tunnel: { running: boolean; pid: string; url: string };
|
|
41
|
-
};
|
|
42
|
-
sessions: { name: string; created: string; attached: boolean; windows: number }[];
|
|
43
|
-
uptime: string;
|
|
44
|
-
}
|
|
45
35
|
|
|
46
36
|
export default function SessionView({
|
|
47
37
|
projectName,
|
|
@@ -63,7 +53,6 @@ export default function SessionView({
|
|
|
63
53
|
const [watchers, setWatchers] = useState<Watcher[]>([]);
|
|
64
54
|
const [batchMode, setBatchMode] = useState(false);
|
|
65
55
|
const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
|
|
66
|
-
const [monitor, setMonitor] = useState<MonitorData | null>(null);
|
|
67
56
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
68
57
|
|
|
69
58
|
// Load cached sessions tree
|
|
@@ -91,17 +80,10 @@ export default function SessionView({
|
|
|
91
80
|
} catch {}
|
|
92
81
|
}, []);
|
|
93
82
|
|
|
94
|
-
const refreshMonitor = useCallback(() => {
|
|
95
|
-
fetch('/api/monitor').then(r => r.json()).then(setMonitor).catch(() => {});
|
|
96
|
-
}, []);
|
|
97
|
-
|
|
98
83
|
useEffect(() => {
|
|
99
84
|
loadTree(true);
|
|
100
85
|
loadWatchers();
|
|
101
|
-
|
|
102
|
-
const timer = setInterval(refreshMonitor, 5000);
|
|
103
|
-
return () => clearInterval(timer);
|
|
104
|
-
}, [loadTree, loadWatchers, refreshMonitor]);
|
|
86
|
+
}, [loadTree, loadWatchers]);
|
|
105
87
|
|
|
106
88
|
// Auto-expand project if only one or if pre-selected
|
|
107
89
|
useEffect(() => {
|
|
@@ -347,46 +329,6 @@ export default function SessionView({
|
|
|
347
329
|
</div>
|
|
348
330
|
)}
|
|
349
331
|
|
|
350
|
-
{/* Monitor — always visible */}
|
|
351
|
-
{monitor && (
|
|
352
|
-
<div className="border-b border-[var(--border)] px-2 py-2 space-y-1.5">
|
|
353
|
-
<div className="flex items-center justify-between">
|
|
354
|
-
<span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Processes</span>
|
|
355
|
-
{monitor.uptime && (
|
|
356
|
-
<span className="text-[8px] text-[var(--text-secondary)]">up {monitor.uptime}</span>
|
|
357
|
-
)}
|
|
358
|
-
</div>
|
|
359
|
-
{[
|
|
360
|
-
{ label: 'Next.js', ...monitor.processes.nextjs },
|
|
361
|
-
{ label: 'Terminal', ...monitor.processes.terminal },
|
|
362
|
-
{ label: 'Telegram', ...monitor.processes.telegram },
|
|
363
|
-
{ label: 'Tunnel', ...monitor.processes.tunnel },
|
|
364
|
-
].map(p => (
|
|
365
|
-
<div key={p.label} className="flex items-center gap-1.5 text-[10px]">
|
|
366
|
-
<span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
|
|
367
|
-
<span className="text-[var(--text-primary)]">{p.label}</span>
|
|
368
|
-
<span className="text-[var(--text-secondary)] font-mono ml-auto">{p.running ? `pid:${p.pid}` : 'stopped'}</span>
|
|
369
|
-
</div>
|
|
370
|
-
))}
|
|
371
|
-
{monitor.processes.tunnel.running && monitor.processes.tunnel.url && (
|
|
372
|
-
<div className="text-[9px] text-[var(--accent)] truncate pl-4">{monitor.processes.tunnel.url}</div>
|
|
373
|
-
)}
|
|
374
|
-
|
|
375
|
-
{monitor.sessions.length > 0 && (
|
|
376
|
-
<div className="pt-1">
|
|
377
|
-
<span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Tmux ({monitor.sessions.length})</span>
|
|
378
|
-
{monitor.sessions.map(s => (
|
|
379
|
-
<div key={s.name} className="flex items-center gap-1.5 text-[10px] mt-0.5">
|
|
380
|
-
<span className={s.attached ? 'text-green-400' : 'text-yellow-500'}>●</span>
|
|
381
|
-
<span className="font-mono text-[var(--text-primary)] truncate flex-1">{s.name}</span>
|
|
382
|
-
<span className="text-[8px] text-[var(--text-secondary)]">{s.attached ? 'attached' : 'detached'}</span>
|
|
383
|
-
</div>
|
|
384
|
-
))}
|
|
385
|
-
</div>
|
|
386
|
-
)}
|
|
387
|
-
</div>
|
|
388
|
-
)}
|
|
389
|
-
|
|
390
332
|
{/* Tree */}
|
|
391
333
|
<div className="flex-1 overflow-y-auto">
|
|
392
334
|
{Object.keys(sessionTree).length === 0 && (
|
|
@@ -202,6 +202,7 @@ interface Settings {
|
|
|
202
202
|
pipelineModel: string;
|
|
203
203
|
telegramModel: string;
|
|
204
204
|
skipPermissions: boolean;
|
|
205
|
+
notificationRetentionDays: number;
|
|
205
206
|
_secretStatus?: Record<string, boolean>;
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -228,6 +229,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
228
229
|
pipelineModel: 'sonnet',
|
|
229
230
|
telegramModel: 'sonnet',
|
|
230
231
|
skipPermissions: false,
|
|
232
|
+
notificationRetentionDays: 30,
|
|
231
233
|
});
|
|
232
234
|
const [secretStatus, setSecretStatus] = useState<Record<string, boolean>>({});
|
|
233
235
|
const [newRoot, setNewRoot] = useState('');
|
|
@@ -578,6 +580,27 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
578
580
|
</p>
|
|
579
581
|
</div>
|
|
580
582
|
|
|
583
|
+
{/* Notification Retention */}
|
|
584
|
+
<div className="space-y-2">
|
|
585
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
586
|
+
Notifications
|
|
587
|
+
</label>
|
|
588
|
+
<div className="flex items-center gap-2">
|
|
589
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Auto-delete after</span>
|
|
590
|
+
<select
|
|
591
|
+
value={settings.notificationRetentionDays || 30}
|
|
592
|
+
onChange={e => setSettings({ ...settings, notificationRetentionDays: Number(e.target.value) })}
|
|
593
|
+
className="text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
594
|
+
>
|
|
595
|
+
<option value={7}>7 days</option>
|
|
596
|
+
<option value={14}>14 days</option>
|
|
597
|
+
<option value={30}>30 days</option>
|
|
598
|
+
<option value={60}>60 days</option>
|
|
599
|
+
<option value={90}>90 days</option>
|
|
600
|
+
</select>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
|
|
581
604
|
{/* Remote Access (Cloudflare Tunnel) */}
|
|
582
605
|
<div className="space-y-2">
|
|
583
606
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
package/lib/init.ts
CHANGED
|
@@ -67,6 +67,12 @@ export function ensureInitialized() {
|
|
|
67
67
|
// Migrate plaintext secrets on startup
|
|
68
68
|
migrateSecrets();
|
|
69
69
|
|
|
70
|
+
// Cleanup old notifications
|
|
71
|
+
try {
|
|
72
|
+
const { cleanupNotifications } = require('./notifications');
|
|
73
|
+
cleanupNotifications();
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
70
76
|
// Auto-detect claude path if not configured
|
|
71
77
|
autoDetectClaude();
|
|
72
78
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-app notification system.
|
|
3
|
+
* Stores notifications in SQLite, auto-cleans based on retention setting.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getDb } from '@/src/core/db/database';
|
|
7
|
+
import { getDbPath } from '@/src/config';
|
|
8
|
+
import { loadSettings } from './settings';
|
|
9
|
+
|
|
10
|
+
export interface Notification {
|
|
11
|
+
id: number;
|
|
12
|
+
type: string; // 'task_done' | 'task_failed' | 'pipeline_done' | 'pipeline_failed' | 'tunnel' | 'system'
|
|
13
|
+
title: string;
|
|
14
|
+
body: string | null;
|
|
15
|
+
read: boolean;
|
|
16
|
+
taskId: string | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function db() {
|
|
21
|
+
return getDb(getDbPath());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Add a notification */
|
|
25
|
+
export function addNotification(type: string, title: string, body?: string, taskId?: string) {
|
|
26
|
+
db().prepare(
|
|
27
|
+
'INSERT INTO notifications (type, title, body, task_id) VALUES (?, ?, ?, ?)'
|
|
28
|
+
).run(type, title, body || null, taskId || null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get recent notifications (newest first) */
|
|
32
|
+
export function getNotifications(limit = 50, offset = 0): Notification[] {
|
|
33
|
+
const rows = db().prepare(
|
|
34
|
+
'SELECT * FROM notifications ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
|
35
|
+
).all(limit, offset) as any[];
|
|
36
|
+
return rows.map(r => ({
|
|
37
|
+
id: r.id,
|
|
38
|
+
type: r.type,
|
|
39
|
+
title: r.title,
|
|
40
|
+
body: r.body,
|
|
41
|
+
read: !!r.read,
|
|
42
|
+
taskId: r.task_id,
|
|
43
|
+
createdAt: r.created_at,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Count unread notifications */
|
|
48
|
+
export function getUnreadCount(): number {
|
|
49
|
+
const row = db().prepare('SELECT COUNT(*) as count FROM notifications WHERE read = 0').get() as any;
|
|
50
|
+
return row?.count || 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Mark one as read */
|
|
54
|
+
export function markRead(id: number) {
|
|
55
|
+
db().prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mark all as read */
|
|
59
|
+
export function markAllRead() {
|
|
60
|
+
db().prepare('UPDATE notifications SET read = 1 WHERE read = 0').run();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Delete one notification */
|
|
64
|
+
export function deleteNotification(id: number) {
|
|
65
|
+
db().prepare('DELETE FROM notifications WHERE id = ?').run(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Clean up old notifications based on retention setting */
|
|
69
|
+
export function cleanupNotifications() {
|
|
70
|
+
const settings = loadSettings();
|
|
71
|
+
const days = settings.notificationRetentionDays || 30;
|
|
72
|
+
db().prepare(
|
|
73
|
+
`DELETE FROM notifications WHERE created_at < datetime('now', '-' || ? || ' days')`
|
|
74
|
+
).run(days);
|
|
75
|
+
}
|
package/lib/notify.ts
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { loadSettings } from './settings';
|
|
6
|
+
import { addNotification } from './notifications';
|
|
6
7
|
import type { Task } from '@/src/types';
|
|
7
8
|
|
|
8
9
|
export async function notifyTaskComplete(task: Task) {
|
|
9
10
|
// Skip pipeline tasks
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!settings.notifyOnComplete) return;
|
|
11
|
+
let isPipeline = false;
|
|
12
|
+
try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
|
|
13
|
+
if (isPipeline) return;
|
|
14
14
|
|
|
15
15
|
const cost = task.costUSD != null ? `$${task.costUSD.toFixed(4)}` : 'unknown';
|
|
16
16
|
const duration = task.startedAt && task.completedAt
|
|
@@ -18,6 +18,19 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
18
18
|
: 'unknown';
|
|
19
19
|
const model = task.log?.find(e => e.subtype === 'init' && e.content.startsWith('Model:'))?.content.replace('Model: ', '') || 'unknown';
|
|
20
20
|
|
|
21
|
+
// In-app notification (always)
|
|
22
|
+
try {
|
|
23
|
+
addNotification(
|
|
24
|
+
'task_done',
|
|
25
|
+
`Task done: ${task.projectName}`,
|
|
26
|
+
`${task.prompt.slice(0, 100)} — ${duration}, ${cost}`,
|
|
27
|
+
task.id
|
|
28
|
+
);
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
const settings = loadSettings();
|
|
32
|
+
if (!settings.notifyOnComplete) return;
|
|
33
|
+
|
|
21
34
|
await sendTelegram(
|
|
22
35
|
`✅ *Task Done*\n\n` +
|
|
23
36
|
`*Project:* ${esc(task.projectName)}\n` +
|
|
@@ -31,7 +44,19 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
31
44
|
|
|
32
45
|
export async function notifyTaskFailed(task: Task) {
|
|
33
46
|
// Skip pipeline tasks
|
|
34
|
-
|
|
47
|
+
let isPipeline = false;
|
|
48
|
+
try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
|
|
49
|
+
if (isPipeline) return;
|
|
50
|
+
|
|
51
|
+
// In-app notification (always)
|
|
52
|
+
try {
|
|
53
|
+
addNotification(
|
|
54
|
+
'task_failed',
|
|
55
|
+
`Task failed: ${task.projectName}`,
|
|
56
|
+
task.error || task.prompt.slice(0, 100),
|
|
57
|
+
task.id
|
|
58
|
+
);
|
|
59
|
+
} catch {}
|
|
35
60
|
|
|
36
61
|
const settings = loadSettings();
|
|
37
62
|
if (!settings.notifyOnFailure) return;
|
package/lib/settings.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface Settings {
|
|
|
21
21
|
pipelineModel: string; // Model for pipelines (default: sonnet)
|
|
22
22
|
telegramModel: string; // Model for Telegram AI features (default: sonnet)
|
|
23
23
|
skipPermissions: boolean; // Add --dangerously-skip-permissions to all claude invocations
|
|
24
|
+
notificationRetentionDays: number; // Auto-cleanup notifications older than N days
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const defaults: Settings = {
|
|
@@ -37,6 +38,7 @@ const defaults: Settings = {
|
|
|
37
38
|
pipelineModel: 'default',
|
|
38
39
|
telegramModel: 'sonnet',
|
|
39
40
|
skipPermissions: false,
|
|
41
|
+
notificationRetentionDays: 30,
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
/** Load settings with secrets decrypted (for internal use) */
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -107,6 +107,19 @@ function initSchema(db: Database.Database) {
|
|
|
107
107
|
|
|
108
108
|
CREATE INDEX IF NOT EXISTS idx_cached_sessions_project ON cached_sessions(project_name, modified);
|
|
109
109
|
|
|
110
|
+
-- In-app notifications
|
|
111
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
112
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
113
|
+
type TEXT NOT NULL,
|
|
114
|
+
title TEXT NOT NULL,
|
|
115
|
+
body TEXT,
|
|
116
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
task_id TEXT,
|
|
118
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read, created_at);
|
|
122
|
+
|
|
110
123
|
-- Session watchers — monitor sessions and notify via Telegram
|
|
111
124
|
CREATE TABLE IF NOT EXISTS session_watchers (
|
|
112
125
|
id TEXT PRIMARY KEY,
|