@hed-hog/core 0.0.332 → 0.0.338

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.
Files changed (36) hide show
  1. package/dist/auth/auth.controller.d.ts +17 -3
  2. package/dist/auth/auth.controller.d.ts.map +1 -1
  3. package/dist/auth/auth.service.d.ts +22 -8
  4. package/dist/auth/auth.service.d.ts.map +1 -1
  5. package/dist/dashboard/dashboard-user/dashboard-user.controller.d.ts +3 -3
  6. package/dist/dashboard/dashboard-user/dashboard-user.service.d.ts +3 -3
  7. package/dist/route/route.service.d.ts.map +1 -1
  8. package/dist/route/route.service.js +0 -14
  9. package/dist/route/route.service.js.map +1 -1
  10. package/dist/session/session.controller.d.ts +1 -1
  11. package/dist/session/session.service.d.ts +3 -3
  12. package/dist/user/user.controller.d.ts +17 -3
  13. package/dist/user/user.controller.d.ts.map +1 -1
  14. package/dist/user/user.service.d.ts +35 -7
  15. package/dist/user/user.service.d.ts.map +1 -1
  16. package/dist/user/user.service.js +5 -0
  17. package/dist/user/user.service.js.map +1 -1
  18. package/hedhog/data/menu.yaml +4 -18
  19. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +6 -1
  20. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +6 -1
  21. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +6 -1
  22. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +6 -1
  23. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +6 -1
  24. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +6 -1
  25. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +6 -1
  26. package/hedhog/frontend/app/mail/log/page.tsx.ejs +6 -1
  27. package/hedhog/frontend/app/roles/menus.tsx.ejs +6 -1
  28. package/hedhog/frontend/app/roles/routes.tsx.ejs +18 -5
  29. package/hedhog/frontend/app/roles/users.tsx.ejs +6 -1
  30. package/hedhog/frontend/app/users/page.tsx.ejs +6 -1
  31. package/hedhog/frontend/messages/en.json +0 -74
  32. package/hedhog/frontend/messages/pt.json +0 -74
  33. package/package.json +5 -5
  34. package/src/route/route.service.ts +0 -14
  35. package/src/user/user.service.ts +5 -0
  36. package/hedhog/frontend/app/routes/page.tsx.ejs +0 -1339
@@ -1,1339 +0,0 @@
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
- }