@nextclaw/ui 0.5.4 → 0.5.6
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/CHANGELOG.md +13 -0
- package/dist/assets/index-B3foa-xK.css +1 -0
- package/dist/assets/index-CsMwBztg.js +347 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/api/config.ts +46 -1
- package/src/api/types.ts +50 -0
- package/src/components/config/CronConfig.tsx +292 -0
- package/src/components/config/SessionsConfig.tsx +17 -4
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/marketplace/MarketplacePage.tsx +10 -2
- package/src/components/ui/confirm-dialog.tsx +67 -0
- package/src/hooks/useConfig.ts +58 -1
- package/src/hooks/useConfirmDialog.tsx +83 -0
- package/src/lib/i18n.ts +27 -0
- package/dist/assets/index-BHGBLfqi.css +0 -1
- package/dist/assets/index-CWt_NEq3.js +0 -332
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw - 系统配置</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CsMwBztg.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B3foa-xK.css">
|
|
11
11
|
</head>
|
|
12
12
|
|
|
13
13
|
<body>
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { ProvidersList } from '@/components/config/ProvidersList';
|
|
|
5
5
|
import { ChannelsList } from '@/components/config/ChannelsList';
|
|
6
6
|
import { RuntimeConfig } from '@/components/config/RuntimeConfig';
|
|
7
7
|
import { SessionsConfig } from '@/components/config/SessionsConfig';
|
|
8
|
+
import { CronConfig } from '@/components/config/CronConfig';
|
|
8
9
|
import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
|
|
9
10
|
import { useWebSocket } from '@/hooks/useWebSocket';
|
|
10
11
|
import { Toaster } from 'sonner';
|
|
@@ -33,6 +34,7 @@ function AppContent() {
|
|
|
33
34
|
<Route path="/channels" element={<ChannelsList />} />
|
|
34
35
|
<Route path="/runtime" element={<RuntimeConfig />} />
|
|
35
36
|
<Route path="/sessions" element={<SessionsConfig />} />
|
|
37
|
+
<Route path="/cron" element={<CronConfig />} />
|
|
36
38
|
<Route path="/marketplace" element={<MarketplacePage />} />
|
|
37
39
|
<Route path="/" element={<Navigate to="/model" replace />} />
|
|
38
40
|
<Route path="*" element={<Navigate to="/model" replace />} />
|
package/src/api/config.ts
CHANGED
|
@@ -11,7 +11,11 @@ import type {
|
|
|
11
11
|
ConfigActionExecuteResult,
|
|
12
12
|
SessionsListView,
|
|
13
13
|
SessionHistoryView,
|
|
14
|
-
SessionPatchUpdate
|
|
14
|
+
SessionPatchUpdate,
|
|
15
|
+
CronListView,
|
|
16
|
+
CronEnableRequest,
|
|
17
|
+
CronRunRequest,
|
|
18
|
+
CronActionResult
|
|
15
19
|
} from './types';
|
|
16
20
|
|
|
17
21
|
// GET /api/config
|
|
@@ -162,3 +166,44 @@ export async function deleteSession(key: string): Promise<{ deleted: boolean }>
|
|
|
162
166
|
}
|
|
163
167
|
return response.data;
|
|
164
168
|
}
|
|
169
|
+
|
|
170
|
+
// GET /api/cron
|
|
171
|
+
export async function fetchCronJobs(params?: { all?: boolean }): Promise<CronListView> {
|
|
172
|
+
const query = new URLSearchParams();
|
|
173
|
+
if (params?.all) {
|
|
174
|
+
query.set('all', '1');
|
|
175
|
+
}
|
|
176
|
+
const suffix = query.toString();
|
|
177
|
+
const response = await api.get<CronListView>(suffix ? '/api/cron?' + suffix : '/api/cron');
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
throw new Error(response.error.message);
|
|
180
|
+
}
|
|
181
|
+
return response.data;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// DELETE /api/cron/:id
|
|
185
|
+
export async function deleteCronJob(id: string): Promise<{ deleted: boolean }> {
|
|
186
|
+
const response = await api.delete<{ deleted: boolean }>(`/api/cron/${encodeURIComponent(id)}`);
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(response.error.message);
|
|
189
|
+
}
|
|
190
|
+
return response.data;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// PUT /api/cron/:id/enable
|
|
194
|
+
export async function setCronJobEnabled(id: string, data: CronEnableRequest): Promise<CronActionResult> {
|
|
195
|
+
const response = await api.put<CronActionResult>(`/api/cron/${encodeURIComponent(id)}/enable`, data);
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(response.error.message);
|
|
198
|
+
}
|
|
199
|
+
return response.data;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// POST /api/cron/:id/run
|
|
203
|
+
export async function runCronJob(id: string, data: CronRunRequest): Promise<CronActionResult> {
|
|
204
|
+
const response = await api.post<CronActionResult>(`/api/cron/${encodeURIComponent(id)}/run`, data);
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(response.error.message);
|
|
207
|
+
}
|
|
208
|
+
return response.data;
|
|
209
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -92,6 +92,56 @@ export type SessionPatchUpdate = {
|
|
|
92
92
|
clearHistory?: boolean;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
export type CronScheduleView =
|
|
96
|
+
| { kind: "at"; atMs?: number | null }
|
|
97
|
+
| { kind: "every"; everyMs?: number | null }
|
|
98
|
+
| { kind: "cron"; expr?: string | null; tz?: string | null };
|
|
99
|
+
|
|
100
|
+
export type CronPayloadView = {
|
|
101
|
+
kind?: "system_event" | "agent_turn";
|
|
102
|
+
message: string;
|
|
103
|
+
deliver?: boolean;
|
|
104
|
+
channel?: string | null;
|
|
105
|
+
to?: string | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type CronJobStateView = {
|
|
109
|
+
nextRunAt?: string | null;
|
|
110
|
+
lastRunAt?: string | null;
|
|
111
|
+
lastStatus?: "ok" | "error" | "skipped" | null;
|
|
112
|
+
lastError?: string | null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type CronJobView = {
|
|
116
|
+
id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
enabled: boolean;
|
|
119
|
+
schedule: CronScheduleView;
|
|
120
|
+
payload: CronPayloadView;
|
|
121
|
+
state: CronJobStateView;
|
|
122
|
+
createdAt: string;
|
|
123
|
+
updatedAt: string;
|
|
124
|
+
deleteAfterRun: boolean;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type CronListView = {
|
|
128
|
+
jobs: CronJobView[];
|
|
129
|
+
total: number;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type CronEnableRequest = {
|
|
133
|
+
enabled: boolean;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export type CronRunRequest = {
|
|
137
|
+
force?: boolean;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type CronActionResult = {
|
|
141
|
+
job: CronJobView | null;
|
|
142
|
+
executed?: boolean;
|
|
143
|
+
};
|
|
144
|
+
|
|
95
145
|
export type RuntimeConfigUpdate = {
|
|
96
146
|
agents?: {
|
|
97
147
|
defaults?: {
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import type { CronJobView } from '@/api/types';
|
|
3
|
+
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
4
|
+
import { useCronJobs, useDeleteCronJob, useToggleCronJob, useRunCronJob } from '@/hooks/useConfig';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
9
|
+
import { cn } from '@/lib/utils';
|
|
10
|
+
import { t } from '@/lib/i18n';
|
|
11
|
+
import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
type StatusFilter = 'all' | 'enabled' | 'disabled';
|
|
14
|
+
|
|
15
|
+
function formatDate(value?: string | null): string {
|
|
16
|
+
if (!value) {
|
|
17
|
+
return '-';
|
|
18
|
+
}
|
|
19
|
+
const date = new Date(value);
|
|
20
|
+
if (Number.isNaN(date.getTime())) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return date.toLocaleString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatDateFromMs(value?: number | null): string {
|
|
27
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
28
|
+
return '-';
|
|
29
|
+
}
|
|
30
|
+
const date = new Date(value);
|
|
31
|
+
if (Number.isNaN(date.getTime())) {
|
|
32
|
+
return '-';
|
|
33
|
+
}
|
|
34
|
+
return date.toLocaleString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatEveryDuration(ms?: number | null): string {
|
|
38
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
39
|
+
return '-';
|
|
40
|
+
}
|
|
41
|
+
const seconds = Math.round(ms / 1000);
|
|
42
|
+
if (seconds < 60) return `${seconds}s`;
|
|
43
|
+
const minutes = Math.round(seconds / 60);
|
|
44
|
+
if (minutes < 60) return `${minutes}m`;
|
|
45
|
+
const hours = Math.round(minutes / 60);
|
|
46
|
+
if (hours < 24) return `${hours}h`;
|
|
47
|
+
const days = Math.round(hours / 24);
|
|
48
|
+
return `${days}d`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeSchedule(job: CronJobView): string {
|
|
52
|
+
const schedule = job.schedule;
|
|
53
|
+
if (schedule.kind === 'cron') {
|
|
54
|
+
return schedule.expr ? `cron ${schedule.expr}` : 'cron';
|
|
55
|
+
}
|
|
56
|
+
if (schedule.kind === 'every') {
|
|
57
|
+
return `every ${formatEveryDuration(schedule.everyMs)}`;
|
|
58
|
+
}
|
|
59
|
+
if (schedule.kind === 'at') {
|
|
60
|
+
return `at ${formatDateFromMs(schedule.atMs)}`;
|
|
61
|
+
}
|
|
62
|
+
return '-';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describeDelivery(job: CronJobView): string {
|
|
66
|
+
if (!job.payload.deliver) {
|
|
67
|
+
return '-';
|
|
68
|
+
}
|
|
69
|
+
const channel = job.payload.channel ?? '-';
|
|
70
|
+
const target = job.payload.to ?? '-';
|
|
71
|
+
return `${channel}:${target}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchQuery(job: CronJobView, query: string): boolean {
|
|
75
|
+
const q = query.trim().toLowerCase();
|
|
76
|
+
if (!q) return true;
|
|
77
|
+
const haystack = [
|
|
78
|
+
job.id,
|
|
79
|
+
job.name,
|
|
80
|
+
job.payload.message,
|
|
81
|
+
job.payload.channel ?? '',
|
|
82
|
+
job.payload.to ?? ''
|
|
83
|
+
].join(' ').toLowerCase();
|
|
84
|
+
return haystack.includes(q);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function filterByStatus(job: CronJobView, status: StatusFilter): boolean {
|
|
88
|
+
if (status === 'all') return true;
|
|
89
|
+
if (status === 'enabled') return job.enabled;
|
|
90
|
+
return !job.enabled;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CronConfig() {
|
|
94
|
+
const [query, setQuery] = useState('');
|
|
95
|
+
const [status, setStatus] = useState<StatusFilter>('all');
|
|
96
|
+
const cronQuery = useCronJobs({ all: true });
|
|
97
|
+
const deleteCronJob = useDeleteCronJob();
|
|
98
|
+
const toggleCronJob = useToggleCronJob();
|
|
99
|
+
const runCronJob = useRunCronJob();
|
|
100
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
101
|
+
|
|
102
|
+
const jobs = useMemo(() => {
|
|
103
|
+
const data = cronQuery.data?.jobs ?? [];
|
|
104
|
+
return data
|
|
105
|
+
.filter((job) => matchQuery(job, query))
|
|
106
|
+
.filter((job) => filterByStatus(job, status));
|
|
107
|
+
}, [cronQuery.data, query, status]);
|
|
108
|
+
|
|
109
|
+
const handleDelete = async (job: CronJobView) => {
|
|
110
|
+
const confirmed = await confirm({
|
|
111
|
+
title: `${t('cronDeleteConfirm')}?`,
|
|
112
|
+
description: job.name ? `${job.name} (${job.id})` : job.id,
|
|
113
|
+
variant: 'destructive',
|
|
114
|
+
confirmLabel: t('delete')
|
|
115
|
+
});
|
|
116
|
+
if (!confirmed) return;
|
|
117
|
+
deleteCronJob.mutate({ id: job.id });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleToggle = async (job: CronJobView) => {
|
|
121
|
+
const nextEnabled = !job.enabled;
|
|
122
|
+
const confirmed = await confirm({
|
|
123
|
+
title: nextEnabled ? `${t('cronEnableConfirm')}?` : `${t('cronDisableConfirm')}?`,
|
|
124
|
+
description: job.name ? `${job.name} (${job.id})` : job.id,
|
|
125
|
+
variant: nextEnabled ? 'default' : 'destructive',
|
|
126
|
+
confirmLabel: nextEnabled ? t('cronEnable') : t('cronDisable')
|
|
127
|
+
});
|
|
128
|
+
if (!confirmed) return;
|
|
129
|
+
toggleCronJob.mutate({ id: job.id, enabled: nextEnabled });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleRun = async (job: CronJobView) => {
|
|
133
|
+
const force = !job.enabled;
|
|
134
|
+
const confirmed = await confirm({
|
|
135
|
+
title: force ? `${t('cronRunForceConfirm')}?` : `${t('cronRunConfirm')}?`,
|
|
136
|
+
description: job.name ? `${job.name} (${job.id})` : job.id,
|
|
137
|
+
confirmLabel: t('cronRunNow')
|
|
138
|
+
});
|
|
139
|
+
if (!confirmed) return;
|
|
140
|
+
runCronJob.mutate({ id: job.id, force });
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="h-[calc(100vh-80px)] w-full max-w-[1200px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
|
|
145
|
+
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
146
|
+
<div>
|
|
147
|
+
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('cronPageTitle')}</h2>
|
|
148
|
+
<p className="text-sm text-gray-500 mt-1">{t('cronPageDescription')}</p>
|
|
149
|
+
</div>
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
className="h-9 w-9 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
|
154
|
+
onClick={() => cronQuery.refetch()}
|
|
155
|
+
>
|
|
156
|
+
<RefreshCw className={cn('h-4 w-4', cronQuery.isFetching && 'animate-spin')} />
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<Card hover={false} className="mb-6">
|
|
161
|
+
<CardContent className="pt-6">
|
|
162
|
+
<div className="flex flex-wrap gap-3 items-center">
|
|
163
|
+
<div className="relative flex-1 min-w-[240px]">
|
|
164
|
+
<Input
|
|
165
|
+
value={query}
|
|
166
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
167
|
+
placeholder={t('cronSearchPlaceholder')}
|
|
168
|
+
className="pl-9"
|
|
169
|
+
/>
|
|
170
|
+
<AlarmClock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
171
|
+
</div>
|
|
172
|
+
<div className="min-w-[180px]">
|
|
173
|
+
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
|
|
174
|
+
<SelectTrigger className="w-full">
|
|
175
|
+
<SelectValue placeholder={t('cronStatusLabel')} />
|
|
176
|
+
</SelectTrigger>
|
|
177
|
+
<SelectContent>
|
|
178
|
+
<SelectItem value="all">{t('cronStatusAll')}</SelectItem>
|
|
179
|
+
<SelectItem value="enabled">{t('cronStatusEnabled')}</SelectItem>
|
|
180
|
+
<SelectItem value="disabled">{t('cronStatusDisabled')}</SelectItem>
|
|
181
|
+
</SelectContent>
|
|
182
|
+
</Select>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="text-xs text-gray-500 ml-auto">
|
|
185
|
+
{t('cronTotalLabel')}: {cronQuery.data?.total ?? 0} / {jobs.length}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</CardContent>
|
|
189
|
+
</Card>
|
|
190
|
+
|
|
191
|
+
<div className="flex-1 overflow-auto custom-scrollbar">
|
|
192
|
+
{cronQuery.isLoading ? (
|
|
193
|
+
<div className="text-sm text-gray-400 p-4 text-center">{t('cronLoading')}</div>
|
|
194
|
+
) : jobs.length === 0 ? (
|
|
195
|
+
<div className="text-sm text-gray-400 p-4 text-center">{t('cronEmpty')}</div>
|
|
196
|
+
) : (
|
|
197
|
+
<div className="space-y-4">
|
|
198
|
+
{jobs.map((job) => (
|
|
199
|
+
<Card key={job.id} className="border border-gray-200">
|
|
200
|
+
<CardContent className="pt-5 pb-5">
|
|
201
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
202
|
+
<div className="min-w-[220px] flex-1">
|
|
203
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
204
|
+
<span className="text-sm font-semibold text-gray-900">
|
|
205
|
+
{job.name || job.id}
|
|
206
|
+
</span>
|
|
207
|
+
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
|
|
208
|
+
{job.id}
|
|
209
|
+
</span>
|
|
210
|
+
<span
|
|
211
|
+
className={cn(
|
|
212
|
+
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
|
213
|
+
job.enabled ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{job.enabled ? t('enabled') : t('disabled')}
|
|
217
|
+
</span>
|
|
218
|
+
{job.deleteAfterRun && (
|
|
219
|
+
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700">
|
|
220
|
+
{t('cronOneShot')}
|
|
221
|
+
</span>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
<div className="mt-2 text-xs text-gray-500">
|
|
225
|
+
{t('cronScheduleLabel')}: {describeSchedule(job)}
|
|
226
|
+
</div>
|
|
227
|
+
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap break-words">
|
|
228
|
+
{job.payload.message}
|
|
229
|
+
</div>
|
|
230
|
+
<div className="mt-2 text-xs text-gray-500">
|
|
231
|
+
{t('cronDeliverTo')}: {describeDelivery(job)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="min-w-[220px] text-xs text-gray-500 space-y-2">
|
|
235
|
+
<div>
|
|
236
|
+
<span className="font-medium text-gray-700">{t('cronNextRun')}:</span>{' '}
|
|
237
|
+
{formatDate(job.state.nextRunAt)}
|
|
238
|
+
</div>
|
|
239
|
+
<div>
|
|
240
|
+
<span className="font-medium text-gray-700">{t('cronLastRun')}:</span>{' '}
|
|
241
|
+
{formatDate(job.state.lastRunAt)}
|
|
242
|
+
</div>
|
|
243
|
+
<div>
|
|
244
|
+
<span className="font-medium text-gray-700">{t('cronLastStatus')}:</span>{' '}
|
|
245
|
+
{job.state.lastStatus ?? '-'}
|
|
246
|
+
</div>
|
|
247
|
+
{job.state.lastError && (
|
|
248
|
+
<div className="text-[11px] text-red-500 break-words">
|
|
249
|
+
{job.state.lastError}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
<div className="flex items-start gap-2 flex-wrap justify-end">
|
|
254
|
+
<Button
|
|
255
|
+
variant="subtle"
|
|
256
|
+
size="sm"
|
|
257
|
+
onClick={() => handleRun(job)}
|
|
258
|
+
className="gap-1"
|
|
259
|
+
>
|
|
260
|
+
<Play className="h-3.5 w-3.5" />
|
|
261
|
+
{t('cronRunNow')}
|
|
262
|
+
</Button>
|
|
263
|
+
<Button
|
|
264
|
+
variant={job.enabled ? 'outline' : 'primary'}
|
|
265
|
+
size="sm"
|
|
266
|
+
onClick={() => handleToggle(job)}
|
|
267
|
+
className="gap-1"
|
|
268
|
+
>
|
|
269
|
+
<Power className="h-3.5 w-3.5" />
|
|
270
|
+
{job.enabled ? t('cronDisable') : t('cronEnable')}
|
|
271
|
+
</Button>
|
|
272
|
+
<Button
|
|
273
|
+
variant="destructive"
|
|
274
|
+
size="sm"
|
|
275
|
+
onClick={() => handleDelete(job)}
|
|
276
|
+
className="gap-1"
|
|
277
|
+
>
|
|
278
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
279
|
+
{t('delete')}
|
|
280
|
+
</Button>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</CardContent>
|
|
284
|
+
</Card>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
<ConfirmDialog />
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import type { SessionEntryView, SessionMessageView } from '@/api/types';
|
|
3
|
+
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
3
4
|
import { useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
|
|
4
5
|
import { Button } from '@/components/ui/button';
|
|
5
6
|
import { Input } from '@/components/ui/input';
|
|
@@ -143,6 +144,7 @@ export function SessionsConfig() {
|
|
|
143
144
|
|
|
144
145
|
const updateSession = useUpdateSession();
|
|
145
146
|
const deleteSession = useDeleteSession();
|
|
147
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
146
148
|
|
|
147
149
|
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
148
150
|
const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
|
|
@@ -188,16 +190,26 @@ export function SessionsConfig() {
|
|
|
188
190
|
setIsEditingMeta(false); // Close editor on save
|
|
189
191
|
};
|
|
190
192
|
|
|
191
|
-
const handleClearHistory = () => {
|
|
193
|
+
const handleClearHistory = async () => {
|
|
192
194
|
if (!selectedKey) return;
|
|
193
|
-
|
|
195
|
+
const confirmed = await confirm({
|
|
196
|
+
title: t('sessionsClearHistory') + '?',
|
|
197
|
+
variant: 'destructive',
|
|
198
|
+
confirmLabel: t('sessionsClearHistory')
|
|
199
|
+
});
|
|
200
|
+
if (confirmed) {
|
|
194
201
|
updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
|
|
195
202
|
}
|
|
196
203
|
};
|
|
197
204
|
|
|
198
|
-
const handleDeleteSession = () => {
|
|
205
|
+
const handleDeleteSession = async () => {
|
|
199
206
|
if (!selectedKey) return;
|
|
200
|
-
|
|
207
|
+
const confirmed = await confirm({
|
|
208
|
+
title: t('sessionsDeleteConfirm') + '?',
|
|
209
|
+
variant: 'destructive',
|
|
210
|
+
confirmLabel: t('sessionsDeleteConfirm')
|
|
211
|
+
});
|
|
212
|
+
if (confirmed) {
|
|
201
213
|
deleteSession.mutate(
|
|
202
214
|
{ key: selectedKey },
|
|
203
215
|
{
|
|
@@ -393,6 +405,7 @@ export function SessionsConfig() {
|
|
|
393
405
|
)}
|
|
394
406
|
</div>
|
|
395
407
|
</div>
|
|
408
|
+
<ConfirmDialog />
|
|
396
409
|
</div>
|
|
397
410
|
);
|
|
398
411
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cn } from '@/lib/utils';
|
|
2
2
|
import { t } from '@/lib/i18n';
|
|
3
|
-
import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store } from 'lucide-react';
|
|
3
|
+
import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store, AlarmClock } from 'lucide-react';
|
|
4
4
|
import { NavLink } from 'react-router-dom';
|
|
5
5
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
6
6
|
|
|
@@ -30,6 +30,11 @@ const navItems = [
|
|
|
30
30
|
label: t('sessions'),
|
|
31
31
|
icon: History,
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
target: '/cron',
|
|
35
|
+
label: t('cron'),
|
|
36
|
+
icon: AlarmClock,
|
|
37
|
+
},
|
|
33
38
|
{
|
|
34
39
|
target: '/marketplace',
|
|
35
40
|
label: 'Marketplace',
|
|
@@ -3,6 +3,7 @@ import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceMan
|
|
|
3
3
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
4
4
|
import { Tabs } from '@/components/ui/tabs-custom';
|
|
5
5
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
6
|
+
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
6
7
|
import {
|
|
7
8
|
useInstallMarketplaceItem,
|
|
8
9
|
useManageMarketplaceItem,
|
|
@@ -391,6 +392,7 @@ export function MarketplacePage() {
|
|
|
391
392
|
|
|
392
393
|
const installMutation = useInstallMarketplaceItem();
|
|
393
394
|
const manageMutation = useManageMarketplaceItem();
|
|
395
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
394
396
|
|
|
395
397
|
const installedRecords = useMemo(
|
|
396
398
|
() => installedQuery.data?.records ?? [],
|
|
@@ -479,7 +481,7 @@ export function MarketplacePage() {
|
|
|
479
481
|
installMutation.mutate({ type: item.type, spec: item.install.spec, kind: item.install.kind });
|
|
480
482
|
};
|
|
481
483
|
|
|
482
|
-
const handleManage = (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
|
|
484
|
+
const handleManage = async (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
|
|
483
485
|
if (manageMutation.isPending) {
|
|
484
486
|
return;
|
|
485
487
|
}
|
|
@@ -490,7 +492,12 @@ export function MarketplacePage() {
|
|
|
490
492
|
}
|
|
491
493
|
|
|
492
494
|
if (action === 'uninstall') {
|
|
493
|
-
const confirmed =
|
|
495
|
+
const confirmed = await confirm({
|
|
496
|
+
title: `Uninstall ${targetId}?`,
|
|
497
|
+
description: 'This will remove the extension. You can install it again from the marketplace.',
|
|
498
|
+
confirmLabel: 'Uninstall',
|
|
499
|
+
variant: 'destructive'
|
|
500
|
+
});
|
|
494
501
|
if (!confirmed) {
|
|
495
502
|
return;
|
|
496
503
|
}
|
|
@@ -597,6 +604,7 @@ export function MarketplacePage() {
|
|
|
597
604
|
onNext={() => setPage((current) => (totalPages > 0 ? Math.min(totalPages, current + 1) : current + 1))}
|
|
598
605
|
/>
|
|
599
606
|
)}
|
|
607
|
+
<ConfirmDialog />
|
|
600
608
|
</div>
|
|
601
609
|
);
|
|
602
610
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle
|
|
9
|
+
} from '@/components/ui/dialog';
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
|
|
12
|
+
export type ConfirmDialogVariant = 'default' | 'destructive';
|
|
13
|
+
|
|
14
|
+
export type ConfirmDialogProps = {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onOpenChange: (open: boolean) => void;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
confirmLabel?: string;
|
|
20
|
+
cancelLabel?: string;
|
|
21
|
+
variant?: ConfirmDialogVariant;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ConfirmDialog = ({
|
|
27
|
+
open,
|
|
28
|
+
onOpenChange,
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
confirmLabel = 'Confirm',
|
|
32
|
+
cancelLabel = 'Cancel',
|
|
33
|
+
variant = 'default',
|
|
34
|
+
onConfirm,
|
|
35
|
+
onCancel
|
|
36
|
+
}: ConfirmDialogProps) => {
|
|
37
|
+
const handleConfirm = () => {
|
|
38
|
+
onConfirm();
|
|
39
|
+
onOpenChange(false);
|
|
40
|
+
};
|
|
41
|
+
const handleCancel = () => {
|
|
42
|
+
onCancel();
|
|
43
|
+
onOpenChange(false);
|
|
44
|
+
};
|
|
45
|
+
return (
|
|
46
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
47
|
+
<DialogContent className="[&>:last-child]:hidden" onCloseAutoFocus={(e) => e.preventDefault()}>
|
|
48
|
+
<DialogHeader>
|
|
49
|
+
<DialogTitle>{title}</DialogTitle>
|
|
50
|
+
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
|
51
|
+
</DialogHeader>
|
|
52
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
53
|
+
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
54
|
+
{cancelLabel}
|
|
55
|
+
</Button>
|
|
56
|
+
<Button
|
|
57
|
+
type="button"
|
|
58
|
+
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
|
59
|
+
onClick={handleConfirm}
|
|
60
|
+
>
|
|
61
|
+
{confirmLabel}
|
|
62
|
+
</Button>
|
|
63
|
+
</DialogFooter>
|
|
64
|
+
</DialogContent>
|
|
65
|
+
</Dialog>
|
|
66
|
+
);
|
|
67
|
+
};
|