@sensolus/create-snt-agent-app 0.1.0 → 0.2.0

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 (44) hide show
  1. package/index.js +2 -1
  2. package/package.json +1 -1
  3. package/template/CLAUDE.md +218 -0
  4. package/template/Dockerfile +32 -0
  5. package/template/Jenkinsfile +28 -0
  6. package/template/README.md +493 -16
  7. package/template/_env.example +14 -0
  8. package/template/backend/app.py +642 -49
  9. package/template/backend/db_config.py +16 -0
  10. package/template/backend/extensions.py +3 -0
  11. package/template/backend/init_db.py +75 -0
  12. package/template/backend/migrations/README +1 -0
  13. package/template/backend/migrations/alembic.ini +50 -0
  14. package/template/backend/migrations/env.py +113 -0
  15. package/template/backend/migrations/script.py.mako +24 -0
  16. package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
  17. package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
  18. package/template/backend/models.py +31 -0
  19. package/template/backend/requirements.txt +8 -2
  20. package/template/eslint.config.js +6 -2
  21. package/template/index.html +11 -8
  22. package/template/infra/docker-compose.yml +15 -0
  23. package/template/openapi.json +40357 -0
  24. package/template/package.json +8 -1
  25. package/template/scripts/create-ecr-repo.sh +42 -0
  26. package/template/src/App.jsx +30 -33
  27. package/template/src/AppConfigContext.jsx +45 -0
  28. package/template/src/hooks/useFavourites.js +44 -0
  29. package/template/src/i18n/index.js +3 -0
  30. package/template/src/i18n/messages.js +8 -14
  31. package/template/src/i18n/translations/de.js +72 -0
  32. package/template/src/i18n/translations/en.js +79 -0
  33. package/template/src/i18n/translations/es.js +72 -0
  34. package/template/src/i18n/translations/fr.js +72 -0
  35. package/template/src/i18n/translations/nl.js +72 -0
  36. package/template/src/main.jsx +2 -6
  37. package/template/src/pages/Home.jsx +170 -0
  38. package/template/src/pages/OrganisationDetail.jsx +263 -0
  39. package/template/src/pages/OrganisationList.jsx +457 -0
  40. package/template/src/pages/Overview.jsx +199 -0
  41. package/template/src/pages/WidgetShowcase.jsx +522 -0
  42. package/template/src/styles/app.css +543 -4
  43. package/template/start-backend.sh +4 -0
  44. package/template/start-frontend.sh +3 -0
@@ -0,0 +1,170 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { SntTabs, SntTabPanel, SntDialog, SntButton, SntInput, SntPageHeader } from '@sensolus/snt-agent-kit'
3
+ import { useLocale } from '../i18n'
4
+ import { Overview } from './Overview'
5
+ import { OrganisationList } from './OrganisationList'
6
+ import { WidgetShowcase } from './WidgetShowcase'
7
+
8
+ export function Home() {
9
+ const { t } = useLocale()
10
+ const [activeTab, setActiveTab] = useState('explore')
11
+ const [authReady, setAuthReady] = useState(false)
12
+ const [hasCookieToken, setHasCookieToken] = useState(false)
13
+ const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
14
+ const [apiKeyInput, setApiKeyInput] = useState('')
15
+ const [submitting, setSubmitting] = useState(false)
16
+ const [submitError, setSubmitError] = useState('')
17
+ const orgListReloadRef = useRef(null)
18
+ const [orgListLoading, setOrgListLoading] = useState(false)
19
+
20
+ useEffect(() => {
21
+ let cancelled = false
22
+ fetch('/api/auth/check')
23
+ .then((r) => r.json())
24
+ .then((data) => {
25
+ if (cancelled) return
26
+ if (data.hasSessionToken) {
27
+ setHasCookieToken(true)
28
+ setAuthReady(true)
29
+ } else if (data.hasStoredApiKey) {
30
+ setAuthReady(true)
31
+ } else {
32
+ setShowApiKeyDialog(true)
33
+ }
34
+ })
35
+ .catch(() => {
36
+ if (!cancelled) setShowApiKeyDialog(true)
37
+ })
38
+ return () => {
39
+ cancelled = true
40
+ }
41
+ }, [])
42
+
43
+ const submitApiKey = async () => {
44
+ const key = apiKeyInput.trim()
45
+ if (!key) return
46
+ setSubmitting(true)
47
+ setSubmitError('')
48
+ try {
49
+ const r = await fetch('/api/auth/api-key', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ apiKey: key }),
53
+ })
54
+ const data = await r.json()
55
+ if (!r.ok) {
56
+ setSubmitError(data.error || t('auth.dialog.failed'))
57
+ return
58
+ }
59
+ setAuthReady(true)
60
+ setShowApiKeyDialog(false)
61
+ setApiKeyInput('')
62
+ } catch (err) {
63
+ setSubmitError(err.message)
64
+ } finally {
65
+ setSubmitting(false)
66
+ }
67
+ }
68
+
69
+ const openApiKeyDialog = () => {
70
+ setSubmitError('')
71
+ setApiKeyInput('')
72
+ setShowApiKeyDialog(true)
73
+ }
74
+
75
+ const tabs = [
76
+ { key: 'explore', label: t('home.tab.explore') },
77
+ { key: 'overview', label: t('home.tab.overview') },
78
+ { key: 'showcase', label: t('home.tab.showcase') },
79
+ ]
80
+
81
+ const headerActions = (
82
+ <div className="header-actions">
83
+ {!hasCookieToken && (
84
+ <SntButton variant="secondary" onClick={openApiKeyDialog}>
85
+ {t('orgList.changeApiKey')}
86
+ </SntButton>
87
+ )}
88
+ {activeTab === 'explore' && (
89
+ <SntButton
90
+ variant="primary"
91
+ onClick={() => orgListReloadRef.current?.()}
92
+ disabled={orgListLoading || !authReady}
93
+ >
94
+ {orgListLoading ? t('common.loading') : t('common.reload')}
95
+ </SntButton>
96
+ )}
97
+ </div>
98
+ )
99
+
100
+ return (
101
+ <div className="home-page">
102
+ <SntPageHeader title="Sample app" actions={headerActions} />
103
+ <SntTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab}>
104
+ <SntTabPanel tabKey="overview" activeTab={activeTab}>
105
+ <Overview authReady={authReady} />
106
+ </SntTabPanel>
107
+ <SntTabPanel tabKey="explore" activeTab={activeTab}>
108
+ <OrganisationList
109
+ authReady={authReady}
110
+ reloadRef={orgListReloadRef}
111
+ onLoadingChange={setOrgListLoading}
112
+ />
113
+ </SntTabPanel>
114
+ <SntTabPanel tabKey="showcase" activeTab={activeTab}>
115
+ <WidgetShowcase />
116
+ </SntTabPanel>
117
+ </SntTabs>
118
+
119
+ <SntDialog
120
+ open={showApiKeyDialog}
121
+ onClose={() => {
122
+ if (authReady) setShowApiKeyDialog(false)
123
+ }}
124
+ title={t('auth.dialog.title')}
125
+ size="small"
126
+ >
127
+ <p>{t('auth.dialog.description')}</p>
128
+ <SntInput
129
+ type="password"
130
+ placeholder={t('orgList.apiKeyPlaceholder')}
131
+ value={apiKeyInput}
132
+ onChange={setApiKeyInput}
133
+ onKeyPress={(e) => {
134
+ if (e.key === 'Enter') submitApiKey()
135
+ }}
136
+ />
137
+ {submitError && (
138
+ <div className="error" style={{ marginTop: 8 }}>
139
+ {submitError}
140
+ </div>
141
+ )}
142
+ <div
143
+ style={{
144
+ marginTop: 16,
145
+ display: 'flex',
146
+ justifyContent: 'flex-end',
147
+ gap: 8,
148
+ }}
149
+ >
150
+ {authReady && (
151
+ <SntButton
152
+ variant="secondary"
153
+ onClick={() => setShowApiKeyDialog(false)}
154
+ disabled={submitting}
155
+ >
156
+ {t('common.cancel')}
157
+ </SntButton>
158
+ )}
159
+ <SntButton
160
+ variant="primary"
161
+ onClick={submitApiKey}
162
+ disabled={submitting || !apiKeyInput.trim()}
163
+ >
164
+ {submitting ? t('common.loading') : t('common.save')}
165
+ </SntButton>
166
+ </div>
167
+ </SntDialog>
168
+ </div>
169
+ )
170
+ }
@@ -0,0 +1,263 @@
1
+ import { useParams } from 'react-router-dom'
2
+ import { useMemo, useState, useEffect } from 'react'
3
+ import {
4
+ SntPageHeader,
5
+ SntBadge,
6
+ SntProgressBar,
7
+ SntCard,
8
+ SntMap,
9
+ SntSpinner,
10
+ } from '@sensolus/snt-agent-kit'
11
+ import { useLocale, formatNumber } from '../i18n'
12
+ import { useAppConfig } from '../AppConfigContext'
13
+
14
+ export function OrganisationDetail() {
15
+ const { id } = useParams()
16
+ const { t, intlLocale } = useLocale()
17
+ const config = useAppConfig()
18
+
19
+ // Try to get org data from sessionStorage
20
+ const organisation = useMemo(() => {
21
+ const cached = sessionStorage.getItem(`org_${id}`)
22
+ if (cached) {
23
+ try {
24
+ return JSON.parse(cached)
25
+ } catch (e) {
26
+ return null
27
+ }
28
+ }
29
+ return null
30
+ }, [id])
31
+
32
+ const [devices, setDevices] = useState([])
33
+ const [mapLoading, setMapLoading] = useState(true)
34
+
35
+ useEffect(() => {
36
+ if (!organisation) return
37
+ setMapLoading(true)
38
+
39
+ fetch(`/api/devices/byFilter?fields=serial,name,lastLat,lastLng,lastAddress,lastLocationUpdate,deviceCategory`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({
43
+ maxResults: 1000,
44
+ startIndex: 0,
45
+ filter: {
46
+ type: 'ORGANISATION',
47
+ ids: [String(id)],
48
+ includedNull: false,
49
+ includedNotNull: false
50
+ }
51
+ })
52
+ })
53
+ .then(r => r.ok ? r.json() : { data: [] })
54
+ .then(result => {
55
+ const items = Array.isArray(result) ? result : (result.data || [])
56
+ setDevices(items.filter(d => d.deviceCategory === 'TRACKER' && !(d.lastLat === 0 && d.lastLng === 0)))
57
+ setMapLoading(false)
58
+ })
59
+ .catch(() => {
60
+ setDevices([])
61
+ setMapLoading(false)
62
+ })
63
+ }, [organisation, id])
64
+
65
+ const getTypeBadgeVariant = (type) => {
66
+ switch (type?.toUpperCase()) {
67
+ case 'PARTNER': return 'success'
68
+ case 'SYSTEM': return 'warning'
69
+ default: return 'info'
70
+ }
71
+ }
72
+
73
+ // Format a value for display
74
+ const formatValue = (value) => {
75
+ if (value === null || value === undefined) return '-'
76
+ if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
77
+ if (typeof value === 'number') return formatNumber(value, intlLocale)
78
+ if (typeof value === 'object') return JSON.stringify(value, null, 2)
79
+ return String(value)
80
+ }
81
+
82
+ // Flatten an object into key-value pairs
83
+ const flattenObject = (obj, prefix = '') => {
84
+ const result = []
85
+ for (const [key, value] of Object.entries(obj)) {
86
+ const fullKey = prefix ? `${prefix}.${key}` : key
87
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
88
+ result.push(...flattenObject(value, fullKey))
89
+ } else {
90
+ result.push({ key: fullKey, value })
91
+ }
92
+ }
93
+ return result
94
+ }
95
+
96
+ // Group fields by category
97
+ const categorizeFields = (org) => {
98
+ const basicInfo = []
99
+ const statistics = []
100
+ const metrics = []
101
+ const other = []
102
+
103
+ const allFields = flattenObject(org)
104
+
105
+ for (const field of allFields) {
106
+ const key = field.key.toLowerCase()
107
+ if (key.startsWith('statistics.metrics.')) {
108
+ metrics.push({
109
+ ...field,
110
+ label: field.key.replace('statistics.metrics.', '').replace(/_/g, ' ')
111
+ })
112
+ } else if (key.startsWith('statistics.')) {
113
+ statistics.push({
114
+ ...field,
115
+ label: field.key.replace('statistics.', '').replace(/([A-Z])/g, ' $1').trim()
116
+ })
117
+ } else if (['id', 'name', 'organisationtype', 'locked', 'platformplan', 'region', 'segment'].includes(key)) {
118
+ basicInfo.push({
119
+ ...field,
120
+ label: field.key.replace(/([A-Z])/g, ' $1').trim()
121
+ })
122
+ } else {
123
+ other.push({
124
+ ...field,
125
+ label: field.key.replace(/([A-Z])/g, ' $1').trim()
126
+ })
127
+ }
128
+ }
129
+
130
+ return { basicInfo, statistics, metrics, other }
131
+ }
132
+
133
+ if (!organisation) {
134
+ return (
135
+ <div className="page-container">
136
+ <SntPageHeader title={t('orgDetail.notFound')} backTo="/" />
137
+ <div className="empty-state">
138
+ <h3>{t('orgDetail.notAvailable')}</h3>
139
+ <p>{t('orgDetail.goBackPrompt')}</p>
140
+ </div>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ const { basicInfo, statistics, metrics, other } = categorizeFields(organisation)
146
+
147
+ const renderField = (field) => {
148
+ const { key, value, label } = field
149
+ const lowerKey = key.toLowerCase()
150
+
151
+ // Special rendering for specific fields
152
+ if (lowerKey === 'organisationtype') {
153
+ return (
154
+ <div key={key} className="org-detail-field">
155
+ <span className="org-detail-label">{label}</span>
156
+ <span className="org-detail-value">
157
+ <SntBadge
158
+ variant={getTypeBadgeVariant(value)}
159
+ text={value || 'NORMAL'}
160
+ />
161
+ </span>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ if (lowerKey.includes('online_ratio') || lowerKey.includes('onlineratio')) {
167
+ return (
168
+ <div key={key} className="org-detail-field">
169
+ <span className="org-detail-label">{label}</span>
170
+ <span className="org-detail-value">
171
+ <SntProgressBar value={value} variant="auto" showLabel />
172
+ </span>
173
+ </div>
174
+ )
175
+ }
176
+
177
+ if (lowerKey === 'locked') {
178
+ return (
179
+ <div key={key} className="org-detail-field">
180
+ <span className="org-detail-label">{label}</span>
181
+ <span className="org-detail-value">
182
+ <SntBadge
183
+ variant={value ? 'danger' : 'success'}
184
+ text={value ? t('orgDetail.locked') : t('orgDetail.active')}
185
+ />
186
+ </span>
187
+ </div>
188
+ )
189
+ }
190
+
191
+ return (
192
+ <div key={key} className="org-detail-field">
193
+ <span className="org-detail-label">{label}</span>
194
+ <span className="org-detail-value">{formatValue(value)}</span>
195
+ </div>
196
+ )
197
+ }
198
+
199
+ return (
200
+ <div className="page-container">
201
+ <SntPageHeader
202
+ title={organisation.name}
203
+ backTo="/"
204
+ >
205
+ <SntBadge
206
+ variant={getTypeBadgeVariant(organisation.organisationType)}
207
+ text={organisation.organisationType || 'NORMAL'}
208
+ />
209
+ </SntPageHeader>
210
+
211
+ <SntCard title={`Trackers (${mapLoading ? '...' : devices.filter(d => d.lastLat != null).length})`}>
212
+ {mapLoading ? (
213
+ <div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
214
+ <SntSpinner size="medium" />
215
+ </div>
216
+ ) : (
217
+ <SntMap
218
+ mapboxKey={config.mapboxKey}
219
+ locationiqKey={config.locationiqKey}
220
+ height="500px"
221
+ devices={devices}
222
+ orgId={id}
223
+ showGeozones={true}
224
+ center={[50.85, 4.35]}
225
+ zoom={6}
226
+ />
227
+ )}
228
+ </SntCard>
229
+
230
+ {basicInfo.length > 0 && (
231
+ <SntCard title={t('orgDetail.basicInfo')}>
232
+ <div className="org-detail-grid">
233
+ {basicInfo.map(renderField)}
234
+ </div>
235
+ </SntCard>
236
+ )}
237
+
238
+ {metrics.length > 0 && (
239
+ <SntCard title={t('orgDetail.metrics')} style={{ marginTop: 24 }}>
240
+ <div className="org-detail-grid">
241
+ {metrics.map(renderField)}
242
+ </div>
243
+ </SntCard>
244
+ )}
245
+
246
+ {statistics.length > 0 && (
247
+ <SntCard title={t('orgDetail.statistics')} style={{ marginTop: 24 }}>
248
+ <div className="org-detail-grid">
249
+ {statistics.map(renderField)}
250
+ </div>
251
+ </SntCard>
252
+ )}
253
+
254
+ {other.length > 0 && (
255
+ <SntCard title={t('orgDetail.otherDetails')} style={{ marginTop: 24 }}>
256
+ <div className="org-detail-grid">
257
+ {other.map(renderField)}
258
+ </div>
259
+ </SntCard>
260
+ )}
261
+ </div>
262
+ )
263
+ }