@sensolus/create-snt-agent-app 0.1.0 → 0.1.1
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/package.json +1 -1
- package/template/CLAUDE.md +218 -0
- package/template/Dockerfile +32 -0
- package/template/Jenkinsfile +28 -0
- package/template/README.md +477 -16
- package/template/_env.example +4 -0
- package/template/backend/app.py +630 -49
- package/template/backend/db_config.py +16 -0
- package/template/backend/extensions.py +3 -0
- package/template/backend/init_db.py +75 -0
- package/template/backend/migrations/README +1 -0
- package/template/backend/migrations/alembic.ini +50 -0
- package/template/backend/migrations/env.py +113 -0
- package/template/backend/migrations/script.py.mako +24 -0
- package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
- package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
- package/template/backend/models.py +31 -0
- package/template/backend/requirements.txt +8 -2
- package/template/eslint.config.js +6 -2
- package/template/index.html +11 -8
- package/template/infra/docker-compose.yml +15 -0
- package/template/openapi.json +40357 -0
- package/template/package.json +8 -1
- package/template/scripts/create-ecr-repo.sh +42 -0
- package/template/src/App.jsx +12 -34
- package/template/src/hooks/useFavourites.js +44 -0
- package/template/src/i18n/index.js +3 -0
- package/template/src/i18n/messages.js +8 -14
- package/template/src/i18n/translations/de.js +96 -0
- package/template/src/i18n/translations/en.js +103 -0
- package/template/src/i18n/translations/es.js +96 -0
- package/template/src/i18n/translations/fr.js +96 -0
- package/template/src/i18n/translations/nl.js +96 -0
- package/template/src/main.jsx +2 -3
- package/template/src/pages/Home.jsx +170 -0
- package/template/src/pages/OrganisationDetail.jsx +259 -0
- package/template/src/pages/OrganisationList.jsx +457 -0
- package/template/src/pages/Overview.jsx +199 -0
- package/template/src/pages/WidgetShowcase.jsx +522 -0
- package/template/src/styles/app.css +543 -4
- package/template/start-backend.sh +4 -0
- package/template/start-frontend.sh +3 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useTransition } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
SntButton,
|
|
5
|
+
SntInput,
|
|
6
|
+
SntBadge,
|
|
7
|
+
SntButtonGroup,
|
|
8
|
+
SntLoadingOverlay,
|
|
9
|
+
SntTable,
|
|
10
|
+
SntProgressBar,
|
|
11
|
+
SntSidepanel,
|
|
12
|
+
SntFilterSection,
|
|
13
|
+
SntSwitch,
|
|
14
|
+
SntCheckboxList,
|
|
15
|
+
SntSummaryStat,
|
|
16
|
+
} from '@sensolus/snt-agent-kit'
|
|
17
|
+
import { useLocale, formatNumber } from '../i18n'
|
|
18
|
+
import { useFavourites } from '../hooks/useFavourites'
|
|
19
|
+
|
|
20
|
+
export function OrganisationList({ authReady, reloadRef, onLoadingChange }) {
|
|
21
|
+
const navigate = useNavigate()
|
|
22
|
+
const { t, intlLocale } = useLocale()
|
|
23
|
+
const [organisations, setOrganisations] = useState([])
|
|
24
|
+
const [loading, setLoading] = useState(false)
|
|
25
|
+
const [error, setError] = useState('')
|
|
26
|
+
const [loaded, setLoaded] = useState(false)
|
|
27
|
+
const [searchFilter, setSearchFilter] = useState('')
|
|
28
|
+
const [viewMode, setViewMode] = useState('cards')
|
|
29
|
+
const [sidepanelOpen, setSidepanelOpen] = useState(true)
|
|
30
|
+
const [showOnlyActive, setShowOnlyActive] = useState(false)
|
|
31
|
+
const [minSubscriptions, setMinSubscriptions] = useState(0)
|
|
32
|
+
const [selectedTypes, setSelectedTypes] = useState([])
|
|
33
|
+
const [showOnlyFavourites, setShowOnlyFavourites] = useState(false)
|
|
34
|
+
const [filterPending, startFilterTransition] = useTransition()
|
|
35
|
+
const { favourites, toggleFavourite, isFavourite } = useFavourites()
|
|
36
|
+
|
|
37
|
+
const setShowOnlyFavouritesDeferred = (value) => {
|
|
38
|
+
startFilterTransition(() => setShowOnlyFavourites(value))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fetchOrganisations = async () => {
|
|
42
|
+
if (!authReady) return
|
|
43
|
+
setLoading(true)
|
|
44
|
+
setError('')
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch('/api/organisations')
|
|
48
|
+
const data = await response.json()
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(data.error || t('orgList.fetchFailed'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setOrganisations(data)
|
|
55
|
+
setLoaded(true)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError(err.message)
|
|
58
|
+
setOrganisations([])
|
|
59
|
+
} finally {
|
|
60
|
+
setLoading(false)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Auto-load once auth becomes available
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (authReady && !loaded && !loading) {
|
|
67
|
+
fetchOrganisations()
|
|
68
|
+
}
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [authReady])
|
|
71
|
+
|
|
72
|
+
// Expose reload to parent so the action button can live in the app header
|
|
73
|
+
if (reloadRef) reloadRef.current = fetchOrganisations
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
onLoadingChange?.(loading)
|
|
77
|
+
}, [loading, onLoadingChange])
|
|
78
|
+
|
|
79
|
+
// Get unique organisation types for the checkbox filter
|
|
80
|
+
const organisationTypes = useMemo(() => {
|
|
81
|
+
const types = new Set(organisations.map(org => org.organisationType || 'NORMAL'))
|
|
82
|
+
return Array.from(types).sort()
|
|
83
|
+
}, [organisations])
|
|
84
|
+
|
|
85
|
+
// Filter organisations by all criteria, then sort favourites to top
|
|
86
|
+
const filteredOrganisations = useMemo(() => {
|
|
87
|
+
const filtered = organisations.filter(org => {
|
|
88
|
+
// Favourites filter
|
|
89
|
+
if (showOnlyFavourites && !isFavourite(org.id)) return false
|
|
90
|
+
|
|
91
|
+
// Search filter
|
|
92
|
+
if (searchFilter.trim()) {
|
|
93
|
+
const search = searchFilter.toLowerCase()
|
|
94
|
+
const matchesSearch =
|
|
95
|
+
org.name?.toLowerCase().includes(search) ||
|
|
96
|
+
org.id?.toString().includes(search) ||
|
|
97
|
+
org.organisationType?.toLowerCase().includes(search)
|
|
98
|
+
if (!matchesSearch) return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Active subscriptions filter
|
|
102
|
+
if (showOnlyActive) {
|
|
103
|
+
const activeSubs = org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0
|
|
104
|
+
if (activeSubs === 0) return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Minimum subscriptions slider
|
|
108
|
+
if (minSubscriptions > 0) {
|
|
109
|
+
const subs = org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0
|
|
110
|
+
if (subs < minSubscriptions) return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Organisation type filter
|
|
114
|
+
if (selectedTypes.length > 0) {
|
|
115
|
+
const orgType = org.organisationType || 'NORMAL'
|
|
116
|
+
if (!selectedTypes.includes(orgType)) return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Sort favourites to the top
|
|
123
|
+
return filtered.sort((a, b) => {
|
|
124
|
+
const aFav = isFavourite(a.id) ? 0 : 1
|
|
125
|
+
const bFav = isFavourite(b.id) ? 0 : 1
|
|
126
|
+
return aFav - bFav
|
|
127
|
+
})
|
|
128
|
+
}, [organisations, searchFilter, showOnlyActive, minSubscriptions, selectedTypes, showOnlyFavourites, isFavourite])
|
|
129
|
+
|
|
130
|
+
// Compute summary statistics
|
|
131
|
+
const summaryStats = useMemo(() => {
|
|
132
|
+
const totalOrgs = organisations.length
|
|
133
|
+
const totalTrackers = organisations.reduce((sum, org) =>
|
|
134
|
+
sum + (org.statistics?.metrics?.NUMBER_OF_TRACKERS ?? 0), 0)
|
|
135
|
+
const totalSubscriptions = organisations.reduce((sum, org) =>
|
|
136
|
+
sum + (org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0), 0)
|
|
137
|
+
const totalUsers = organisations.reduce((sum, org) =>
|
|
138
|
+
sum + (org.statistics?.metrics?.NUMBER_OF_USERS ?? 0), 0)
|
|
139
|
+
const avgOnlineRatio = organisations.length > 0
|
|
140
|
+
? organisations.reduce((sum, org) =>
|
|
141
|
+
sum + (org.statistics?.metrics?.ONLINE_RATIO ?? 0), 0) / organisations.length
|
|
142
|
+
: 0
|
|
143
|
+
|
|
144
|
+
return { totalOrgs, totalTrackers, totalSubscriptions, totalUsers, avgOnlineRatio }
|
|
145
|
+
}, [organisations])
|
|
146
|
+
|
|
147
|
+
// Get max subscriptions for slider
|
|
148
|
+
const maxSubscriptions = useMemo(() => {
|
|
149
|
+
return Math.max(
|
|
150
|
+
...organisations.map(org => org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0),
|
|
151
|
+
100
|
|
152
|
+
)
|
|
153
|
+
}, [organisations])
|
|
154
|
+
|
|
155
|
+
const getTypeBadgeVariant = (type) => {
|
|
156
|
+
switch (type?.toUpperCase()) {
|
|
157
|
+
case 'PARTNER': return 'success'
|
|
158
|
+
case 'SYSTEM': return 'warning'
|
|
159
|
+
default: return 'info'
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Helper to get metric from statistics.metrics
|
|
164
|
+
const getMetric = (org, metricName) => {
|
|
165
|
+
const value = org.statistics?.metrics?.[metricName]
|
|
166
|
+
return value !== undefined ? Math.round(value) : null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const handleOrgClick = (org) => {
|
|
170
|
+
// Store the org data in sessionStorage for the detail page
|
|
171
|
+
sessionStorage.setItem(`org_${org.id}`, JSON.stringify(org))
|
|
172
|
+
navigate(`/organisation/${org.id}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Table columns definition - using actual API metric names
|
|
176
|
+
const tableColumns = [
|
|
177
|
+
{
|
|
178
|
+
key: 'favourite',
|
|
179
|
+
header: '',
|
|
180
|
+
sortable: false,
|
|
181
|
+
render: (row) => (
|
|
182
|
+
<button
|
|
183
|
+
className={`star-btn ${isFavourite(row.id) ? 'star-active' : ''}`}
|
|
184
|
+
onClick={(e) => { e.stopPropagation(); toggleFavourite(row.id) }}
|
|
185
|
+
title={isFavourite(row.id) ? t('orgList.removeFavourite') : t('orgList.addFavourite')}
|
|
186
|
+
>
|
|
187
|
+
{isFavourite(row.id) ? '\u2605' : '\u2606'}
|
|
188
|
+
</button>
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'name',
|
|
193
|
+
header: t('orgList.col.name'),
|
|
194
|
+
render: (row, value) => (
|
|
195
|
+
<button
|
|
196
|
+
className="link-button"
|
|
197
|
+
onClick={() => handleOrgClick(row)}
|
|
198
|
+
>
|
|
199
|
+
{value}
|
|
200
|
+
</button>
|
|
201
|
+
)
|
|
202
|
+
},
|
|
203
|
+
{ key: 'id', header: t('orgList.col.id') },
|
|
204
|
+
{
|
|
205
|
+
key: 'organisationType',
|
|
206
|
+
header: t('orgList.col.type'),
|
|
207
|
+
render: (row, value) => (
|
|
208
|
+
<SntBadge variant={getTypeBadgeVariant(value)} text={value || 'NORMAL'} />
|
|
209
|
+
)
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
key: 'trackers',
|
|
213
|
+
header: t('orgList.col.trackers'),
|
|
214
|
+
getValue: (row) => row.statistics?.metrics?.NUMBER_OF_TRACKERS ?? null,
|
|
215
|
+
render: (row) => getMetric(row, 'NUMBER_OF_TRACKERS') ?? '-'
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: 'subscriptions',
|
|
219
|
+
header: t('orgList.col.activeSubs'),
|
|
220
|
+
getValue: (row) => row.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? null,
|
|
221
|
+
render: (row) => getMetric(row, 'NUMBER_OF_ACTIVE_SUBSCRIPTION') ?? '-'
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: 'users',
|
|
225
|
+
header: t('orgList.col.users'),
|
|
226
|
+
getValue: (row) => row.statistics?.metrics?.NUMBER_OF_USERS ?? null,
|
|
227
|
+
render: (row) => getMetric(row, 'NUMBER_OF_USERS') ?? '-'
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
key: 'onlineRatio',
|
|
231
|
+
header: t('orgList.col.online'),
|
|
232
|
+
getValue: (row) => row.statistics?.metrics?.ONLINE_RATIO ?? null,
|
|
233
|
+
render: (row) => {
|
|
234
|
+
const ratio = row.statistics?.metrics?.ONLINE_RATIO
|
|
235
|
+
return ratio !== undefined
|
|
236
|
+
? <SntProgressBar value={ratio} variant="auto" />
|
|
237
|
+
: '-'
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
// Reset filters handler
|
|
243
|
+
const handleResetFilters = () => {
|
|
244
|
+
setSearchFilter('')
|
|
245
|
+
setShowOnlyActive(false)
|
|
246
|
+
setShowOnlyFavourites(false)
|
|
247
|
+
setMinSubscriptions(0)
|
|
248
|
+
setSelectedTypes([])
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className="page-container">
|
|
253
|
+
{/* Summary Stats */}
|
|
254
|
+
{loaded && organisations.length > 0 && (
|
|
255
|
+
<div className="summary-stats-row">
|
|
256
|
+
<SntSummaryStat
|
|
257
|
+
value={favourites.size}
|
|
258
|
+
label={t('orgList.summary.favourites')}
|
|
259
|
+
variant="warning"
|
|
260
|
+
onClick={() => setShowOnlyFavouritesDeferred(!showOnlyFavourites)}
|
|
261
|
+
active={showOnlyFavourites}
|
|
262
|
+
/>
|
|
263
|
+
<SntSummaryStat
|
|
264
|
+
value={summaryStats.totalOrgs}
|
|
265
|
+
label={t('orgList.summary.organisations')}
|
|
266
|
+
variant="info"
|
|
267
|
+
onClick={() => setShowOnlyFavouritesDeferred(false)}
|
|
268
|
+
active={!showOnlyFavourites}
|
|
269
|
+
/>
|
|
270
|
+
<SntSummaryStat
|
|
271
|
+
value={formatNumber(summaryStats.totalTrackers, intlLocale)}
|
|
272
|
+
label={t('orgList.summary.totalTrackers')}
|
|
273
|
+
/>
|
|
274
|
+
<SntSummaryStat
|
|
275
|
+
value={formatNumber(summaryStats.totalSubscriptions, intlLocale)}
|
|
276
|
+
label={t('orgList.summary.activeSubscriptions')}
|
|
277
|
+
variant="success"
|
|
278
|
+
/>
|
|
279
|
+
<SntSummaryStat
|
|
280
|
+
value={formatNumber(summaryStats.totalUsers, intlLocale)}
|
|
281
|
+
label={t('orgList.summary.totalUsers')}
|
|
282
|
+
/>
|
|
283
|
+
<SntSummaryStat
|
|
284
|
+
value={`${Math.round(summaryStats.avgOnlineRatio)}%`}
|
|
285
|
+
label={t('orgList.summary.avgOnlineRatio')}
|
|
286
|
+
variant={summaryStats.avgOnlineRatio >= 80 ? 'success' : summaryStats.avgOnlineRatio >= 50 ? 'warning' : 'danger'}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{error && <div className="error">{error}</div>}
|
|
292
|
+
|
|
293
|
+
{loading && (
|
|
294
|
+
<SntLoadingOverlay message={t('orgList.loadingOrgs')} />
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{/* Main content with sidepanel */}
|
|
298
|
+
{loaded && organisations.length > 0 && (
|
|
299
|
+
<div className="page-with-sidepanel">
|
|
300
|
+
<SntSidepanel
|
|
301
|
+
title={t('common.filters')}
|
|
302
|
+
open={sidepanelOpen}
|
|
303
|
+
onToggle={() => setSidepanelOpen(!sidepanelOpen)}
|
|
304
|
+
>
|
|
305
|
+
<SntFilterSection label={t('orgList.favourites')}>
|
|
306
|
+
<SntSwitch
|
|
307
|
+
checked={showOnlyFavourites}
|
|
308
|
+
onChange={setShowOnlyFavourites}
|
|
309
|
+
label={t('orgList.onlyFavourites')}
|
|
310
|
+
/>
|
|
311
|
+
</SntFilterSection>
|
|
312
|
+
|
|
313
|
+
<SntFilterSection label={t('common.search')}>
|
|
314
|
+
<SntInput
|
|
315
|
+
type="text"
|
|
316
|
+
placeholder={t('orgList.searchPlaceholder')}
|
|
317
|
+
value={searchFilter}
|
|
318
|
+
onChange={setSearchFilter}
|
|
319
|
+
/>
|
|
320
|
+
</SntFilterSection>
|
|
321
|
+
|
|
322
|
+
<SntFilterSection label={t('orgList.activeSubscriptions')}>
|
|
323
|
+
<SntSwitch
|
|
324
|
+
checked={showOnlyActive}
|
|
325
|
+
onChange={setShowOnlyActive}
|
|
326
|
+
label={t('orgList.onlyActive')}
|
|
327
|
+
/>
|
|
328
|
+
</SntFilterSection>
|
|
329
|
+
|
|
330
|
+
<SntFilterSection label={t('orgList.minSubscriptions', minSubscriptions)}>
|
|
331
|
+
<input
|
|
332
|
+
type="range"
|
|
333
|
+
className="snt-slider"
|
|
334
|
+
min={0}
|
|
335
|
+
max={maxSubscriptions}
|
|
336
|
+
value={minSubscriptions}
|
|
337
|
+
onChange={(e) => setMinSubscriptions(Number(e.target.value))}
|
|
338
|
+
/>
|
|
339
|
+
</SntFilterSection>
|
|
340
|
+
|
|
341
|
+
{organisationTypes.length > 0 && (
|
|
342
|
+
<SntFilterSection label={t('orgList.organisationType')}>
|
|
343
|
+
<SntCheckboxList
|
|
344
|
+
options={organisationTypes}
|
|
345
|
+
selected={selectedTypes}
|
|
346
|
+
onChange={setSelectedTypes}
|
|
347
|
+
/>
|
|
348
|
+
</SntFilterSection>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
<SntFilterSection>
|
|
352
|
+
<SntButton variant="secondary" onClick={handleResetFilters}>
|
|
353
|
+
{t('common.resetFilters')}
|
|
354
|
+
</SntButton>
|
|
355
|
+
</SntFilterSection>
|
|
356
|
+
</SntSidepanel>
|
|
357
|
+
|
|
358
|
+
<div className={`page-main-content ${filterPending ? 'content-pending' : ''}`}>
|
|
359
|
+
<div className="toolbar-row">
|
|
360
|
+
<span className="filter-count">
|
|
361
|
+
{t('orgList.showing', filteredOrganisations.length, organisations.length)}
|
|
362
|
+
</span>
|
|
363
|
+
<SntButtonGroup
|
|
364
|
+
options={[
|
|
365
|
+
{ value: 'cards', label: t('common.cards') },
|
|
366
|
+
{ value: 'table', label: t('common.table') },
|
|
367
|
+
]}
|
|
368
|
+
value={viewMode}
|
|
369
|
+
onChange={setViewMode}
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{filteredOrganisations.length === 0 && (
|
|
374
|
+
<div className="empty-state">
|
|
375
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
376
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
377
|
+
</svg>
|
|
378
|
+
<h3>{t('orgList.noOrgsFound')}</h3>
|
|
379
|
+
<p>{t('orgList.noOrgsMatch')}</p>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{filteredOrganisations.length > 0 && viewMode === 'cards' && (
|
|
384
|
+
<div className="org-grid">
|
|
385
|
+
{filteredOrganisations.map((org) => (
|
|
386
|
+
<div
|
|
387
|
+
key={org.id}
|
|
388
|
+
className="org-card org-card-clickable"
|
|
389
|
+
onClick={() => handleOrgClick(org)}
|
|
390
|
+
>
|
|
391
|
+
<div className="org-card-header">
|
|
392
|
+
<h3 className="org-name">{org.name}</h3>
|
|
393
|
+
<button
|
|
394
|
+
className={`star-btn ${isFavourite(org.id) ? 'star-active' : ''}`}
|
|
395
|
+
onClick={(e) => { e.stopPropagation(); toggleFavourite(org.id) }}
|
|
396
|
+
title={isFavourite(org.id) ? t('orgList.removeFavourite') : t('orgList.addFavourite')}
|
|
397
|
+
>
|
|
398
|
+
{isFavourite(org.id) ? '\u2605' : '\u2606'}
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
<span className="org-type">
|
|
402
|
+
<SntBadge
|
|
403
|
+
variant={getTypeBadgeVariant(org.organisationType)}
|
|
404
|
+
text={org.organisationType || 'NORMAL'}
|
|
405
|
+
/>
|
|
406
|
+
</span>
|
|
407
|
+
<div className="org-details">
|
|
408
|
+
<div className="org-stat">
|
|
409
|
+
<span className="stat-label">{t('orgList.stat.id')}</span>
|
|
410
|
+
<span className="stat-value">{org.id}</span>
|
|
411
|
+
</div>
|
|
412
|
+
{getMetric(org, 'NUMBER_OF_TRACKERS') !== null && (
|
|
413
|
+
<div className="org-stat">
|
|
414
|
+
<span className="stat-label">{t('orgList.stat.trackers')}</span>
|
|
415
|
+
<span className="stat-value">{getMetric(org, 'NUMBER_OF_TRACKERS')}</span>
|
|
416
|
+
</div>
|
|
417
|
+
)}
|
|
418
|
+
{getMetric(org, 'NUMBER_OF_ACTIVE_SUBSCRIPTION') !== null && (
|
|
419
|
+
<div className="org-stat">
|
|
420
|
+
<span className="stat-label">{t('orgList.stat.activeSubscriptions')}</span>
|
|
421
|
+
<span className="stat-value">{getMetric(org, 'NUMBER_OF_ACTIVE_SUBSCRIPTION')}</span>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
{getMetric(org, 'NUMBER_OF_USERS') !== null && (
|
|
425
|
+
<div className="org-stat">
|
|
426
|
+
<span className="stat-label">{t('orgList.stat.users')}</span>
|
|
427
|
+
<span className="stat-value">{getMetric(org, 'NUMBER_OF_USERS')}</span>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
{org.statistics?.metrics?.ONLINE_RATIO !== undefined && (
|
|
431
|
+
<div className="org-stat">
|
|
432
|
+
<span className="stat-label">{t('orgList.stat.online')}</span>
|
|
433
|
+
<span className="stat-value">
|
|
434
|
+
<SntProgressBar value={org.statistics.metrics.ONLINE_RATIO} variant="auto" />
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
))}
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
{filteredOrganisations.length > 0 && viewMode === 'table' && (
|
|
445
|
+
<SntTable
|
|
446
|
+
data={filteredOrganisations}
|
|
447
|
+
columns={tableColumns}
|
|
448
|
+
defaultPageSize={25}
|
|
449
|
+
emptyMessage={t('orgList.noOrgsFound')}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { SntCard, SntLoadingOverlay, SntSummaryStat } from '@sensolus/snt-agent-kit'
|
|
3
|
+
import { useLocale, formatNumber, formatShortDate } from '../i18n'
|
|
4
|
+
|
|
5
|
+
const SERIES = [
|
|
6
|
+
{ key: 'orgCount', color: '#212851', labelKey: 'overview.series.orgs' },
|
|
7
|
+
{ key: 'trackerTotal', color: '#0071A1', labelKey: 'overview.series.trackers' },
|
|
8
|
+
{ key: 'userTotal', color: '#39CB99', labelKey: 'overview.series.users' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const CHART_W = 720
|
|
12
|
+
const CHART_H = 280
|
|
13
|
+
const PADDING = { top: 16, right: 16, bottom: 32, left: 48 }
|
|
14
|
+
|
|
15
|
+
function LineChart({ data, intlLocale, timezone, t }) {
|
|
16
|
+
if (!data.length) return null
|
|
17
|
+
|
|
18
|
+
const innerW = CHART_W - PADDING.left - PADDING.right
|
|
19
|
+
const innerH = CHART_H - PADDING.top - PADDING.bottom
|
|
20
|
+
|
|
21
|
+
const maxValue = Math.max(
|
|
22
|
+
1,
|
|
23
|
+
...data.flatMap((d) => SERIES.map((s) => d[s.key] || 0)),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const xFor = (i) =>
|
|
27
|
+
PADDING.left + (data.length === 1 ? innerW / 2 : (i / (data.length - 1)) * innerW)
|
|
28
|
+
const yFor = (v) => PADDING.top + innerH - (v / maxValue) * innerH
|
|
29
|
+
|
|
30
|
+
const ticks = 4
|
|
31
|
+
const yTicks = Array.from({ length: ticks + 1 }, (_, i) => Math.round((maxValue / ticks) * i))
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<svg
|
|
35
|
+
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
|
36
|
+
style={{ width: '100%', height: 'auto', maxWidth: CHART_W }}
|
|
37
|
+
role="img"
|
|
38
|
+
aria-label={t('overview.chart.title')}
|
|
39
|
+
>
|
|
40
|
+
{/* y-axis grid + labels */}
|
|
41
|
+
{yTicks.map((v, i) => {
|
|
42
|
+
const y = yFor(v)
|
|
43
|
+
return (
|
|
44
|
+
<g key={i}>
|
|
45
|
+
<line
|
|
46
|
+
x1={PADDING.left}
|
|
47
|
+
x2={CHART_W - PADDING.right}
|
|
48
|
+
y1={y}
|
|
49
|
+
y2={y}
|
|
50
|
+
stroke="#E5E8ED"
|
|
51
|
+
strokeWidth="1"
|
|
52
|
+
/>
|
|
53
|
+
<text
|
|
54
|
+
x={PADDING.left - 8}
|
|
55
|
+
y={y + 4}
|
|
56
|
+
textAnchor="end"
|
|
57
|
+
fontSize="11"
|
|
58
|
+
fill="#535E6F"
|
|
59
|
+
>
|
|
60
|
+
{formatNumber(v, intlLocale)}
|
|
61
|
+
</text>
|
|
62
|
+
</g>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
|
|
66
|
+
{/* x-axis labels (first, middle, last) */}
|
|
67
|
+
{[0, Math.floor((data.length - 1) / 2), data.length - 1]
|
|
68
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
69
|
+
.map((i) => (
|
|
70
|
+
<text
|
|
71
|
+
key={i}
|
|
72
|
+
x={xFor(i)}
|
|
73
|
+
y={CHART_H - 8}
|
|
74
|
+
textAnchor="middle"
|
|
75
|
+
fontSize="11"
|
|
76
|
+
fill="#535E6F"
|
|
77
|
+
>
|
|
78
|
+
{formatShortDate(data[i].date, intlLocale, timezone)}
|
|
79
|
+
</text>
|
|
80
|
+
))}
|
|
81
|
+
|
|
82
|
+
{/* lines */}
|
|
83
|
+
{SERIES.map((s) => {
|
|
84
|
+
const path = data
|
|
85
|
+
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(d[s.key] || 0)}`)
|
|
86
|
+
.join(' ')
|
|
87
|
+
return (
|
|
88
|
+
<g key={s.key}>
|
|
89
|
+
<path d={path} fill="none" stroke={s.color} strokeWidth="2" />
|
|
90
|
+
{data.map((d, i) => (
|
|
91
|
+
<circle
|
|
92
|
+
key={i}
|
|
93
|
+
cx={xFor(i)}
|
|
94
|
+
cy={yFor(d[s.key] || 0)}
|
|
95
|
+
r="3"
|
|
96
|
+
fill={s.color}
|
|
97
|
+
>
|
|
98
|
+
<title>
|
|
99
|
+
{formatShortDate(d.date, intlLocale, timezone)} — {t(s.labelKey)}:{' '}
|
|
100
|
+
{formatNumber(d[s.key] || 0, intlLocale)}
|
|
101
|
+
</title>
|
|
102
|
+
</circle>
|
|
103
|
+
))}
|
|
104
|
+
</g>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</svg>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function Overview({ authReady }) {
|
|
112
|
+
const { t, intlLocale, timezone } = useLocale()
|
|
113
|
+
const [data, setData] = useState([])
|
|
114
|
+
const [loading, setLoading] = useState(false)
|
|
115
|
+
const [error, setError] = useState(null)
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!authReady) return
|
|
119
|
+
let cancelled = false
|
|
120
|
+
setLoading(true)
|
|
121
|
+
fetch('/api/org-stats/totals')
|
|
122
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
123
|
+
.then((rows) => {
|
|
124
|
+
if (!cancelled) setData(rows)
|
|
125
|
+
})
|
|
126
|
+
.catch((e) => {
|
|
127
|
+
if (!cancelled) setError(e.message)
|
|
128
|
+
})
|
|
129
|
+
.finally(() => {
|
|
130
|
+
if (!cancelled) setLoading(false)
|
|
131
|
+
})
|
|
132
|
+
return () => {
|
|
133
|
+
cancelled = true
|
|
134
|
+
}
|
|
135
|
+
}, [authReady])
|
|
136
|
+
|
|
137
|
+
const latest = data[data.length - 1]
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="page-container">
|
|
141
|
+
{loading && <SntLoadingOverlay message={t('common.loading')} />}
|
|
142
|
+
|
|
143
|
+
{!loading && error && (
|
|
144
|
+
<SntCard>
|
|
145
|
+
<p style={{ color: 'var(--snt-red)' }}>{error}</p>
|
|
146
|
+
</SntCard>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{!loading && !error && data.length === 0 && (
|
|
150
|
+
<SntCard>
|
|
151
|
+
<p>{t('overview.empty')}</p>
|
|
152
|
+
</SntCard>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{!loading && !error && data.length > 0 && (
|
|
156
|
+
<>
|
|
157
|
+
<div className="summary-stats-row">
|
|
158
|
+
<SntSummaryStat
|
|
159
|
+
value={formatNumber(latest.orgCount, intlLocale)}
|
|
160
|
+
label={t('overview.series.orgs')}
|
|
161
|
+
variant="info"
|
|
162
|
+
/>
|
|
163
|
+
<SntSummaryStat
|
|
164
|
+
value={formatNumber(latest.trackerTotal, intlLocale)}
|
|
165
|
+
label={t('overview.series.trackers')}
|
|
166
|
+
/>
|
|
167
|
+
<SntSummaryStat
|
|
168
|
+
value={formatNumber(latest.userTotal, intlLocale)}
|
|
169
|
+
label={t('overview.series.users')}
|
|
170
|
+
variant="success"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<SntCard title={t('overview.chart.title')}>
|
|
175
|
+
<div style={{ display: 'flex', gap: 16, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
176
|
+
{SERIES.map((s) => (
|
|
177
|
+
<span
|
|
178
|
+
key={s.key}
|
|
179
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12 }}
|
|
180
|
+
>
|
|
181
|
+
<span
|
|
182
|
+
style={{
|
|
183
|
+
display: 'inline-block',
|
|
184
|
+
width: 12,
|
|
185
|
+
height: 2,
|
|
186
|
+
background: s.color,
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
{t(s.labelKey)}
|
|
190
|
+
</span>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
<LineChart data={data} intlLocale={intlLocale} timezone={timezone} t={t} />
|
|
194
|
+
</SntCard>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|