@swarmclawai/swarmclaw 0.5.2 → 0.5.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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/src/app/api/notifications/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +64 -0
- package/src/app/api/search/route.ts +105 -0
- package/src/app/api/tasks/[id]/route.ts +8 -0
- package/src/app/api/usage/route.ts +45 -0
- package/src/cli/index.js +18 -0
- package/src/cli/spec.js +16 -0
- package/src/components/layout/app-layout.tsx +46 -0
- package/src/components/layout/mobile-header.tsx +2 -0
- package/src/components/schedules/schedule-card.tsx +2 -1
- package/src/components/shared/notification-center.tsx +185 -0
- package/src/components/shared/search-dialog.tsx +287 -0
- package/src/components/usage/metrics-dashboard.tsx +78 -0
- package/src/lib/cron-human.ts +114 -0
- package/src/lib/server/create-notification.ts +30 -0
- package/src/lib/server/daemon-state.ts +8 -0
- package/src/lib/server/storage.ts +27 -0
- package/src/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +58 -1
- package/src/types/index.ts +13 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import { saveNotification } from '@/lib/server/storage'
|
|
3
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
4
|
+
import type { AppNotification } from '@/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create and persist a notification, then push a WS invalidation.
|
|
8
|
+
*/
|
|
9
|
+
export function createNotification(opts: {
|
|
10
|
+
type: AppNotification['type']
|
|
11
|
+
title: string
|
|
12
|
+
message?: string
|
|
13
|
+
entityType?: string
|
|
14
|
+
entityId?: string
|
|
15
|
+
}) {
|
|
16
|
+
const id = genId()
|
|
17
|
+
const notification: AppNotification = {
|
|
18
|
+
id,
|
|
19
|
+
type: opts.type,
|
|
20
|
+
title: opts.title,
|
|
21
|
+
message: opts.message,
|
|
22
|
+
entityType: opts.entityType,
|
|
23
|
+
entityId: opts.entityId,
|
|
24
|
+
read: false,
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
}
|
|
27
|
+
saveNotification(id, notification)
|
|
28
|
+
notify('notifications')
|
|
29
|
+
return notification
|
|
30
|
+
}
|
|
@@ -17,6 +17,7 @@ import { enqueueSessionRun } from './session-run-manager'
|
|
|
17
17
|
import { WORKSPACE_DIR } from './data-dir'
|
|
18
18
|
import { genId } from '@/lib/id'
|
|
19
19
|
import type { WebhookRetryEntry } from '@/types'
|
|
20
|
+
import { createNotification } from '@/lib/server/create-notification'
|
|
20
21
|
|
|
21
22
|
const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
|
|
22
23
|
const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
|
|
@@ -254,6 +255,13 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
254
255
|
connectors[connector.id] = connector
|
|
255
256
|
saveConnectors(connectors)
|
|
256
257
|
ds.connectorRestartState.delete(connector.id)
|
|
258
|
+
createNotification({
|
|
259
|
+
type: 'error',
|
|
260
|
+
title: `Connector "${connector.name}" failed`,
|
|
261
|
+
message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures.`,
|
|
262
|
+
entityType: 'connector',
|
|
263
|
+
entityId: connector.id,
|
|
264
|
+
})
|
|
257
265
|
continue
|
|
258
266
|
}
|
|
259
267
|
|
|
@@ -51,6 +51,7 @@ const COLLECTIONS = [
|
|
|
51
51
|
'projects',
|
|
52
52
|
'activity',
|
|
53
53
|
'webhook_retry_queue',
|
|
54
|
+
'notifications',
|
|
54
55
|
] as const
|
|
55
56
|
|
|
56
57
|
for (const table of COLLECTIONS) {
|
|
@@ -775,6 +776,32 @@ export function deleteWebhookRetry(id: string) {
|
|
|
775
776
|
deleteCollectionItem('webhook_retry_queue', id)
|
|
776
777
|
}
|
|
777
778
|
|
|
779
|
+
// --- Notifications ---
|
|
780
|
+
export function loadNotifications(): Record<string, unknown> {
|
|
781
|
+
return loadCollection('notifications')
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function saveNotification(id: string, data: unknown) {
|
|
785
|
+
upsertCollectionItem('notifications', id, data)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export function deleteNotification(id: string) {
|
|
789
|
+
deleteCollectionItem('notifications', id)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function markNotificationRead(id: string) {
|
|
793
|
+
const raw = getCollectionRawCache('notifications')
|
|
794
|
+
const json = raw.get(id)
|
|
795
|
+
if (!json) return
|
|
796
|
+
try {
|
|
797
|
+
const notification = JSON.parse(json) as Record<string, unknown>
|
|
798
|
+
notification.read = true
|
|
799
|
+
upsertCollectionItem('notifications', id, notification)
|
|
800
|
+
} catch {
|
|
801
|
+
// ignore malformed
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
778
805
|
export function getSessionMessages(sessionId: string): Message[] {
|
|
779
806
|
const stmt = db.prepare('SELECT data FROM sessions WHERE id = ?')
|
|
780
807
|
const row = stmt.get(sessionId) as { data: string } | undefined
|
package/src/proxy.ts
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import type { NextRequest } from 'next/server'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Rate-limit state — HMR-safe via globalThis */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
|
|
8
|
+
interface RateLimitEntry {
|
|
9
|
+
count: number
|
|
10
|
+
lockedUntil: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const rateLimitMap = (
|
|
14
|
+
(globalThis as Record<string, unknown>).__swarmclaw_rate_limit__ ??= new Map()
|
|
15
|
+
) as Map<string, RateLimitEntry>
|
|
16
|
+
|
|
17
|
+
const MAX_ATTEMPTS = 5
|
|
18
|
+
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
|
|
19
|
+
const PRUNE_THRESHOLD = 1000
|
|
20
|
+
|
|
21
|
+
/** Prune expired entries when the map grows too large. */
|
|
22
|
+
function pruneRateLimitMap() {
|
|
23
|
+
if (rateLimitMap.size <= PRUNE_THRESHOLD) return
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
rateLimitMap.forEach((entry, ip) => {
|
|
26
|
+
if (entry.lockedUntil < now && entry.count < MAX_ATTEMPTS) {
|
|
27
|
+
rateLimitMap.delete(ip)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract client IP from the request. */
|
|
33
|
+
function getClientIp(request: NextRequest): string {
|
|
34
|
+
const forwarded = request.headers.get('x-forwarded-for')
|
|
35
|
+
if (forwarded) {
|
|
36
|
+
const first = forwarded.split(',')[0]?.trim()
|
|
37
|
+
if (first) return first
|
|
38
|
+
}
|
|
39
|
+
return (request as unknown as { ip?: string }).ip ?? 'unknown'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------------------------ */
|
|
43
|
+
/* Proxy */
|
|
44
|
+
/* ------------------------------------------------------------------ */
|
|
45
|
+
|
|
46
|
+
/** Access key auth proxy with brute-force rate limiting.
|
|
5
47
|
* Checks X-Access-Key header or ?key= param on all /api/ routes except /api/auth.
|
|
6
48
|
* The key is validated against the ACCESS_KEY env var.
|
|
49
|
+
* After 5 failed attempts from a single IP the client is locked out for 15 minutes.
|
|
7
50
|
*/
|
|
8
51
|
export function proxy(request: NextRequest) {
|
|
9
52
|
const { pathname } = request.nextUrl
|
|
@@ -29,13 +72,47 @@ export function proxy(request: NextRequest) {
|
|
|
29
72
|
return NextResponse.next()
|
|
30
73
|
}
|
|
31
74
|
|
|
75
|
+
// --- Rate-limit housekeeping ---
|
|
76
|
+
pruneRateLimitMap()
|
|
77
|
+
|
|
78
|
+
const clientIp = getClientIp(request)
|
|
79
|
+
const entry = rateLimitMap.get(clientIp)
|
|
80
|
+
|
|
81
|
+
// Check lockout before even validating the key
|
|
82
|
+
if (entry && entry.lockedUntil > Date.now()) {
|
|
83
|
+
const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: 'Too many failed attempts. Try again later.', retryAfter },
|
|
86
|
+
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
32
90
|
const providedKey =
|
|
33
91
|
request.headers.get('x-access-key')
|
|
34
92
|
|| request.nextUrl.searchParams.get('key')
|
|
35
93
|
|| ''
|
|
36
94
|
|
|
37
95
|
if (providedKey !== accessKey) {
|
|
38
|
-
|
|
96
|
+
// Record the failed attempt
|
|
97
|
+
const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
|
|
98
|
+
current.count += 1
|
|
99
|
+
|
|
100
|
+
if (current.count >= MAX_ATTEMPTS) {
|
|
101
|
+
current.lockedUntil = Date.now() + LOCKOUT_MS
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
rateLimitMap.set(clientIp, current)
|
|
105
|
+
|
|
106
|
+
const remaining = Math.max(0, MAX_ATTEMPTS - current.count)
|
|
107
|
+
return NextResponse.json(
|
|
108
|
+
{ error: 'Unauthorized' },
|
|
109
|
+
{ status: 401, headers: { 'X-RateLimit-Remaining': String(remaining) } },
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Successful auth — clear any prior failed-attempt tracking for this IP
|
|
114
|
+
if (entry) {
|
|
115
|
+
rateLimitMap.delete(clientIp)
|
|
39
116
|
}
|
|
40
117
|
|
|
41
118
|
return NextResponse.next()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { create } from 'zustand'
|
|
4
|
-
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry } from '../types'
|
|
4
|
+
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry, AppNotification } from '../types'
|
|
5
5
|
import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
|
|
6
6
|
import { fetchAgents } from '../lib/agents'
|
|
7
7
|
import { fetchSchedules } from '../lib/schedules'
|
|
@@ -184,6 +184,14 @@ interface AppState {
|
|
|
184
184
|
activityEntries: ActivityEntry[]
|
|
185
185
|
loadActivity: (filters?: { entityType?: string; limit?: number }) => Promise<void>
|
|
186
186
|
|
|
187
|
+
// Notifications
|
|
188
|
+
notifications: AppNotification[]
|
|
189
|
+
unreadNotificationCount: number
|
|
190
|
+
loadNotifications: () => Promise<void>
|
|
191
|
+
markNotificationRead: (id: string) => Promise<void>
|
|
192
|
+
markAllNotificationsRead: () => Promise<void>
|
|
193
|
+
clearReadNotifications: () => Promise<void>
|
|
194
|
+
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
export const useAppStore = create<AppState>((set, get) => ({
|
|
@@ -591,4 +599,53 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
591
599
|
}
|
|
592
600
|
},
|
|
593
601
|
|
|
602
|
+
// Notifications
|
|
603
|
+
notifications: [],
|
|
604
|
+
unreadNotificationCount: 0,
|
|
605
|
+
loadNotifications: async () => {
|
|
606
|
+
try {
|
|
607
|
+
const notifications = await api<AppNotification[]>('GET', '/notifications')
|
|
608
|
+
set({
|
|
609
|
+
notifications,
|
|
610
|
+
unreadNotificationCount: notifications.filter((n) => !n.read).length,
|
|
611
|
+
})
|
|
612
|
+
} catch {
|
|
613
|
+
// ignore
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
markNotificationRead: async (id) => {
|
|
617
|
+
const notifications = get().notifications.map((n) =>
|
|
618
|
+
n.id === id ? { ...n, read: true } : n,
|
|
619
|
+
)
|
|
620
|
+
set({
|
|
621
|
+
notifications,
|
|
622
|
+
unreadNotificationCount: notifications.filter((n) => !n.read).length,
|
|
623
|
+
})
|
|
624
|
+
try {
|
|
625
|
+
await api('PUT', `/notifications/${id}`, { read: true })
|
|
626
|
+
} catch {
|
|
627
|
+
// ignore
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
markAllNotificationsRead: async () => {
|
|
631
|
+
const notifications = get().notifications.map((n) => ({ ...n, read: true }))
|
|
632
|
+
set({ notifications, unreadNotificationCount: 0 })
|
|
633
|
+
try {
|
|
634
|
+
await Promise.all(
|
|
635
|
+
get().notifications.filter((n) => !n.read).map((n) => api('PUT', `/notifications/${n.id}`, { read: true })),
|
|
636
|
+
)
|
|
637
|
+
} catch {
|
|
638
|
+
// ignore
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
clearReadNotifications: async () => {
|
|
642
|
+
const notifications = get().notifications.filter((n) => !n.read)
|
|
643
|
+
set({ notifications, unreadNotificationCount: notifications.length })
|
|
644
|
+
try {
|
|
645
|
+
await api('DELETE', '/notifications')
|
|
646
|
+
} catch {
|
|
647
|
+
// ignore
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
|
|
594
651
|
}))
|
package/src/types/index.ts
CHANGED
|
@@ -375,6 +375,19 @@ export interface Project {
|
|
|
375
375
|
updatedAt: number
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
// --- Notifications ---
|
|
379
|
+
|
|
380
|
+
export interface AppNotification {
|
|
381
|
+
id: string
|
|
382
|
+
type: 'info' | 'success' | 'warning' | 'error'
|
|
383
|
+
title: string
|
|
384
|
+
message?: string
|
|
385
|
+
entityType?: string
|
|
386
|
+
entityId?: string
|
|
387
|
+
read: boolean
|
|
388
|
+
createdAt: number
|
|
389
|
+
}
|
|
390
|
+
|
|
378
391
|
// --- Session Runs ---
|
|
379
392
|
|
|
380
393
|
export type SessionRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|