Skip to content

Commit b282d0e

Browse files
committed
Merge branch 'backlog/v12.0.0' of https://github.com/utmstack/UTMStack into backlog/v12.0.0
2 parents 7f16262 + 7f79ee3 commit b282d0e

78 files changed

Lines changed: 9929 additions & 2126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/modules/dashboards/repository/layout_pg.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (r *pgLayoutRepository) List(ctx context.Context, f dto.LayoutFilter) ([]do
4545
return nil, 0, err
4646
}
4747
var items []domain.DashboardVisualization
48-
if err := q.Order("dv_order ASC").Offset(f.Offset()).Limit(f.Limit()).Find(&items).Error; err != nil {
48+
if err := q.Offset(f.Offset()).Limit(f.Limit()).Find(&items).Error; err != nil {
4949
return nil, 0, err
5050
}
5151
return items, total, nil

frontend/package-lock.json

Lines changed: 685 additions & 897 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,21 @@
2121
"axios": "^1.7.7",
2222
"class-variance-authority": "^0.7.1",
2323
"clsx": "^2.1.1",
24+
"date-fns": "^4.4.0",
25+
"echarts": "^6.1.0",
26+
"echarts-for-react": "^3.0.6",
2427
"i18next": "^26.3.1",
2528
"i18next-browser-languagedetector": "^8.2.1",
2629
"js-yaml": "^4.1.0",
30+
"leaflet": "^1.9.4",
2731
"lucide-react": "^0.468.0",
2832
"mermaid": "^11.15.0",
2933
"react": "^19.0.0",
34+
"react-day-picker": "^10.0.1",
3035
"react-dom": "^19.0.0",
36+
"react-grid-layout": "^2.2.3",
3137
"react-i18next": "^17.0.8",
38+
"react-leaflet": "^5.0.0",
3239
"react-router-dom": "^7.1.1",
3340
"sonner": "^2.0.7",
3441
"tailwind-merge": "^2.6.0"
@@ -39,9 +46,11 @@
3946
"@testing-library/jest-dom": "^6.9.1",
4047
"@testing-library/react": "^16.3.2",
4148
"@testing-library/user-event": "^14.6.1",
49+
"@types/leaflet": "^1.9.21",
4250
"@types/node": "^22.10.2",
4351
"@types/react": "^19.0.2",
4452
"@types/react-dom": "^19.0.2",
53+
"@types/react-grid-layout": "^2.1.0",
4554
"@vitejs/plugin-react": "^4.3.4",
4655
"@vitest/ui": "^4.1.4",
4756
"eslint": "^9.17.0",

frontend/src/app/routes/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { Routes, Route, Navigate } from 'react-router-dom'
22
import { ProtectedRoute } from '@/features/auth'
33
import { LoginPage } from '@/features/auth/pages/LoginPage'
44
import { HomePage } from '@/features/home/pages/HomePage'
5+
import {
6+
DashboardPage,
7+
NewDashboardPage,
8+
NewVisualizationPage,
9+
EditVisualizationPage,
10+
VisualizationListPage,
11+
} from '@/features/dashboard'
512
import { ProfilePage } from '@/features/profile/pages/ProfilePage'
613
import { AuditPage } from '@/features/audit/pages/AuditPage'
714
import { AlertsPage } from '@/features/threats/pages/AlertsPage'
@@ -48,6 +55,13 @@ export function AppRoutes() {
4855
>
4956
<Route index element={<Navigate to="/home" replace />} />
5057
<Route path="home" element={<HomePage />} />
58+
{/* Dashboards */}
59+
<Route path="dashboards" element={<Navigate to="/dashboards/list" replace />} />
60+
<Route path="dashboards/list" element={<DashboardPage />} />
61+
<Route path="dashboards/new" element={<NewDashboardPage />} />
62+
<Route path="dashboards/visualizations" element={<VisualizationListPage />} />
63+
<Route path="dashboards/visualizations/new" element={<NewVisualizationPage />} />
64+
<Route path="dashboards/visualizations/:id" element={<EditVisualizationPage />} />
5165

5266
{/* Threat Management */}
5367
<Route path="threat-management" element={<Navigate to="/threat-management/alerts" replace />} />
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { useEffect, useState } from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import { useTranslation } from 'react-i18next'
4+
import { Loader2, Plus, Search, X } from 'lucide-react'
5+
import { Button } from '@/shared/components/ui/button'
6+
import { Input } from '@/shared/components/ui/input'
7+
import { Pagination } from '@/shared/components/ui/pagination'
8+
import { cn } from '@/shared/lib/utils'
9+
import { useVisualizations } from '@/features/dashboard/hooks/useVisualizations'
10+
import { DEFAULT_PAGE_SIZE } from '@/features/dashboard/constants'
11+
import type { Visualization } from '@/features/dashboard/types'
12+
13+
export function AddVisualizationDrawer({
14+
open,
15+
excludedIds,
16+
busy,
17+
onClose,
18+
onAdd,
19+
}: {
20+
open: boolean
21+
excludedIds: Set<number>
22+
busy?: boolean
23+
onClose: () => void
24+
onAdd: (visualizations: Visualization[]) => void | Promise<void>
25+
}) {
26+
const { t } = useTranslation()
27+
const navigate = useNavigate()
28+
const [search, setSearch] = useState('')
29+
const [page, setPage] = useState(0)
30+
const [size, setSize] = useState(DEFAULT_PAGE_SIZE)
31+
const [selected, setSelected] = useState<Map<number, Visualization>>(new Map())
32+
33+
useEffect(() => {
34+
if (!open) setSelected(new Map())
35+
}, [open])
36+
37+
const query = useVisualizations({ name: search || undefined, page, size })
38+
39+
if (!open) return null
40+
41+
const items = query.data?.data ?? []
42+
const total = query.data?.total ?? 0
43+
const selectedCount = selected.size
44+
45+
const toggle = (viz: Visualization) => {
46+
if (excludedIds.has(viz.id)) return
47+
setSelected((prev) => {
48+
const next = new Map(prev)
49+
if (next.has(viz.id)) {
50+
next.delete(viz.id)
51+
} else {
52+
next.set(viz.id, viz)
53+
}
54+
return next
55+
})
56+
}
57+
58+
const submit = async () => {
59+
if (selectedCount === 0 || busy) return
60+
await onAdd(Array.from(selected.values()))
61+
}
62+
63+
return (
64+
<div
65+
className="fixed inset-0 z-40 flex justify-end bg-black/40 backdrop-blur-sm"
66+
onClick={() => !busy && onClose()}
67+
>
68+
<div
69+
className="flex h-full w-full max-w-md flex-col overflow-hidden border-l border-border bg-card shadow-xl"
70+
onClick={(e) => e.stopPropagation()}
71+
>
72+
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
73+
<div>
74+
<h2 className="text-base font-semibold">{t('dashboards.addWidget.title')}</h2>
75+
<p className="mt-1 text-xs text-muted-foreground">
76+
{t('dashboards.addWidget.subtitle')}
77+
</p>
78+
</div>
79+
<button
80+
type="button"
81+
onClick={onClose}
82+
disabled={busy}
83+
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
84+
aria-label={t('common.actions.cancel') ?? 'Cancel'}
85+
>
86+
<X size={16} />
87+
</button>
88+
</header>
89+
90+
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
91+
<div className="relative flex-1">
92+
<Search
93+
size={14}
94+
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
95+
/>
96+
<Input
97+
value={search}
98+
onChange={(e) => {
99+
setSearch(e.target.value)
100+
setPage(0)
101+
}}
102+
placeholder={t('dashboards.addWidget.searchPlaceholder') ?? ''}
103+
className="h-9 pl-9"
104+
/>
105+
</div>
106+
<Button
107+
type="button"
108+
variant="outline"
109+
size="sm"
110+
onClick={() => navigate('/dashboards/visualizations/new')}
111+
>
112+
<Plus size={14} className="mr-1" />
113+
{t('dashboards.addWidget.newWidget')}
114+
</Button>
115+
</div>
116+
117+
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
118+
{query.isLoading && (
119+
<div className="flex items-center justify-center gap-2 px-3 py-8 text-xs text-muted-foreground">
120+
<Loader2 size={14} className="animate-spin" />
121+
{t('dashboards.addWidget.loading')}
122+
</div>
123+
)}
124+
{!query.isLoading && items.length === 0 && (
125+
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
126+
{t('dashboards.addWidget.empty')}
127+
</div>
128+
)}
129+
<ul className="space-y-1">
130+
{items.map((viz) => {
131+
const already = excludedIds.has(viz.id)
132+
const checked = selected.has(viz.id)
133+
return (
134+
<li key={viz.id}>
135+
<button
136+
type="button"
137+
disabled={already}
138+
onClick={() => toggle(viz)}
139+
className={cn(
140+
'flex w-full items-start gap-3 rounded-md border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
141+
checked
142+
? 'border-primary bg-primary/5'
143+
: 'border-transparent hover:border-border hover:bg-muted'
144+
)}
145+
>
146+
<span
147+
className={cn(
148+
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
149+
checked
150+
? 'border-primary bg-primary text-primary-foreground'
151+
: 'border-input bg-background'
152+
)}
153+
>
154+
{checked && (
155+
<svg
156+
width="10"
157+
height="10"
158+
viewBox="0 0 12 12"
159+
fill="none"
160+
xmlns="http://www.w3.org/2000/svg"
161+
>
162+
<path
163+
d="M2 6.5L4.5 9L10 3"
164+
stroke="currentColor"
165+
strokeWidth="2"
166+
strokeLinecap="round"
167+
strokeLinejoin="round"
168+
/>
169+
</svg>
170+
)}
171+
</span>
172+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
173+
<span className="truncate text-sm font-medium">{viz.name}</span>
174+
{viz.description && (
175+
<span className="truncate text-xs text-muted-foreground">
176+
{viz.description}
177+
</span>
178+
)}
179+
{already && (
180+
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
181+
{t('dashboards.addWidget.alreadyAdded')}
182+
</span>
183+
)}
184+
</span>
185+
</button>
186+
</li>
187+
)
188+
})}
189+
</ul>
190+
</div>
191+
192+
<div className="border-t border-border px-5 py-3">
193+
<Pagination
194+
page={page}
195+
pageSize={size}
196+
total={total}
197+
loading={query.isLoading}
198+
onPageChange={setPage}
199+
onPageSizeChange={(s) => {
200+
setSize(s)
201+
setPage(0)
202+
}}
203+
align="right"
204+
/>
205+
</div>
206+
207+
<footer className="flex items-center justify-between gap-2 border-t border-border bg-muted/30 px-5 py-3">
208+
<span className="text-xs text-muted-foreground">
209+
{selectedCount > 0
210+
? t('dashboards.addWidget.selectedCount', { count: selectedCount })
211+
: t('dashboards.addWidget.noneSelected')}
212+
</span>
213+
<div className="flex items-center gap-2">
214+
<Button variant="outline" size="sm" onClick={onClose} disabled={busy}>
215+
{t('common.actions.cancel')}
216+
</Button>
217+
<Button
218+
size="sm"
219+
onClick={() => void submit()}
220+
disabled={selectedCount === 0 || busy}
221+
>
222+
{busy && <Loader2 size={14} className="mr-1 animate-spin" />}
223+
{t('dashboards.addWidget.addSelected', { count: selectedCount })}
224+
</Button>
225+
</div>
226+
</footer>
227+
</div>
228+
</div>
229+
)
230+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { Loader2, Plus, Save, X } from 'lucide-react'
3+
import { Button } from '@/shared/components/ui/button'
4+
5+
export function DashboardEditorBar({
6+
dirty,
7+
saving,
8+
onAddWidget,
9+
onSave,
10+
onDiscard,
11+
}: {
12+
dirty: boolean
13+
saving: boolean
14+
onAddWidget: () => void
15+
onSave: () => void
16+
onDiscard: () => void
17+
}) {
18+
const { t } = useTranslation()
19+
return (
20+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
21+
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
22+
{t('dashboards.editor.editing')}
23+
</span>
24+
<div className="ml-auto flex items-center gap-2">
25+
<Button variant="outline" size="sm" onClick={onAddWidget}>
26+
<Plus size={14} className="mr-1" />
27+
{t('dashboards.editor.addWidget')}
28+
</Button>
29+
<Button variant="outline" size="sm" onClick={onDiscard} disabled={saving}>
30+
<X size={14} className="mr-1" />
31+
{t('dashboards.editor.discard')}
32+
</Button>
33+
<Button size="sm" onClick={onSave} disabled={!dirty || saving}>
34+
{saving ? (
35+
<Loader2 size={14} className="mr-1 animate-spin" />
36+
) : (
37+
<Save size={14} className="mr-1" />
38+
)}
39+
{t('dashboards.editor.save')}
40+
</Button>
41+
</div>
42+
</div>
43+
)
44+
}

0 commit comments

Comments
 (0)