@hed-hog/core 0.0.331 → 0.0.332
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/dist/route/route.service.d.ts.map +1 -1
- package/dist/route/route.service.js +14 -0
- package/dist/route/route.service.js.map +1 -1
- package/hedhog/data/menu.yaml +18 -4
- package/hedhog/frontend/app/routes/page.tsx.ejs +1339 -0
- package/hedhog/frontend/messages/en.json +74 -0
- package/hedhog/frontend/messages/pt.json +74 -0
- package/package.json +4 -4
- package/src/route/route.service.ts +14 -0
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EmptyState,
|
|
5
|
+
Page,
|
|
6
|
+
PageHeader,
|
|
7
|
+
PaginationFooter,
|
|
8
|
+
SearchBar,
|
|
9
|
+
} from '@/components/entity-list';
|
|
10
|
+
import { ViewModeToggle } from '@/components/entity-list/view-mode-toggle';
|
|
11
|
+
import {
|
|
12
|
+
AlertDialog,
|
|
13
|
+
AlertDialogAction,
|
|
14
|
+
AlertDialogCancel,
|
|
15
|
+
AlertDialogContent,
|
|
16
|
+
AlertDialogDescription,
|
|
17
|
+
AlertDialogFooter,
|
|
18
|
+
AlertDialogHeader,
|
|
19
|
+
AlertDialogTitle,
|
|
20
|
+
} from '@/components/ui/alert-dialog';
|
|
21
|
+
import { Badge } from '@/components/ui/badge';
|
|
22
|
+
import { Button } from '@/components/ui/button';
|
|
23
|
+
import {
|
|
24
|
+
Card,
|
|
25
|
+
CardContent,
|
|
26
|
+
CardDescription,
|
|
27
|
+
CardHeader,
|
|
28
|
+
CardTitle,
|
|
29
|
+
} from '@/components/ui/card';
|
|
30
|
+
import {
|
|
31
|
+
Form,
|
|
32
|
+
FormControl,
|
|
33
|
+
FormField,
|
|
34
|
+
FormItem,
|
|
35
|
+
FormLabel,
|
|
36
|
+
FormMessage,
|
|
37
|
+
} from '@/components/ui/form';
|
|
38
|
+
import { Input } from '@/components/ui/input';
|
|
39
|
+
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
40
|
+
import {
|
|
41
|
+
Select,
|
|
42
|
+
SelectContent,
|
|
43
|
+
SelectItem,
|
|
44
|
+
SelectTrigger,
|
|
45
|
+
SelectValue,
|
|
46
|
+
} from '@/components/ui/select';
|
|
47
|
+
import {
|
|
48
|
+
Sheet,
|
|
49
|
+
SheetContent,
|
|
50
|
+
SheetDescription,
|
|
51
|
+
SheetHeader,
|
|
52
|
+
SheetTitle,
|
|
53
|
+
} from '@/components/ui/sheet';
|
|
54
|
+
import { Switch } from '@/components/ui/switch';
|
|
55
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
56
|
+
import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
|
|
57
|
+
import { cn } from '@/lib/utils';
|
|
58
|
+
import { Route as ApiRoute, Screen } from '@hed-hog/api-types';
|
|
59
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
60
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
61
|
+
import {
|
|
62
|
+
Bot,
|
|
63
|
+
FileText,
|
|
64
|
+
Globe,
|
|
65
|
+
Layers3,
|
|
66
|
+
Loader2,
|
|
67
|
+
Plus,
|
|
68
|
+
Route as RouteIcon,
|
|
69
|
+
Save,
|
|
70
|
+
ShieldCheck,
|
|
71
|
+
Trash2,
|
|
72
|
+
Wrench,
|
|
73
|
+
} from 'lucide-react';
|
|
74
|
+
import { useTranslations } from 'next-intl';
|
|
75
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
76
|
+
import { useForm } from 'react-hook-form';
|
|
77
|
+
import { toast } from 'sonner';
|
|
78
|
+
import { z } from 'zod';
|
|
79
|
+
|
|
80
|
+
type PaginatedResponse<T> = {
|
|
81
|
+
data: T[];
|
|
82
|
+
total: number;
|
|
83
|
+
page: number;
|
|
84
|
+
pageSize: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type RouteType = 'HTTP' | 'MCP';
|
|
88
|
+
|
|
89
|
+
type RouteItem = ApiRoute & {
|
|
90
|
+
id: number;
|
|
91
|
+
type?: RouteType;
|
|
92
|
+
tool_name?: string | null;
|
|
93
|
+
name?: string | null;
|
|
94
|
+
role_route?: Array<{ route_id: number; role_id: number }>;
|
|
95
|
+
route_screen?: Array<{ route_id: number; screen_id: number }>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type RoleItem = {
|
|
99
|
+
id: number;
|
|
100
|
+
slug: string;
|
|
101
|
+
name?: string;
|
|
102
|
+
role_locale?: Array<{
|
|
103
|
+
name?: string;
|
|
104
|
+
description?: string;
|
|
105
|
+
}>;
|
|
106
|
+
role_route?: Array<{
|
|
107
|
+
route_id: number;
|
|
108
|
+
role_id: number;
|
|
109
|
+
}>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type ScreenItem = Screen & {
|
|
113
|
+
id: number;
|
|
114
|
+
route_screen?: Array<{
|
|
115
|
+
route_id: number;
|
|
116
|
+
screen_id: number;
|
|
117
|
+
}>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type RouteFormValues = {
|
|
121
|
+
type: RouteType;
|
|
122
|
+
url: string;
|
|
123
|
+
method: string;
|
|
124
|
+
tool_name: string;
|
|
125
|
+
name: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const HTTP_METHODS = [
|
|
129
|
+
'GET',
|
|
130
|
+
'POST',
|
|
131
|
+
'PUT',
|
|
132
|
+
'PATCH',
|
|
133
|
+
'DELETE',
|
|
134
|
+
'OPTIONS',
|
|
135
|
+
'HEAD',
|
|
136
|
+
] as const;
|
|
137
|
+
const ALL_FILTER = '__all__';
|
|
138
|
+
|
|
139
|
+
function getErrorMessage(error: unknown, fallbackMessage: string) {
|
|
140
|
+
if (error && typeof error === 'object') {
|
|
141
|
+
const record = error as {
|
|
142
|
+
message?: string;
|
|
143
|
+
response?: {
|
|
144
|
+
data?: {
|
|
145
|
+
message?: string;
|
|
146
|
+
error?: string;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
record.response?.data?.message ||
|
|
153
|
+
record.response?.data?.error ||
|
|
154
|
+
record.message ||
|
|
155
|
+
fallbackMessage
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return fallbackMessage;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeRouteType(route: Partial<RouteItem>): RouteType {
|
|
163
|
+
if (route.type === 'MCP' || route.tool_name) {
|
|
164
|
+
return 'MCP';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 'HTTP';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getRouteTitle(route: RouteItem) {
|
|
171
|
+
const type = normalizeRouteType(route);
|
|
172
|
+
|
|
173
|
+
if (type === 'MCP') {
|
|
174
|
+
return route.name?.trim() || route.tool_name?.trim() || `#${route.id}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return route.url || `#${route.id}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getRouteSubtitle(route: RouteItem) {
|
|
181
|
+
const type = normalizeRouteType(route);
|
|
182
|
+
|
|
183
|
+
if (type === 'MCP') {
|
|
184
|
+
return route.tool_name?.trim() || '—';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return route.method || '—';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getMethodBadgeClass(method?: string | null) {
|
|
191
|
+
const normalized = (method || '').toUpperCase();
|
|
192
|
+
|
|
193
|
+
if (normalized === 'GET') return 'bg-blue-50 text-blue-700 border-blue-200';
|
|
194
|
+
if (normalized === 'POST')
|
|
195
|
+
return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
|
196
|
+
if (normalized === 'PUT')
|
|
197
|
+
return 'bg-amber-50 text-amber-700 border-amber-200';
|
|
198
|
+
if (normalized === 'PATCH')
|
|
199
|
+
return 'bg-violet-50 text-violet-700 border-violet-200';
|
|
200
|
+
if (normalized === 'DELETE')
|
|
201
|
+
return 'bg-rose-50 text-rose-700 border-rose-200';
|
|
202
|
+
|
|
203
|
+
return 'bg-slate-50 text-slate-700 border-slate-200';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function RouteRolesSection({ routeId }: { routeId: number }) {
|
|
207
|
+
const t = useTranslations('core.RoutePage');
|
|
208
|
+
const { request, currentLocaleCode } = useApp();
|
|
209
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
210
|
+
const [togglingId, setTogglingId] = useState<number | null>(null);
|
|
211
|
+
|
|
212
|
+
const { data, isLoading, refetch } = useQuery<PaginatedResponse<RoleItem>>({
|
|
213
|
+
queryKey: ['route-roles', routeId, currentLocaleCode],
|
|
214
|
+
queryFn: async () => {
|
|
215
|
+
const response = await request<PaginatedResponse<RoleItem>>({
|
|
216
|
+
url: `/route/${routeId}/role?pageSize=10000`,
|
|
217
|
+
method: 'GET',
|
|
218
|
+
});
|
|
219
|
+
return response.data;
|
|
220
|
+
},
|
|
221
|
+
enabled: !!routeId,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const roles = data?.data ?? [];
|
|
225
|
+
|
|
226
|
+
const filteredRoles = useMemo(() => {
|
|
227
|
+
const normalized = searchQuery.trim().toLowerCase();
|
|
228
|
+
|
|
229
|
+
if (!normalized) return roles;
|
|
230
|
+
|
|
231
|
+
return roles.filter((role) => {
|
|
232
|
+
const name = role.role_locale?.[0]?.name ?? role.name ?? '';
|
|
233
|
+
const description = role.role_locale?.[0]?.description ?? '';
|
|
234
|
+
|
|
235
|
+
return [name, role.slug, description]
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.some((value) => value.toLowerCase().includes(normalized));
|
|
238
|
+
});
|
|
239
|
+
}, [roles, searchQuery]);
|
|
240
|
+
|
|
241
|
+
const assignedCount = roles.filter((role) =>
|
|
242
|
+
Boolean(role.role_route?.length)
|
|
243
|
+
).length;
|
|
244
|
+
|
|
245
|
+
const toggleRole = async (roleIdToToggle: number, assigned: boolean) => {
|
|
246
|
+
setTogglingId(roleIdToToggle);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const currentIds = roles
|
|
250
|
+
.filter((role) => Boolean(role.role_route?.length))
|
|
251
|
+
.map((role) => role.id);
|
|
252
|
+
|
|
253
|
+
const nextIds = assigned
|
|
254
|
+
? currentIds.filter((id) => id !== roleIdToToggle)
|
|
255
|
+
: [...new Set([...currentIds, roleIdToToggle])];
|
|
256
|
+
|
|
257
|
+
await request({
|
|
258
|
+
url: `/route/${routeId}/role`,
|
|
259
|
+
method: 'PATCH',
|
|
260
|
+
data: { ids: nextIds },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
toast.success(assigned ? t('roleRemoved') : t('roleAssigned'));
|
|
264
|
+
await refetch();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
toast.error(
|
|
267
|
+
getErrorMessage(
|
|
268
|
+
error,
|
|
269
|
+
assigned ? t('errorRemovingRole') : t('errorAssigningRole')
|
|
270
|
+
)
|
|
271
|
+
);
|
|
272
|
+
} finally {
|
|
273
|
+
setTogglingId(null);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (isLoading) {
|
|
278
|
+
return (
|
|
279
|
+
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
|
280
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
281
|
+
{t('loadingRoles')}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className="space-y-4">
|
|
288
|
+
<div className="rounded-xl border bg-muted/20 p-3">
|
|
289
|
+
<div className="flex items-center justify-between gap-3">
|
|
290
|
+
<div>
|
|
291
|
+
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
|
292
|
+
<ShieldCheck className="h-4 w-4" />
|
|
293
|
+
{t('rolesTitle')}
|
|
294
|
+
</h4>
|
|
295
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
296
|
+
{t('rolesDescription')}
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
<Badge variant="secondary" className="rounded-full px-2.5 py-1">
|
|
300
|
+
{assignedCount}
|
|
301
|
+
</Badge>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<Input
|
|
306
|
+
value={searchQuery}
|
|
307
|
+
onChange={(event) => setSearchQuery(event.target.value)}
|
|
308
|
+
placeholder={t('searchRoles')}
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
{filteredRoles.length ? (
|
|
312
|
+
<div className="space-y-2">
|
|
313
|
+
{filteredRoles.map((role) => {
|
|
314
|
+
const assigned = Boolean(role.role_route?.length);
|
|
315
|
+
const label = role.role_locale?.[0]?.name ?? role.name ?? role.slug;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div
|
|
319
|
+
key={role.id}
|
|
320
|
+
className={cn(
|
|
321
|
+
'flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
|
322
|
+
assigned
|
|
323
|
+
? 'border-primary/40 bg-primary/5'
|
|
324
|
+
: 'hover:border-primary/30'
|
|
325
|
+
)}
|
|
326
|
+
>
|
|
327
|
+
<div className="min-w-0">
|
|
328
|
+
<div className="truncate text-sm font-medium">{label}</div>
|
|
329
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
330
|
+
{role.role_locale?.[0]?.description || role.slug}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
<Switch
|
|
334
|
+
checked={assigned}
|
|
335
|
+
disabled={togglingId === role.id}
|
|
336
|
+
onCheckedChange={() => toggleRole(role.id, assigned)}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
})}
|
|
341
|
+
</div>
|
|
342
|
+
) : (
|
|
343
|
+
<EmptyState
|
|
344
|
+
className="min-h-52"
|
|
345
|
+
icon={<ShieldCheck className="h-10 w-10" />}
|
|
346
|
+
title={t('noRolesFound')}
|
|
347
|
+
description={t('rolesDescription')}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function RouteScreensSection({ routeId }: { routeId: number }) {
|
|
355
|
+
const t = useTranslations('core.RoutePage');
|
|
356
|
+
const { request, currentLocaleCode } = useApp();
|
|
357
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
358
|
+
const [togglingId, setTogglingId] = useState<number | null>(null);
|
|
359
|
+
|
|
360
|
+
const { data, isLoading, refetch } = useQuery<PaginatedResponse<ScreenItem>>({
|
|
361
|
+
queryKey: ['route-screens', routeId, currentLocaleCode],
|
|
362
|
+
queryFn: async () => {
|
|
363
|
+
const response = await request<PaginatedResponse<ScreenItem>>({
|
|
364
|
+
url: `/route/${routeId}/screen?pageSize=10000`,
|
|
365
|
+
method: 'GET',
|
|
366
|
+
});
|
|
367
|
+
return response.data;
|
|
368
|
+
},
|
|
369
|
+
enabled: !!routeId,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const screens = data?.data ?? [];
|
|
373
|
+
|
|
374
|
+
const filteredScreens = useMemo(() => {
|
|
375
|
+
const normalized = searchQuery.trim().toLowerCase();
|
|
376
|
+
|
|
377
|
+
if (!normalized) return screens;
|
|
378
|
+
|
|
379
|
+
return screens.filter((screen) => {
|
|
380
|
+
const name = screen.screen_locale?.[0]?.name ?? screen.name ?? '';
|
|
381
|
+
const description =
|
|
382
|
+
screen.screen_locale?.[0]?.description ?? screen.description ?? '';
|
|
383
|
+
|
|
384
|
+
return [name, screen.slug, description]
|
|
385
|
+
.filter(Boolean)
|
|
386
|
+
.some((value) => value.toLowerCase().includes(normalized));
|
|
387
|
+
});
|
|
388
|
+
}, [screens, searchQuery]);
|
|
389
|
+
|
|
390
|
+
const assignedCount = screens.filter((screen) =>
|
|
391
|
+
Boolean(screen.route_screen?.length)
|
|
392
|
+
).length;
|
|
393
|
+
|
|
394
|
+
const toggleScreen = async (screenIdToToggle: number, assigned: boolean) => {
|
|
395
|
+
setTogglingId(screenIdToToggle);
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const currentIds = screens
|
|
399
|
+
.filter((screen) => Boolean(screen.route_screen?.length))
|
|
400
|
+
.map((screen) => screen.id);
|
|
401
|
+
|
|
402
|
+
const nextIds = assigned
|
|
403
|
+
? currentIds.filter((id) => id !== screenIdToToggle)
|
|
404
|
+
: [...new Set([...currentIds, screenIdToToggle])];
|
|
405
|
+
|
|
406
|
+
await request({
|
|
407
|
+
url: `/route/${routeId}/screen`,
|
|
408
|
+
method: 'PATCH',
|
|
409
|
+
data: { ids: nextIds },
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
toast.success(assigned ? t('screenRemoved') : t('screenAssigned'));
|
|
413
|
+
await refetch();
|
|
414
|
+
} catch (error) {
|
|
415
|
+
toast.error(
|
|
416
|
+
getErrorMessage(
|
|
417
|
+
error,
|
|
418
|
+
assigned ? t('errorRemovingScreen') : t('errorAssigningScreen')
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
} finally {
|
|
422
|
+
setTogglingId(null);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
if (isLoading) {
|
|
427
|
+
return (
|
|
428
|
+
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
|
429
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
430
|
+
{t('loadingScreens')}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div className="space-y-4">
|
|
437
|
+
<div className="rounded-xl border bg-muted/20 p-3">
|
|
438
|
+
<div className="flex items-center justify-between gap-3">
|
|
439
|
+
<div>
|
|
440
|
+
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
|
441
|
+
<Layers3 className="h-4 w-4" />
|
|
442
|
+
{t('screensTitle')}
|
|
443
|
+
</h4>
|
|
444
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
445
|
+
{t('screensDescription')}
|
|
446
|
+
</p>
|
|
447
|
+
</div>
|
|
448
|
+
<Badge variant="secondary" className="rounded-full px-2.5 py-1">
|
|
449
|
+
{assignedCount}
|
|
450
|
+
</Badge>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<Input
|
|
455
|
+
value={searchQuery}
|
|
456
|
+
onChange={(event) => setSearchQuery(event.target.value)}
|
|
457
|
+
placeholder={t('searchScreens')}
|
|
458
|
+
/>
|
|
459
|
+
|
|
460
|
+
{filteredScreens.length ? (
|
|
461
|
+
<div className="space-y-2">
|
|
462
|
+
{filteredScreens.map((screen) => {
|
|
463
|
+
const assigned = Boolean(screen.route_screen?.length);
|
|
464
|
+
const label =
|
|
465
|
+
screen.screen_locale?.[0]?.name ?? screen.name ?? screen.slug;
|
|
466
|
+
const description =
|
|
467
|
+
screen.screen_locale?.[0]?.description ??
|
|
468
|
+
screen.description ??
|
|
469
|
+
screen.slug;
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<div
|
|
473
|
+
key={screen.id}
|
|
474
|
+
className={cn(
|
|
475
|
+
'flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
|
476
|
+
assigned
|
|
477
|
+
? 'border-primary/40 bg-primary/5'
|
|
478
|
+
: 'hover:border-primary/30'
|
|
479
|
+
)}
|
|
480
|
+
>
|
|
481
|
+
<div className="min-w-0">
|
|
482
|
+
<div className="truncate text-sm font-medium">{label}</div>
|
|
483
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
484
|
+
{description}
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
<Switch
|
|
488
|
+
checked={assigned}
|
|
489
|
+
disabled={togglingId === screen.id}
|
|
490
|
+
onCheckedChange={() => toggleScreen(screen.id, assigned)}
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
})}
|
|
495
|
+
</div>
|
|
496
|
+
) : (
|
|
497
|
+
<EmptyState
|
|
498
|
+
className="min-h-52"
|
|
499
|
+
icon={<Layers3 className="h-10 w-10" />}
|
|
500
|
+
title={t('noScreensFound')}
|
|
501
|
+
description={t('screensDescription')}
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default function RoutePage() {
|
|
509
|
+
const t = useTranslations('core.RoutePage');
|
|
510
|
+
const { request, currentLocaleCode } = useApp();
|
|
511
|
+
|
|
512
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
513
|
+
const [typeFilter, setTypeFilter] = useState<string>(ALL_FILTER);
|
|
514
|
+
const [methodFilter, setMethodFilter] = useState<string>(ALL_FILTER);
|
|
515
|
+
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
|
|
516
|
+
const [page, setPage] = useState(1);
|
|
517
|
+
const [pageSize, setPageSize] = usePersistedPageSize({
|
|
518
|
+
storageKey: 'pagination:core-routes:pageSize',
|
|
519
|
+
defaultValue: 12,
|
|
520
|
+
allowedValues: [6, 12, 24, 48],
|
|
521
|
+
});
|
|
522
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
523
|
+
const [editingRoute, setEditingRoute] = useState<RouteItem | null>(null);
|
|
524
|
+
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
|
525
|
+
const [activeTab, setActiveTab] = useState('basic-info');
|
|
526
|
+
const [saving, setSaving] = useState(false);
|
|
527
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
528
|
+
|
|
529
|
+
const formSchema = z
|
|
530
|
+
.object({
|
|
531
|
+
type: z.enum(['HTTP', 'MCP']),
|
|
532
|
+
url: z.string(),
|
|
533
|
+
method: z.string(),
|
|
534
|
+
tool_name: z.string(),
|
|
535
|
+
name: z.string(),
|
|
536
|
+
})
|
|
537
|
+
.superRefine((values, ctx) => {
|
|
538
|
+
if (values.type === 'HTTP') {
|
|
539
|
+
if (!values.url.trim()) {
|
|
540
|
+
ctx.addIssue({
|
|
541
|
+
code: 'custom',
|
|
542
|
+
path: ['url'],
|
|
543
|
+
message: t('errorUrlRequired'),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!values.method.trim()) {
|
|
548
|
+
ctx.addIssue({
|
|
549
|
+
code: 'custom',
|
|
550
|
+
path: ['method'],
|
|
551
|
+
message: t('errorMethodRequired'),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (values.type === 'MCP' && !values.tool_name.trim()) {
|
|
557
|
+
ctx.addIssue({
|
|
558
|
+
code: 'custom',
|
|
559
|
+
path: ['tool_name'],
|
|
560
|
+
message: t('errorToolNameRequired'),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const form = useForm<RouteFormValues>({
|
|
566
|
+
resolver: zodResolver(formSchema),
|
|
567
|
+
defaultValues: {
|
|
568
|
+
type: 'HTTP',
|
|
569
|
+
url: '',
|
|
570
|
+
method: 'GET',
|
|
571
|
+
tool_name: '',
|
|
572
|
+
name: '',
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const watchedType = form.watch('type');
|
|
577
|
+
|
|
578
|
+
const {
|
|
579
|
+
data: routesResponse,
|
|
580
|
+
isLoading,
|
|
581
|
+
refetch,
|
|
582
|
+
} = useQuery<PaginatedResponse<RouteItem>>({
|
|
583
|
+
queryKey: ['routes-admin', currentLocaleCode],
|
|
584
|
+
queryFn: async () => {
|
|
585
|
+
const response = await request<PaginatedResponse<RouteItem>>({
|
|
586
|
+
url: '/route?pageSize=10000',
|
|
587
|
+
method: 'GET',
|
|
588
|
+
});
|
|
589
|
+
return response.data;
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const allRoutes = routesResponse?.data ?? [];
|
|
594
|
+
|
|
595
|
+
const filteredRoutes = useMemo(() => {
|
|
596
|
+
const normalizedSearch = searchQuery.trim().toLowerCase();
|
|
597
|
+
|
|
598
|
+
return allRoutes.filter((route) => {
|
|
599
|
+
const routeType = normalizeRouteType(route);
|
|
600
|
+
const matchesType = typeFilter === ALL_FILTER || routeType === typeFilter;
|
|
601
|
+
const matchesMethod =
|
|
602
|
+
methodFilter === ALL_FILTER ||
|
|
603
|
+
(route.method || '').toUpperCase() === methodFilter;
|
|
604
|
+
|
|
605
|
+
const matchesSearch =
|
|
606
|
+
!normalizedSearch ||
|
|
607
|
+
[
|
|
608
|
+
route.url,
|
|
609
|
+
route.method,
|
|
610
|
+
route.tool_name,
|
|
611
|
+
route.name,
|
|
612
|
+
getRouteTitle(route),
|
|
613
|
+
getRouteSubtitle(route),
|
|
614
|
+
]
|
|
615
|
+
.filter(Boolean)
|
|
616
|
+
.some((value) =>
|
|
617
|
+
String(value).toLowerCase().includes(normalizedSearch)
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
return matchesType && matchesMethod && matchesSearch;
|
|
621
|
+
});
|
|
622
|
+
}, [allRoutes, methodFilter, searchQuery, typeFilter]);
|
|
623
|
+
|
|
624
|
+
const totalPages = Math.max(1, Math.ceil(filteredRoutes.length / pageSize));
|
|
625
|
+
|
|
626
|
+
useEffect(() => {
|
|
627
|
+
if (page > totalPages) {
|
|
628
|
+
setPage(totalPages);
|
|
629
|
+
}
|
|
630
|
+
}, [page, totalPages]);
|
|
631
|
+
|
|
632
|
+
const paginatedRoutes = useMemo(() => {
|
|
633
|
+
const start = (page - 1) * pageSize;
|
|
634
|
+
return filteredRoutes.slice(start, start + pageSize);
|
|
635
|
+
}, [filteredRoutes, page, pageSize]);
|
|
636
|
+
|
|
637
|
+
const routeStats = useMemo(
|
|
638
|
+
() => ({
|
|
639
|
+
total: allRoutes.length,
|
|
640
|
+
http: allRoutes.filter((route) => normalizeRouteType(route) === 'HTTP')
|
|
641
|
+
.length,
|
|
642
|
+
mcp: allRoutes.filter((route) => normalizeRouteType(route) === 'MCP')
|
|
643
|
+
.length,
|
|
644
|
+
filtered: filteredRoutes.length,
|
|
645
|
+
}),
|
|
646
|
+
[allRoutes, filteredRoutes.length]
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const renderRouteBadges = (route: RouteItem) => {
|
|
650
|
+
const roleCount = route.role_route?.length ?? 0;
|
|
651
|
+
const screenCount = route.route_screen?.length ?? 0;
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<>
|
|
655
|
+
<Badge variant="secondary" className="rounded-full px-2 py-1">
|
|
656
|
+
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
657
|
+
{t('rolesCount', { count: roleCount })}
|
|
658
|
+
</Badge>
|
|
659
|
+
<Badge variant="secondary" className="rounded-full px-2 py-1">
|
|
660
|
+
<Layers3 className="mr-1 h-3 w-3" />
|
|
661
|
+
{t('screensCount', { count: screenCount })}
|
|
662
|
+
</Badge>
|
|
663
|
+
{route.name ? (
|
|
664
|
+
<Badge variant="outline" className="rounded-full px-2 py-1">
|
|
665
|
+
<FileText className="mr-1 h-3 w-3" />
|
|
666
|
+
{route.name}
|
|
667
|
+
</Badge>
|
|
668
|
+
) : null}
|
|
669
|
+
</>
|
|
670
|
+
);
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const openCreateDialog = () => {
|
|
674
|
+
form.reset({
|
|
675
|
+
type: 'HTTP',
|
|
676
|
+
url: '',
|
|
677
|
+
method: 'GET',
|
|
678
|
+
tool_name: '',
|
|
679
|
+
name: '',
|
|
680
|
+
});
|
|
681
|
+
setFormError(null);
|
|
682
|
+
setEditingRoute(null);
|
|
683
|
+
setActiveTab('basic-info');
|
|
684
|
+
setIsDialogOpen(true);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const handleEdit = async (route: RouteItem) => {
|
|
688
|
+
setFormError(null);
|
|
689
|
+
form.reset({
|
|
690
|
+
type: normalizeRouteType(route),
|
|
691
|
+
url: route.url ?? '',
|
|
692
|
+
method: route.method ?? 'GET',
|
|
693
|
+
tool_name: route.tool_name ?? '',
|
|
694
|
+
name: route.name ?? '',
|
|
695
|
+
});
|
|
696
|
+
setEditingRoute(route);
|
|
697
|
+
setActiveTab('basic-info');
|
|
698
|
+
setIsDialogOpen(true);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const handleSubmit = async (values: RouteFormValues) => {
|
|
702
|
+
setSaving(true);
|
|
703
|
+
setFormError(null);
|
|
704
|
+
|
|
705
|
+
const payload =
|
|
706
|
+
values.type === 'HTTP'
|
|
707
|
+
? {
|
|
708
|
+
type: 'HTTP',
|
|
709
|
+
url: values.url.trim(),
|
|
710
|
+
method: values.method,
|
|
711
|
+
tool_name: null,
|
|
712
|
+
name: values.name.trim() || null,
|
|
713
|
+
}
|
|
714
|
+
: {
|
|
715
|
+
type: 'MCP',
|
|
716
|
+
url: null,
|
|
717
|
+
method: null,
|
|
718
|
+
tool_name: values.tool_name.trim(),
|
|
719
|
+
name: values.name.trim() || null,
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
if (editingRoute?.id) {
|
|
724
|
+
await request({
|
|
725
|
+
url: `/route/${editingRoute.id}`,
|
|
726
|
+
method: 'PATCH',
|
|
727
|
+
data: payload,
|
|
728
|
+
});
|
|
729
|
+
toast.success(t('routeUpdatedSuccess'));
|
|
730
|
+
} else {
|
|
731
|
+
await request({
|
|
732
|
+
url: '/route',
|
|
733
|
+
method: 'POST',
|
|
734
|
+
data: payload,
|
|
735
|
+
});
|
|
736
|
+
toast.success(t('routeCreatedSuccess'));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await refetch();
|
|
740
|
+
setIsDialogOpen(false);
|
|
741
|
+
setEditingRoute(null);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
setFormError(getErrorMessage(error, t('serverError')));
|
|
744
|
+
} finally {
|
|
745
|
+
setSaving(false);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const onDelete = async () => {
|
|
750
|
+
if (!editingRoute?.id) return;
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
await request({
|
|
754
|
+
url: '/route',
|
|
755
|
+
method: 'DELETE',
|
|
756
|
+
data: {
|
|
757
|
+
ids: [editingRoute.id],
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
toast.success(t('routeDeletedSuccess'));
|
|
762
|
+
setOpenDeleteModal(false);
|
|
763
|
+
setIsDialogOpen(false);
|
|
764
|
+
setEditingRoute(null);
|
|
765
|
+
await refetch();
|
|
766
|
+
} catch (error) {
|
|
767
|
+
setFormError(getErrorMessage(error, t('serverError')));
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
return (
|
|
772
|
+
<Page>
|
|
773
|
+
<PageHeader
|
|
774
|
+
breadcrumbs={[{ label: t('home'), href: '/' }, { label: t('routes') }]}
|
|
775
|
+
actions={[
|
|
776
|
+
{
|
|
777
|
+
label: t('buttonAddRoute'),
|
|
778
|
+
onClick: openCreateDialog,
|
|
779
|
+
variant: 'default',
|
|
780
|
+
icon: <Plus className="h-4 w-4" />,
|
|
781
|
+
},
|
|
782
|
+
]}
|
|
783
|
+
title={t('title')}
|
|
784
|
+
description={t('description')}
|
|
785
|
+
/>
|
|
786
|
+
|
|
787
|
+
<KpiCardsGrid
|
|
788
|
+
columns={4}
|
|
789
|
+
className="mt-3 gap-3"
|
|
790
|
+
cardClassName="shadow-sm"
|
|
791
|
+
items={[
|
|
792
|
+
{
|
|
793
|
+
key: 'total-routes',
|
|
794
|
+
title: t('totalRoutes'),
|
|
795
|
+
value: String(routeStats.total),
|
|
796
|
+
icon: RouteIcon,
|
|
797
|
+
layout: 'compact',
|
|
798
|
+
accentClassName: 'from-blue-500/20 via-sky-500/10 to-transparent',
|
|
799
|
+
iconContainerClassName: 'bg-blue-50 text-blue-600',
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
key: 'http-routes',
|
|
803
|
+
title: t('httpRoutes'),
|
|
804
|
+
value: String(routeStats.http),
|
|
805
|
+
icon: Globe,
|
|
806
|
+
layout: 'compact',
|
|
807
|
+
accentClassName:
|
|
808
|
+
'from-emerald-500/20 via-green-500/10 to-transparent',
|
|
809
|
+
iconContainerClassName: 'bg-emerald-50 text-emerald-600',
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
key: 'mcp-routes',
|
|
813
|
+
title: t('mcpRoutes'),
|
|
814
|
+
value: String(routeStats.mcp),
|
|
815
|
+
icon: Bot,
|
|
816
|
+
layout: 'compact',
|
|
817
|
+
accentClassName:
|
|
818
|
+
'from-violet-500/20 via-fuchsia-500/10 to-transparent',
|
|
819
|
+
iconContainerClassName: 'bg-violet-50 text-violet-600',
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
key: 'filtered-routes',
|
|
823
|
+
title: t('filteredRoutes'),
|
|
824
|
+
value: String(routeStats.filtered),
|
|
825
|
+
icon: Wrench,
|
|
826
|
+
layout: 'compact',
|
|
827
|
+
accentClassName:
|
|
828
|
+
'from-amber-500/20 via-yellow-500/10 to-transparent',
|
|
829
|
+
iconContainerClassName: 'bg-amber-50 text-amber-700',
|
|
830
|
+
},
|
|
831
|
+
]}
|
|
832
|
+
/>
|
|
833
|
+
|
|
834
|
+
<SearchBar
|
|
835
|
+
className="mt-4"
|
|
836
|
+
searchQuery={searchQuery}
|
|
837
|
+
onSearchChange={setSearchQuery}
|
|
838
|
+
showSearchButton={false}
|
|
839
|
+
placeholder={t('searchPlaceholder')}
|
|
840
|
+
controls={[
|
|
841
|
+
{
|
|
842
|
+
id: 'type-filter',
|
|
843
|
+
type: 'select',
|
|
844
|
+
value: typeFilter,
|
|
845
|
+
onChange: (value) => {
|
|
846
|
+
setTypeFilter(value);
|
|
847
|
+
setPage(1);
|
|
848
|
+
},
|
|
849
|
+
options: [
|
|
850
|
+
{ label: t('allTypes'), value: ALL_FILTER },
|
|
851
|
+
{ label: 'HTTP', value: 'HTTP' },
|
|
852
|
+
{ label: 'MCP', value: 'MCP' },
|
|
853
|
+
],
|
|
854
|
+
placeholder: t('selectType'),
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
id: 'method-filter',
|
|
858
|
+
type: 'select',
|
|
859
|
+
value: methodFilter,
|
|
860
|
+
onChange: (value) => {
|
|
861
|
+
setMethodFilter(value);
|
|
862
|
+
setPage(1);
|
|
863
|
+
},
|
|
864
|
+
options: [
|
|
865
|
+
{ label: t('allMethods'), value: ALL_FILTER },
|
|
866
|
+
...HTTP_METHODS.map((method) => ({
|
|
867
|
+
label: method,
|
|
868
|
+
value: method,
|
|
869
|
+
})),
|
|
870
|
+
],
|
|
871
|
+
placeholder: t('selectMethod'),
|
|
872
|
+
disabled: typeFilter === 'MCP',
|
|
873
|
+
},
|
|
874
|
+
]}
|
|
875
|
+
actions={
|
|
876
|
+
<div className="flex items-center gap-3">
|
|
877
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
878
|
+
{t('viewMode')}
|
|
879
|
+
</span>
|
|
880
|
+
<ViewModeToggle
|
|
881
|
+
viewMode={viewMode}
|
|
882
|
+
onViewModeChange={setViewMode}
|
|
883
|
+
cardsLabel={t('viewModeCards')}
|
|
884
|
+
listLabel={t('viewModeList')}
|
|
885
|
+
/>
|
|
886
|
+
</div>
|
|
887
|
+
}
|
|
888
|
+
/>
|
|
889
|
+
|
|
890
|
+
<div className="pt-4">
|
|
891
|
+
{isLoading ? (
|
|
892
|
+
viewMode === 'cards' ? (
|
|
893
|
+
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
894
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
895
|
+
<Card
|
|
896
|
+
key={`route-skeleton-${index}`}
|
|
897
|
+
className="animate-pulse rounded-2xl border border-border/60 bg-card p-4 shadow-sm"
|
|
898
|
+
>
|
|
899
|
+
<CardHeader className="space-y-2 p-0">
|
|
900
|
+
<div className="h-4 w-32 rounded bg-muted" />
|
|
901
|
+
<div className="h-3 w-48 rounded bg-muted" />
|
|
902
|
+
</CardHeader>
|
|
903
|
+
</Card>
|
|
904
|
+
))}
|
|
905
|
+
</div>
|
|
906
|
+
) : (
|
|
907
|
+
<div className="space-y-3">
|
|
908
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
909
|
+
<div
|
|
910
|
+
key={`route-list-skeleton-${index}`}
|
|
911
|
+
className="h-20 animate-pulse rounded-xl border border-border/60 bg-muted/40"
|
|
912
|
+
/>
|
|
913
|
+
))}
|
|
914
|
+
</div>
|
|
915
|
+
)
|
|
916
|
+
) : !paginatedRoutes.length ? (
|
|
917
|
+
<EmptyState
|
|
918
|
+
icon={<RouteIcon className="h-12 w-12" />}
|
|
919
|
+
title={t('noRoutesFound')}
|
|
920
|
+
description={t('emptyDescription')}
|
|
921
|
+
actionLabel={t('buttonAddRoute')}
|
|
922
|
+
onAction={openCreateDialog}
|
|
923
|
+
/>
|
|
924
|
+
) : viewMode === 'list' ? (
|
|
925
|
+
<div className="space-y-3">
|
|
926
|
+
{paginatedRoutes.map((route) => {
|
|
927
|
+
const routeType = normalizeRouteType(route);
|
|
928
|
+
|
|
929
|
+
return (
|
|
930
|
+
<div
|
|
931
|
+
key={route.id}
|
|
932
|
+
className="flex cursor-pointer flex-col gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 shadow-sm transition hover:border-primary hover:shadow-md md:flex-row md:items-center md:justify-between"
|
|
933
|
+
onClick={() => void handleEdit(route)}
|
|
934
|
+
>
|
|
935
|
+
<div className="min-w-0 flex-1">
|
|
936
|
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
937
|
+
<Badge
|
|
938
|
+
variant="outline"
|
|
939
|
+
className={cn(
|
|
940
|
+
'rounded-full px-2 py-0.5',
|
|
941
|
+
routeType === 'MCP'
|
|
942
|
+
? 'border-violet-200 bg-violet-50 text-violet-700'
|
|
943
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
944
|
+
)}
|
|
945
|
+
>
|
|
946
|
+
{routeType}
|
|
947
|
+
</Badge>
|
|
948
|
+
|
|
949
|
+
{routeType === 'HTTP' ? (
|
|
950
|
+
<Badge
|
|
951
|
+
variant="outline"
|
|
952
|
+
className={cn(
|
|
953
|
+
'rounded-full px-2 py-0.5',
|
|
954
|
+
getMethodBadgeClass(route.method)
|
|
955
|
+
)}
|
|
956
|
+
>
|
|
957
|
+
{route.method}
|
|
958
|
+
</Badge>
|
|
959
|
+
) : null}
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<div className="truncate text-sm font-semibold">
|
|
963
|
+
{getRouteTitle(route)}
|
|
964
|
+
</div>
|
|
965
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
966
|
+
{getRouteSubtitle(route)}
|
|
967
|
+
</div>
|
|
968
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
969
|
+
{renderRouteBadges(route)}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
|
|
973
|
+
<div className="flex shrink-0 justify-end">
|
|
974
|
+
<Button
|
|
975
|
+
variant="outline"
|
|
976
|
+
size="sm"
|
|
977
|
+
className="cursor-pointer"
|
|
978
|
+
onClick={(event) => {
|
|
979
|
+
event.stopPropagation();
|
|
980
|
+
void handleEdit(route);
|
|
981
|
+
}}
|
|
982
|
+
>
|
|
983
|
+
{t('buttonEditRoute')}
|
|
984
|
+
</Button>
|
|
985
|
+
</div>
|
|
986
|
+
</div>
|
|
987
|
+
);
|
|
988
|
+
})}
|
|
989
|
+
</div>
|
|
990
|
+
) : (
|
|
991
|
+
<div className="grid grid-cols-1 gap-4 xl:grid-cols-4">
|
|
992
|
+
{paginatedRoutes.map((route) => {
|
|
993
|
+
const routeType = normalizeRouteType(route);
|
|
994
|
+
|
|
995
|
+
return (
|
|
996
|
+
<Card
|
|
997
|
+
key={route.id}
|
|
998
|
+
className="cursor-pointer rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition hover:border-primary hover:shadow-md"
|
|
999
|
+
onClick={() => void handleEdit(route)}
|
|
1000
|
+
>
|
|
1001
|
+
<CardHeader className="flex items-start justify-between gap-4 p-0">
|
|
1002
|
+
<div className="min-w-0 flex-1">
|
|
1003
|
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
1004
|
+
<Badge
|
|
1005
|
+
variant="outline"
|
|
1006
|
+
className={cn(
|
|
1007
|
+
'rounded-full px-2 py-0.5',
|
|
1008
|
+
routeType === 'MCP'
|
|
1009
|
+
? 'border-violet-200 bg-violet-50 text-violet-700'
|
|
1010
|
+
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
1011
|
+
)}
|
|
1012
|
+
>
|
|
1013
|
+
{routeType}
|
|
1014
|
+
</Badge>
|
|
1015
|
+
|
|
1016
|
+
{routeType === 'HTTP' ? (
|
|
1017
|
+
<Badge
|
|
1018
|
+
variant="outline"
|
|
1019
|
+
className={cn(
|
|
1020
|
+
'rounded-full px-2 py-0.5',
|
|
1021
|
+
getMethodBadgeClass(route.method)
|
|
1022
|
+
)}
|
|
1023
|
+
>
|
|
1024
|
+
{route.method}
|
|
1025
|
+
</Badge>
|
|
1026
|
+
) : null}
|
|
1027
|
+
</div>
|
|
1028
|
+
|
|
1029
|
+
<CardTitle className="truncate text-sm font-semibold">
|
|
1030
|
+
{getRouteTitle(route)}
|
|
1031
|
+
</CardTitle>
|
|
1032
|
+
<CardDescription className="mt-1 truncate text-xs text-muted-foreground">
|
|
1033
|
+
{getRouteSubtitle(route)}
|
|
1034
|
+
</CardDescription>
|
|
1035
|
+
</div>
|
|
1036
|
+
|
|
1037
|
+
<Button
|
|
1038
|
+
variant="outline"
|
|
1039
|
+
size="sm"
|
|
1040
|
+
className="cursor-pointer"
|
|
1041
|
+
onClick={(event) => {
|
|
1042
|
+
event.stopPropagation();
|
|
1043
|
+
void handleEdit(route);
|
|
1044
|
+
}}
|
|
1045
|
+
>
|
|
1046
|
+
{t('buttonEditRoute')}
|
|
1047
|
+
</Button>
|
|
1048
|
+
</CardHeader>
|
|
1049
|
+
|
|
1050
|
+
<CardContent className="mt-4 flex flex-wrap gap-2 p-0">
|
|
1051
|
+
{renderRouteBadges(route)}
|
|
1052
|
+
</CardContent>
|
|
1053
|
+
</Card>
|
|
1054
|
+
);
|
|
1055
|
+
})}
|
|
1056
|
+
</div>
|
|
1057
|
+
)}
|
|
1058
|
+
</div>
|
|
1059
|
+
|
|
1060
|
+
<div className="mt-4 w-full border-t pt-2">
|
|
1061
|
+
<PaginationFooter
|
|
1062
|
+
currentPage={page}
|
|
1063
|
+
pageSize={pageSize}
|
|
1064
|
+
totalItems={filteredRoutes.length}
|
|
1065
|
+
onPageChange={setPage}
|
|
1066
|
+
onPageSizeChange={(nextPageSize) => {
|
|
1067
|
+
setPageSize(nextPageSize);
|
|
1068
|
+
setPage(1);
|
|
1069
|
+
}}
|
|
1070
|
+
pageSizeOptions={[6, 12, 24, 48]}
|
|
1071
|
+
/>
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<Sheet
|
|
1075
|
+
open={isDialogOpen}
|
|
1076
|
+
onOpenChange={(open) => {
|
|
1077
|
+
setIsDialogOpen(open);
|
|
1078
|
+
if (!open) {
|
|
1079
|
+
setEditingRoute(null);
|
|
1080
|
+
setFormError(null);
|
|
1081
|
+
}
|
|
1082
|
+
}}
|
|
1083
|
+
>
|
|
1084
|
+
<SheetContent className="w-full gap-0 overflow-y-auto sm:max-w-3xl">
|
|
1085
|
+
<SheetHeader>
|
|
1086
|
+
<SheetTitle>
|
|
1087
|
+
{editingRoute ? t('titleEditRoute') : t('dialogAddRouteTitle')}
|
|
1088
|
+
</SheetTitle>
|
|
1089
|
+
<SheetDescription>
|
|
1090
|
+
{editingRoute
|
|
1091
|
+
? getRouteTitle(editingRoute)
|
|
1092
|
+
: t('dialogAddRouteDescription')}
|
|
1093
|
+
</SheetDescription>
|
|
1094
|
+
</SheetHeader>
|
|
1095
|
+
|
|
1096
|
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
1097
|
+
<TabsList
|
|
1098
|
+
className={cn(
|
|
1099
|
+
'grid w-full rounded-md bg-muted text-sm text-muted-foreground',
|
|
1100
|
+
editingRoute ? 'grid-cols-3' : 'grid-cols-1'
|
|
1101
|
+
)}
|
|
1102
|
+
>
|
|
1103
|
+
<TabsTrigger value="basic-info" className="gap-2">
|
|
1104
|
+
<FileText className="h-4 w-4" />
|
|
1105
|
+
<span>{t('tabBasicInfo')}</span>
|
|
1106
|
+
</TabsTrigger>
|
|
1107
|
+
{editingRoute ? (
|
|
1108
|
+
<TabsTrigger value="roles" className="gap-2">
|
|
1109
|
+
<ShieldCheck className="h-4 w-4" />
|
|
1110
|
+
<span>{t('tabRoles')}</span>
|
|
1111
|
+
</TabsTrigger>
|
|
1112
|
+
) : null}
|
|
1113
|
+
{editingRoute ? (
|
|
1114
|
+
<TabsTrigger value="screens" className="gap-2">
|
|
1115
|
+
<Layers3 className="h-4 w-4" />
|
|
1116
|
+
<span>{t('tabScreens')}</span>
|
|
1117
|
+
</TabsTrigger>
|
|
1118
|
+
) : null}
|
|
1119
|
+
</TabsList>
|
|
1120
|
+
|
|
1121
|
+
<TabsContent
|
|
1122
|
+
value="basic-info"
|
|
1123
|
+
className="mt-4 space-y-4 px-4 pb-4"
|
|
1124
|
+
>
|
|
1125
|
+
<div className="space-y-1">
|
|
1126
|
+
<h4 className="text-sm font-semibold">{t('basicInfoTitle')}</h4>
|
|
1127
|
+
<p className="text-xs text-muted-foreground">
|
|
1128
|
+
{t('basicInfoDescription')}
|
|
1129
|
+
</p>
|
|
1130
|
+
</div>
|
|
1131
|
+
|
|
1132
|
+
<Form {...form}>
|
|
1133
|
+
<form
|
|
1134
|
+
onSubmit={form.handleSubmit(handleSubmit)}
|
|
1135
|
+
className="space-y-4"
|
|
1136
|
+
>
|
|
1137
|
+
<FormField
|
|
1138
|
+
control={form.control}
|
|
1139
|
+
name="type"
|
|
1140
|
+
render={({ field }) => (
|
|
1141
|
+
<FormItem>
|
|
1142
|
+
<FormLabel>{t('formTypeLabel')}</FormLabel>
|
|
1143
|
+
<Select
|
|
1144
|
+
value={field.value}
|
|
1145
|
+
onValueChange={(value) =>
|
|
1146
|
+
field.onChange(value as RouteType)
|
|
1147
|
+
}
|
|
1148
|
+
>
|
|
1149
|
+
<FormControl>
|
|
1150
|
+
<SelectTrigger className="w-full">
|
|
1151
|
+
<SelectValue />
|
|
1152
|
+
</SelectTrigger>
|
|
1153
|
+
</FormControl>
|
|
1154
|
+
<SelectContent>
|
|
1155
|
+
<SelectItem value="HTTP">HTTP</SelectItem>
|
|
1156
|
+
<SelectItem value="MCP">MCP</SelectItem>
|
|
1157
|
+
</SelectContent>
|
|
1158
|
+
</Select>
|
|
1159
|
+
<FormMessage />
|
|
1160
|
+
</FormItem>
|
|
1161
|
+
)}
|
|
1162
|
+
/>
|
|
1163
|
+
|
|
1164
|
+
{watchedType === 'HTTP' ? (
|
|
1165
|
+
<>
|
|
1166
|
+
<FormField
|
|
1167
|
+
control={form.control}
|
|
1168
|
+
name="url"
|
|
1169
|
+
render={({ field }) => (
|
|
1170
|
+
<FormItem>
|
|
1171
|
+
<FormLabel>{t('formUrlLabel')}</FormLabel>
|
|
1172
|
+
<FormControl>
|
|
1173
|
+
<Input
|
|
1174
|
+
placeholder={t('formUrlPlaceholder')}
|
|
1175
|
+
{...field}
|
|
1176
|
+
/>
|
|
1177
|
+
</FormControl>
|
|
1178
|
+
<FormMessage />
|
|
1179
|
+
</FormItem>
|
|
1180
|
+
)}
|
|
1181
|
+
/>
|
|
1182
|
+
|
|
1183
|
+
<FormField
|
|
1184
|
+
control={form.control}
|
|
1185
|
+
name="method"
|
|
1186
|
+
render={({ field }) => (
|
|
1187
|
+
<FormItem>
|
|
1188
|
+
<FormLabel>{t('formMethodLabel')}</FormLabel>
|
|
1189
|
+
<Select
|
|
1190
|
+
value={field.value}
|
|
1191
|
+
onValueChange={field.onChange}
|
|
1192
|
+
>
|
|
1193
|
+
<FormControl>
|
|
1194
|
+
<SelectTrigger className="w-full">
|
|
1195
|
+
<SelectValue />
|
|
1196
|
+
</SelectTrigger>
|
|
1197
|
+
</FormControl>
|
|
1198
|
+
<SelectContent>
|
|
1199
|
+
{HTTP_METHODS.map((method) => (
|
|
1200
|
+
<SelectItem key={method} value={method}>
|
|
1201
|
+
{method}
|
|
1202
|
+
</SelectItem>
|
|
1203
|
+
))}
|
|
1204
|
+
</SelectContent>
|
|
1205
|
+
</Select>
|
|
1206
|
+
<FormMessage />
|
|
1207
|
+
</FormItem>
|
|
1208
|
+
)}
|
|
1209
|
+
/>
|
|
1210
|
+
</>
|
|
1211
|
+
) : (
|
|
1212
|
+
<>
|
|
1213
|
+
<FormField
|
|
1214
|
+
control={form.control}
|
|
1215
|
+
name="tool_name"
|
|
1216
|
+
render={({ field }) => (
|
|
1217
|
+
<FormItem>
|
|
1218
|
+
<FormLabel>{t('formToolNameLabel')}</FormLabel>
|
|
1219
|
+
<FormControl>
|
|
1220
|
+
<Input
|
|
1221
|
+
placeholder={t('formToolNamePlaceholder')}
|
|
1222
|
+
{...field}
|
|
1223
|
+
/>
|
|
1224
|
+
</FormControl>
|
|
1225
|
+
<FormMessage />
|
|
1226
|
+
</FormItem>
|
|
1227
|
+
)}
|
|
1228
|
+
/>
|
|
1229
|
+
|
|
1230
|
+
<FormField
|
|
1231
|
+
control={form.control}
|
|
1232
|
+
name="name"
|
|
1233
|
+
render={({ field }) => (
|
|
1234
|
+
<FormItem>
|
|
1235
|
+
<FormLabel>{t('formNameLabel')}</FormLabel>
|
|
1236
|
+
<FormControl>
|
|
1237
|
+
<Input
|
|
1238
|
+
placeholder={t('formNamePlaceholder')}
|
|
1239
|
+
{...field}
|
|
1240
|
+
/>
|
|
1241
|
+
</FormControl>
|
|
1242
|
+
<FormMessage />
|
|
1243
|
+
</FormItem>
|
|
1244
|
+
)}
|
|
1245
|
+
/>
|
|
1246
|
+
</>
|
|
1247
|
+
)}
|
|
1248
|
+
|
|
1249
|
+
{watchedType === 'HTTP' ? (
|
|
1250
|
+
<FormField
|
|
1251
|
+
control={form.control}
|
|
1252
|
+
name="name"
|
|
1253
|
+
render={({ field }) => (
|
|
1254
|
+
<FormItem>
|
|
1255
|
+
<FormLabel>{t('formOptionalNameLabel')}</FormLabel>
|
|
1256
|
+
<FormControl>
|
|
1257
|
+
<Input
|
|
1258
|
+
placeholder={t('formOptionalNamePlaceholder')}
|
|
1259
|
+
{...field}
|
|
1260
|
+
/>
|
|
1261
|
+
</FormControl>
|
|
1262
|
+
<FormMessage />
|
|
1263
|
+
</FormItem>
|
|
1264
|
+
)}
|
|
1265
|
+
/>
|
|
1266
|
+
) : null}
|
|
1267
|
+
|
|
1268
|
+
{formError ? (
|
|
1269
|
+
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
1270
|
+
{formError}
|
|
1271
|
+
</div>
|
|
1272
|
+
) : null}
|
|
1273
|
+
|
|
1274
|
+
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
1275
|
+
{editingRoute ? (
|
|
1276
|
+
<Button
|
|
1277
|
+
type="button"
|
|
1278
|
+
variant="destructive"
|
|
1279
|
+
onClick={() => setOpenDeleteModal(true)}
|
|
1280
|
+
>
|
|
1281
|
+
<Trash2 className="mr-1 h-4 w-4" />
|
|
1282
|
+
{t('buttonDeleteRoute')}
|
|
1283
|
+
</Button>
|
|
1284
|
+
) : null}
|
|
1285
|
+
|
|
1286
|
+
<Button
|
|
1287
|
+
type="button"
|
|
1288
|
+
variant="outline"
|
|
1289
|
+
onClick={() => setIsDialogOpen(false)}
|
|
1290
|
+
>
|
|
1291
|
+
{t('cancel')}
|
|
1292
|
+
</Button>
|
|
1293
|
+
<Button type="submit" disabled={saving}>
|
|
1294
|
+
{saving ? (
|
|
1295
|
+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
|
1296
|
+
) : (
|
|
1297
|
+
<Save className="mr-1 h-4 w-4" />
|
|
1298
|
+
)}
|
|
1299
|
+
{t('saveChanges')}
|
|
1300
|
+
</Button>
|
|
1301
|
+
</div>
|
|
1302
|
+
</form>
|
|
1303
|
+
</Form>
|
|
1304
|
+
</TabsContent>
|
|
1305
|
+
|
|
1306
|
+
{editingRoute ? (
|
|
1307
|
+
<TabsContent value="roles" className="mt-4 space-y-4 px-4 pb-4">
|
|
1308
|
+
<RouteRolesSection routeId={editingRoute.id} />
|
|
1309
|
+
</TabsContent>
|
|
1310
|
+
) : null}
|
|
1311
|
+
|
|
1312
|
+
{editingRoute ? (
|
|
1313
|
+
<TabsContent value="screens" className="mt-4 space-y-4 px-4 pb-4">
|
|
1314
|
+
<RouteScreensSection routeId={editingRoute.id} />
|
|
1315
|
+
</TabsContent>
|
|
1316
|
+
) : null}
|
|
1317
|
+
</Tabs>
|
|
1318
|
+
</SheetContent>
|
|
1319
|
+
</Sheet>
|
|
1320
|
+
|
|
1321
|
+
<AlertDialog open={openDeleteModal} onOpenChange={setOpenDeleteModal}>
|
|
1322
|
+
<AlertDialogContent>
|
|
1323
|
+
<AlertDialogHeader>
|
|
1324
|
+
<AlertDialogTitle>{t('dialogDeleteRouteTitle')}</AlertDialogTitle>
|
|
1325
|
+
<AlertDialogDescription>
|
|
1326
|
+
{t('dialogDeleteRouteDescription')}
|
|
1327
|
+
</AlertDialogDescription>
|
|
1328
|
+
</AlertDialogHeader>
|
|
1329
|
+
<AlertDialogFooter>
|
|
1330
|
+
<AlertDialogCancel>{t('deleteRouteCancel')}</AlertDialogCancel>
|
|
1331
|
+
<AlertDialogAction onClick={onDelete}>
|
|
1332
|
+
{t('deleteRouteConfirm')}
|
|
1333
|
+
</AlertDialogAction>
|
|
1334
|
+
</AlertDialogFooter>
|
|
1335
|
+
</AlertDialogContent>
|
|
1336
|
+
</AlertDialog>
|
|
1337
|
+
</Page>
|
|
1338
|
+
);
|
|
1339
|
+
}
|