piilot-pack-fropendata-ui 0.1.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.
- package/README.md +48 -0
- package/package.json +48 -0
- package/src/FrOpenDataSettingsView.tsx +468 -0
- package/src/index.ts +46 -0
- package/src/locales/en.json +95 -0
- package/src/locales/fr.json +95 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# piilot-pack-fropendata-ui
|
|
2
|
+
|
|
3
|
+
Frontend contributions for the `hello` Piilot plugin. Ships alongside
|
|
4
|
+
the backend package `piilot-pack-fropendata` on PyPI — the two share a
|
|
5
|
+
namespace and a per-company activation flag.
|
|
6
|
+
|
|
7
|
+
## What's in here
|
|
8
|
+
|
|
9
|
+
- `src/index.ts` — plugin entry point. Exports `register(core)` called
|
|
10
|
+
by the host at boot.
|
|
11
|
+
- `src/HelloModuleView.tsx` — React component rendered when the user
|
|
12
|
+
opens `/modules/:slug` matching `fropendata.hello`.
|
|
13
|
+
- `src/locales/{fr,en}.json` — translation keys merged under the
|
|
14
|
+
`hello` namespace by the host's i18next.
|
|
15
|
+
- `__tests__/` — Vitest isolated tests.
|
|
16
|
+
|
|
17
|
+
## Pattern
|
|
18
|
+
|
|
19
|
+
Source-only package : `main`/`exports` point at `./src/index.ts`.
|
|
20
|
+
The consumer (the Piilot host) transforms and bundles via Vite. No
|
|
21
|
+
`dist/` to build or ship.
|
|
22
|
+
|
|
23
|
+
## Host import contract
|
|
24
|
+
|
|
25
|
+
The plugin imports back into the host via the `@plugin-host/*`
|
|
26
|
+
alias — see `tsconfig.json` paths. Never reach around with
|
|
27
|
+
`../../../frontend/src/...` : the alias is stable across the planned
|
|
28
|
+
Module Federation migration; deep paths are not.
|
|
29
|
+
|
|
30
|
+
## Publish
|
|
31
|
+
|
|
32
|
+
Bump `package.json` version, commit, tag `ui-v<version>` in the
|
|
33
|
+
plugin repo :
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git tag ui-v0.3.0
|
|
37
|
+
git push origin ui-v0.3.0
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The `.github/workflows/release-ui.yml` workflow publishes to npm.
|
|
41
|
+
See the root `CLAUDE.md` for token setup.
|
|
42
|
+
|
|
43
|
+
## Full contract
|
|
44
|
+
|
|
45
|
+
For the complete guide (Vite 3-tier resolution, host-side consumption,
|
|
46
|
+
module federation roadmap) see :
|
|
47
|
+
[`docs/sdk/PLUGIN_DEVELOPMENT.md`](https://github.com/Kinetics-Consulting-V2/AICockpit/blob/main/docs/sdk/PLUGIN_DEVELOPMENT.md)
|
|
48
|
+
§20 — "Frontend contributions".
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "piilot-pack-fropendata-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Frontend du plugin Open Data France — ModuleView Settings (4 onglets : APIs, Auth, Cache, Métriques) + bundles i18n FR/EN.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Kinetics Consulting V2 <contact@piilot.ai>",
|
|
7
|
+
"homepage": "https://github.com/Kinetics-Consulting-V2/piilot-pack-fropendata",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Kinetics-Consulting-V2/piilot-pack-fropendata.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["piilot", "plugin", "frontend", "open-data", "france", "sirene", "inpi", "bodacc"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"import": "./src/index.ts",
|
|
20
|
+
"default": "./src/index.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"react-dom": "^19.0.0",
|
|
35
|
+
"react-i18next": "^16.0.0",
|
|
36
|
+
"react-router-dom": "^7.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
40
|
+
"@testing-library/react": "^16.1.0",
|
|
41
|
+
"@types/node": "^22.10.2",
|
|
42
|
+
"@types/react": "^19.0.2",
|
|
43
|
+
"@types/react-dom": "^19.0.2",
|
|
44
|
+
"jsdom": "^25.0.1",
|
|
45
|
+
"typescript": "^5.7.2",
|
|
46
|
+
"vitest": "^3.2.4"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings ModuleView for the fr-opendata plugin.
|
|
3
|
+
*
|
|
4
|
+
* Rendered by the host's ``ModuleViewShell`` when the user opens
|
|
5
|
+
* ``/modules/<uuid>`` whose ``module_slug`` matches ``fropendata.settings``
|
|
6
|
+
* (cf. backend ``seeds.py``).
|
|
7
|
+
*
|
|
8
|
+
* The view has 4 tabs:
|
|
9
|
+
* - APIs : status of the 6 backing APIs (auth required vs public).
|
|
10
|
+
* - Auth : credential forms for SIRENE + INPI (BYOK).
|
|
11
|
+
* - Cache : per-API TTL overrides (v0.1 read-only — placeholder for v0.2).
|
|
12
|
+
* - Métriques : cache hit/miss rates from the /settings/health endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Implementation notes:
|
|
15
|
+
* - Pas d'imports `@plugin-host/*` hors le type d'API host (via `index.ts`).
|
|
16
|
+
* Tout le rendu utilise React + classes Tailwind brutes pour rester
|
|
17
|
+
* portable et testable en isolation.
|
|
18
|
+
* - Toutes les chaînes passent par `useTranslation('fropendata')` —
|
|
19
|
+
* namespace cohérent avec `registerI18nBundle` dans `index.ts`.
|
|
20
|
+
* - Les calls admin (test connection, patch config) hit
|
|
21
|
+
* `/plugins/fropendata/*` directement avec `fetch`, en s'appuyant
|
|
22
|
+
* sur le cookie de session que le host injecte.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
26
|
+
import { useTranslation } from 'react-i18next'
|
|
27
|
+
|
|
28
|
+
type TabKey = 'apis' | 'auth' | 'cache' | 'metrics'
|
|
29
|
+
|
|
30
|
+
interface APIHealthEntry {
|
|
31
|
+
name: string
|
|
32
|
+
requires_auth: boolean
|
|
33
|
+
cache_hits: number
|
|
34
|
+
cache_misses: number
|
|
35
|
+
hit_rate: number | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface HealthResponse {
|
|
39
|
+
plugin: string
|
|
40
|
+
version: string
|
|
41
|
+
status: string
|
|
42
|
+
company_id: string | null
|
|
43
|
+
apis: APIHealthEntry[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ConnectionTestResult {
|
|
47
|
+
provider: string
|
|
48
|
+
ok: boolean
|
|
49
|
+
detail: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TABS: TabKey[] = ['apis', 'auth', 'cache', 'metrics']
|
|
53
|
+
|
|
54
|
+
export default function FrOpenDataSettingsView(): JSX.Element {
|
|
55
|
+
const { t } = useTranslation('fropendata')
|
|
56
|
+
const [activeTab, setActiveTab] = useState<TabKey>('apis')
|
|
57
|
+
const [health, setHealth] = useState<HealthResponse | null>(null)
|
|
58
|
+
const [healthError, setHealthError] = useState<string | null>(null)
|
|
59
|
+
const [healthLoading, setHealthLoading] = useState(false)
|
|
60
|
+
|
|
61
|
+
const refreshHealth = useCallback(async () => {
|
|
62
|
+
setHealthLoading(true)
|
|
63
|
+
setHealthError(null)
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch('/plugins/fropendata/settings/health', {
|
|
66
|
+
credentials: 'include',
|
|
67
|
+
})
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`HTTP ${res.status}`)
|
|
70
|
+
}
|
|
71
|
+
const data = (await res.json()) as HealthResponse
|
|
72
|
+
setHealth(data)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
setHealthError(e instanceof Error ? e.message : String(e))
|
|
75
|
+
} finally {
|
|
76
|
+
setHealthLoading(false)
|
|
77
|
+
}
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
void refreshHealth()
|
|
82
|
+
}, [refreshHealth])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="p-6 max-w-5xl mx-auto">
|
|
86
|
+
<header className="mb-6">
|
|
87
|
+
<h1 className="text-2xl font-semibold text-gray-900 mb-1">
|
|
88
|
+
{t('modules.settings.title')}
|
|
89
|
+
</h1>
|
|
90
|
+
<p className="text-sm text-gray-600">
|
|
91
|
+
{t('modules.settings.description')}
|
|
92
|
+
</p>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
{/* Tabs nav */}
|
|
96
|
+
<nav className="flex border-b border-gray-200 mb-6" role="tablist">
|
|
97
|
+
{TABS.map((tab) => (
|
|
98
|
+
<button
|
|
99
|
+
key={tab}
|
|
100
|
+
type="button"
|
|
101
|
+
role="tab"
|
|
102
|
+
aria-selected={activeTab === tab}
|
|
103
|
+
onClick={() => setActiveTab(tab)}
|
|
104
|
+
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
105
|
+
activeTab === tab
|
|
106
|
+
? 'border-b-2 border-blue-600 text-blue-600'
|
|
107
|
+
: 'text-gray-500 hover:text-gray-700'
|
|
108
|
+
}`}
|
|
109
|
+
>
|
|
110
|
+
{t(`tabs.${tab}`)}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</nav>
|
|
114
|
+
|
|
115
|
+
{/* Tab panels */}
|
|
116
|
+
<section role="tabpanel" aria-label={t(`tabs.${activeTab}`)}>
|
|
117
|
+
{activeTab === 'apis' && (
|
|
118
|
+
<APIsPanel
|
|
119
|
+
health={health}
|
|
120
|
+
loading={healthLoading}
|
|
121
|
+
error={healthError}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
{activeTab === 'auth' && <AuthPanel onCredsSaved={refreshHealth} />}
|
|
125
|
+
{activeTab === 'cache' && <CachePanel />}
|
|
126
|
+
{activeTab === 'metrics' && (
|
|
127
|
+
<MetricsPanel
|
|
128
|
+
health={health}
|
|
129
|
+
loading={healthLoading}
|
|
130
|
+
error={healthError}
|
|
131
|
+
onRefresh={refreshHealth}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
</section>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
/* APIs tab — status grid for the 6 backing APIs */
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
|
|
143
|
+
function APIsPanel({
|
|
144
|
+
health,
|
|
145
|
+
loading,
|
|
146
|
+
error,
|
|
147
|
+
}: {
|
|
148
|
+
health: HealthResponse | null
|
|
149
|
+
loading: boolean
|
|
150
|
+
error: string | null
|
|
151
|
+
}): JSX.Element {
|
|
152
|
+
const { t } = useTranslation('fropendata')
|
|
153
|
+
|
|
154
|
+
if (loading && !health) {
|
|
155
|
+
return <p className="text-sm text-gray-500">{t('common.loading')}</p>
|
|
156
|
+
}
|
|
157
|
+
if (error) {
|
|
158
|
+
return (
|
|
159
|
+
<p className="text-sm text-red-600">
|
|
160
|
+
{t('errors.health_fetch_failed')}: {error}
|
|
161
|
+
</p>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (!health) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
170
|
+
{health.apis.map((api) => (
|
|
171
|
+
<li
|
|
172
|
+
key={api.name}
|
|
173
|
+
className="border border-gray-200 rounded-lg p-4 bg-white"
|
|
174
|
+
>
|
|
175
|
+
<div className="flex items-center justify-between mb-2">
|
|
176
|
+
<span className="font-medium text-gray-900">
|
|
177
|
+
{t(`apis.${api.name}.label`)}
|
|
178
|
+
</span>
|
|
179
|
+
<span
|
|
180
|
+
className={`text-xs px-2 py-0.5 rounded ${
|
|
181
|
+
api.requires_auth
|
|
182
|
+
? 'bg-amber-100 text-amber-700'
|
|
183
|
+
: 'bg-green-100 text-green-700'
|
|
184
|
+
}`}
|
|
185
|
+
>
|
|
186
|
+
{api.requires_auth ? t('apis.requires_auth') : t('apis.no_auth')}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<p className="text-xs text-gray-500">
|
|
190
|
+
{t(`apis.${api.name}.description`)}
|
|
191
|
+
</p>
|
|
192
|
+
</li>
|
|
193
|
+
))}
|
|
194
|
+
</ul>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
199
|
+
/* Auth tab — credentials forms + test connection buttons */
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
|
|
202
|
+
function AuthPanel({
|
|
203
|
+
onCredsSaved,
|
|
204
|
+
}: {
|
|
205
|
+
onCredsSaved: () => void
|
|
206
|
+
}): JSX.Element {
|
|
207
|
+
const { t } = useTranslation('fropendata')
|
|
208
|
+
return (
|
|
209
|
+
<div className="space-y-6">
|
|
210
|
+
<ConnectorAuthCard
|
|
211
|
+
provider="sirene"
|
|
212
|
+
title={t('connectors.sirene.title')}
|
|
213
|
+
description={t('connectors.sirene.description')}
|
|
214
|
+
helpUrl="https://api.insee.fr/catalogue/"
|
|
215
|
+
helpLabel={t('connectors.sirene.help_link')}
|
|
216
|
+
fields={[
|
|
217
|
+
{ name: 'consumer_key', label: t('connectors.sirene.consumer_key'), type: 'text' },
|
|
218
|
+
{ name: 'consumer_secret', label: t('connectors.sirene.consumer_secret'), type: 'password' },
|
|
219
|
+
]}
|
|
220
|
+
onSaved={onCredsSaved}
|
|
221
|
+
/>
|
|
222
|
+
<ConnectorAuthCard
|
|
223
|
+
provider="inpi"
|
|
224
|
+
title={t('connectors.inpi.title')}
|
|
225
|
+
description={t('connectors.inpi.description')}
|
|
226
|
+
helpUrl="https://www.inpi.fr/services-en-ligne"
|
|
227
|
+
helpLabel={t('connectors.inpi.help_link')}
|
|
228
|
+
fields={[
|
|
229
|
+
{ name: 'username', label: t('connectors.inpi.username'), type: 'text' },
|
|
230
|
+
{ name: 'password', label: t('connectors.inpi.password'), type: 'password' },
|
|
231
|
+
]}
|
|
232
|
+
onSaved={onCredsSaved}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface AuthField {
|
|
239
|
+
name: string
|
|
240
|
+
label: string
|
|
241
|
+
type: 'text' | 'password'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ConnectorAuthCard({
|
|
245
|
+
provider,
|
|
246
|
+
title,
|
|
247
|
+
description,
|
|
248
|
+
helpUrl,
|
|
249
|
+
helpLabel,
|
|
250
|
+
fields,
|
|
251
|
+
onSaved,
|
|
252
|
+
}: {
|
|
253
|
+
provider: 'sirene' | 'inpi'
|
|
254
|
+
title: string
|
|
255
|
+
description: string
|
|
256
|
+
helpUrl: string
|
|
257
|
+
helpLabel: string
|
|
258
|
+
fields: AuthField[]
|
|
259
|
+
onSaved: () => void
|
|
260
|
+
}): JSX.Element {
|
|
261
|
+
const { t } = useTranslation('fropendata')
|
|
262
|
+
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null)
|
|
263
|
+
const [testing, setTesting] = useState(false)
|
|
264
|
+
|
|
265
|
+
const handleTest = async () => {
|
|
266
|
+
setTesting(true)
|
|
267
|
+
setTestResult(null)
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(`/plugins/fropendata/connections/test/${provider}`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
credentials: 'include',
|
|
272
|
+
})
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
throw new Error(`HTTP ${res.status}`)
|
|
275
|
+
}
|
|
276
|
+
const data = (await res.json()) as ConnectionTestResult
|
|
277
|
+
setTestResult(data)
|
|
278
|
+
if (data.ok) {
|
|
279
|
+
onSaved()
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
setTestResult({
|
|
283
|
+
provider,
|
|
284
|
+
ok: false,
|
|
285
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
286
|
+
})
|
|
287
|
+
} finally {
|
|
288
|
+
setTesting(false)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div className="border border-gray-200 rounded-lg p-5 bg-white">
|
|
294
|
+
<div className="flex items-start justify-between mb-3">
|
|
295
|
+
<div>
|
|
296
|
+
<h3 className="font-medium text-gray-900">{title}</h3>
|
|
297
|
+
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
|
298
|
+
</div>
|
|
299
|
+
<a
|
|
300
|
+
href={helpUrl}
|
|
301
|
+
target="_blank"
|
|
302
|
+
rel="noopener noreferrer"
|
|
303
|
+
className="text-xs text-blue-600 hover:underline whitespace-nowrap ml-4"
|
|
304
|
+
>
|
|
305
|
+
{helpLabel} ↗
|
|
306
|
+
</a>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<p className="text-xs text-gray-500 mb-3">
|
|
310
|
+
{t('connectors.save_via_settings_note')}
|
|
311
|
+
</p>
|
|
312
|
+
|
|
313
|
+
<ul className="text-xs text-gray-600 mb-4 space-y-1">
|
|
314
|
+
{fields.map((f) => (
|
|
315
|
+
<li key={f.name}>
|
|
316
|
+
<code className="bg-gray-100 px-1.5 py-0.5 rounded">{f.name}</code>
|
|
317
|
+
{' — '}
|
|
318
|
+
{f.label}
|
|
319
|
+
{f.type === 'password' && ' 🔒'}
|
|
320
|
+
</li>
|
|
321
|
+
))}
|
|
322
|
+
</ul>
|
|
323
|
+
|
|
324
|
+
<div className="flex items-center gap-3">
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
onClick={handleTest}
|
|
328
|
+
disabled={testing}
|
|
329
|
+
className="text-sm px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
330
|
+
>
|
|
331
|
+
{testing ? t('common.testing') : t('common.test_connection')}
|
|
332
|
+
</button>
|
|
333
|
+
{testResult && (
|
|
334
|
+
<span
|
|
335
|
+
className={`text-sm ${testResult.ok ? 'text-green-600' : 'text-red-600'}`}
|
|
336
|
+
>
|
|
337
|
+
{testResult.ok ? '✓' : '✗'} {testResult.detail}
|
|
338
|
+
</span>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
/* Cache tab — TTL overrides placeholder (v0.2) */
|
|
347
|
+
/* ------------------------------------------------------------------ */
|
|
348
|
+
|
|
349
|
+
function CachePanel(): JSX.Element {
|
|
350
|
+
const { t } = useTranslation('fropendata')
|
|
351
|
+
const ttls: { name: string; ttl: string }[] = [
|
|
352
|
+
{ name: 'recherche', ttl: '24h' },
|
|
353
|
+
{ name: 'sirene', ttl: '7j' },
|
|
354
|
+
{ name: 'ban', ttl: '30j' },
|
|
355
|
+
{ name: 'geo', ttl: '90j' },
|
|
356
|
+
{ name: 'bodacc', ttl: '1j' },
|
|
357
|
+
{ name: 'inpi', ttl: '7j' },
|
|
358
|
+
]
|
|
359
|
+
return (
|
|
360
|
+
<div className="space-y-4">
|
|
361
|
+
<p className="text-sm text-gray-600">{t('cache.intro')}</p>
|
|
362
|
+
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-3">
|
|
363
|
+
⚠ {t('cache.v01_readonly_warning')}
|
|
364
|
+
</p>
|
|
365
|
+
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
|
|
366
|
+
<thead className="bg-gray-50">
|
|
367
|
+
<tr>
|
|
368
|
+
<th className="text-left px-3 py-2 font-medium text-gray-700">
|
|
369
|
+
{t('cache.api')}
|
|
370
|
+
</th>
|
|
371
|
+
<th className="text-left px-3 py-2 font-medium text-gray-700">
|
|
372
|
+
{t('cache.default_ttl')}
|
|
373
|
+
</th>
|
|
374
|
+
<th className="text-left px-3 py-2 font-medium text-gray-700">
|
|
375
|
+
{t('cache.justification')}
|
|
376
|
+
</th>
|
|
377
|
+
</tr>
|
|
378
|
+
</thead>
|
|
379
|
+
<tbody className="divide-y divide-gray-200">
|
|
380
|
+
{ttls.map((row) => (
|
|
381
|
+
<tr key={row.name}>
|
|
382
|
+
<td className="px-3 py-2 font-mono text-xs">{row.name}</td>
|
|
383
|
+
<td className="px-3 py-2">{row.ttl}</td>
|
|
384
|
+
<td className="px-3 py-2 text-xs text-gray-600">
|
|
385
|
+
{t(`cache.justification_${row.name}`)}
|
|
386
|
+
</td>
|
|
387
|
+
</tr>
|
|
388
|
+
))}
|
|
389
|
+
</tbody>
|
|
390
|
+
</table>
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* ------------------------------------------------------------------ */
|
|
396
|
+
/* Métriques tab — cache hit/miss + refresh button */
|
|
397
|
+
/* ------------------------------------------------------------------ */
|
|
398
|
+
|
|
399
|
+
function MetricsPanel({
|
|
400
|
+
health,
|
|
401
|
+
loading,
|
|
402
|
+
error,
|
|
403
|
+
onRefresh,
|
|
404
|
+
}: {
|
|
405
|
+
health: HealthResponse | null
|
|
406
|
+
loading: boolean
|
|
407
|
+
error: string | null
|
|
408
|
+
onRefresh: () => void
|
|
409
|
+
}): JSX.Element {
|
|
410
|
+
const { t } = useTranslation('fropendata')
|
|
411
|
+
return (
|
|
412
|
+
<div className="space-y-4">
|
|
413
|
+
<div className="flex items-center justify-between">
|
|
414
|
+
<p className="text-sm text-gray-600">{t('metrics.intro')}</p>
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={onRefresh}
|
|
418
|
+
disabled={loading}
|
|
419
|
+
className="text-sm px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
|
420
|
+
>
|
|
421
|
+
{loading ? t('common.loading') : t('common.refresh')}
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
{error && (
|
|
425
|
+
<p className="text-sm text-red-600">
|
|
426
|
+
{t('errors.health_fetch_failed')}: {error}
|
|
427
|
+
</p>
|
|
428
|
+
)}
|
|
429
|
+
{health && (
|
|
430
|
+
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
|
|
431
|
+
<thead className="bg-gray-50">
|
|
432
|
+
<tr>
|
|
433
|
+
<th className="text-left px-3 py-2 font-medium text-gray-700">
|
|
434
|
+
{t('metrics.api')}
|
|
435
|
+
</th>
|
|
436
|
+
<th className="text-right px-3 py-2 font-medium text-gray-700">
|
|
437
|
+
{t('metrics.hits')}
|
|
438
|
+
</th>
|
|
439
|
+
<th className="text-right px-3 py-2 font-medium text-gray-700">
|
|
440
|
+
{t('metrics.misses')}
|
|
441
|
+
</th>
|
|
442
|
+
<th className="text-right px-3 py-2 font-medium text-gray-700">
|
|
443
|
+
{t('metrics.hit_rate')}
|
|
444
|
+
</th>
|
|
445
|
+
</tr>
|
|
446
|
+
</thead>
|
|
447
|
+
<tbody className="divide-y divide-gray-200">
|
|
448
|
+
{health.apis.map((api) => (
|
|
449
|
+
<tr key={api.name}>
|
|
450
|
+
<td className="px-3 py-2 font-mono text-xs">{api.name}</td>
|
|
451
|
+
<td className="px-3 py-2 text-right">{api.cache_hits}</td>
|
|
452
|
+
<td className="px-3 py-2 text-right">{api.cache_misses}</td>
|
|
453
|
+
<td className="px-3 py-2 text-right">
|
|
454
|
+
{api.hit_rate === null
|
|
455
|
+
? '—'
|
|
456
|
+
: `${(api.hit_rate * 100).toFixed(1)}%`}
|
|
457
|
+
</td>
|
|
458
|
+
</tr>
|
|
459
|
+
))}
|
|
460
|
+
</tbody>
|
|
461
|
+
</table>
|
|
462
|
+
)}
|
|
463
|
+
<p className="text-xs text-gray-500">
|
|
464
|
+
v {health?.version ?? '—'} · company {health?.company_id ?? '—'}
|
|
465
|
+
</p>
|
|
466
|
+
</div>
|
|
467
|
+
)
|
|
468
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fr-opendata plugin — frontend entry point.
|
|
3
|
+
*
|
|
4
|
+
* Exports a single ``register`` function the host calls at boot via the
|
|
5
|
+
* Vite alias ``@plugin/fr-opendata`` (host-side ``loader.ts``). Wires
|
|
6
|
+
* the Settings module view + i18n bundles into the core registry.
|
|
7
|
+
*
|
|
8
|
+
* This plugin contributes exactly **two** things to the host UI:
|
|
9
|
+
* 1. A React component (``FrOpenDataSettingsView``) rendered inside
|
|
10
|
+
* ``ModuleViewShell`` when the user opens ``/modules/<uuid>`` whose
|
|
11
|
+
* ``module_slug`` is ``fropendata.settings``.
|
|
12
|
+
* 2. FR/EN translation keys merged under the ``fropendata`` namespace.
|
|
13
|
+
*
|
|
14
|
+
* Tools, agent templates, connectors, HTTP routes and the Bodacc-watch
|
|
15
|
+
* KB all live backend-side (``piilot_pack_fropendata`` PyPI package).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PluginHostApi } from '@plugin-host/lib/pluginUI'
|
|
19
|
+
|
|
20
|
+
import FrOpenDataSettingsView from './FrOpenDataSettingsView'
|
|
21
|
+
import en from './locales/en.json'
|
|
22
|
+
import fr from './locales/fr.json'
|
|
23
|
+
|
|
24
|
+
// Single source of truth for this plugin's namespace. Used both for
|
|
25
|
+
// ``registerI18nBundle(NS, ...)`` AND for unwrapping the locale JSON's
|
|
26
|
+
// top-level ``{[NS]: {...}}`` envelope. Keeping it as a const avoids
|
|
27
|
+
// drift between the i18n bundle key and the module slug prefix.
|
|
28
|
+
const NS = 'fropendata'
|
|
29
|
+
|
|
30
|
+
export function register(core: PluginHostApi): void {
|
|
31
|
+
// Slug must match the ``module_slug`` seeded by the backend's
|
|
32
|
+
// ``register_module(...)`` call (see ``piilot_pack_fropendata/seeds.py``).
|
|
33
|
+
// Mismatch here silently disables the plugin UI — the host falls back
|
|
34
|
+
// to the generic step runner. Verified slug = ``fropendata.settings``.
|
|
35
|
+
core.registerModuleView(`${NS}.settings`, FrOpenDataSettingsView)
|
|
36
|
+
|
|
37
|
+
// Locale JSONs wrap their content under the namespace key (matches the
|
|
38
|
+
// host's i18n convention). Pass the inner payload to avoid double
|
|
39
|
+
// nesting when the host merges into its global catalog.
|
|
40
|
+
const frKeys = (fr as Record<string, unknown>)[NS] ?? fr
|
|
41
|
+
const enKeys = (en as Record<string, unknown>)[NS] ?? en
|
|
42
|
+
core.registerI18nBundle(NS, 'fr', frKeys as Record<string, unknown>)
|
|
43
|
+
core.registerI18nBundle(NS, 'en', enKeys as Record<string, unknown>)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default { register }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fropendata": {
|
|
3
|
+
"modules": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"title": "Open Data France",
|
|
6
|
+
"description": "Configure your SIRENE and INPI connectors, tune cache TTLs and watch usage metrics for the 6 French government APIs aggregated by the plugin."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"tabs": {
|
|
10
|
+
"apis": "APIs",
|
|
11
|
+
"auth": "Authentication",
|
|
12
|
+
"cache": "Cache",
|
|
13
|
+
"metrics": "Metrics"
|
|
14
|
+
},
|
|
15
|
+
"common": {
|
|
16
|
+
"loading": "Loading…",
|
|
17
|
+
"refresh": "Refresh",
|
|
18
|
+
"test_connection": "Test connection",
|
|
19
|
+
"testing": "Testing…",
|
|
20
|
+
"save": "Save",
|
|
21
|
+
"cancel": "Cancel"
|
|
22
|
+
},
|
|
23
|
+
"apis": {
|
|
24
|
+
"requires_auth": "BYOK required",
|
|
25
|
+
"no_auth": "No authentication",
|
|
26
|
+
"recherche": {
|
|
27
|
+
"label": "Company search",
|
|
28
|
+
"description": "DINUM-maintained meta-API: fast lookup by name, SIREN, SIRET or director. Quota 7 req/s."
|
|
29
|
+
},
|
|
30
|
+
"sirene": {
|
|
31
|
+
"label": "SIRENE V3",
|
|
32
|
+
"description": "Official INSEE registry. Source of truth for legal unit and establishment data. 30 req/min when authenticated."
|
|
33
|
+
},
|
|
34
|
+
"ban": {
|
|
35
|
+
"label": "National Address Database",
|
|
36
|
+
"description": "Geocoding and normalization of French addresses. 50 req/s."
|
|
37
|
+
},
|
|
38
|
+
"geo": {
|
|
39
|
+
"label": "Administrative breakdown",
|
|
40
|
+
"description": "Communes, départements, régions, EPCIs by INSEE code."
|
|
41
|
+
},
|
|
42
|
+
"bodacc": {
|
|
43
|
+
"label": "Bodacc",
|
|
44
|
+
"description": "French legal-events publication board (insolvency, sales, statutes changes)."
|
|
45
|
+
},
|
|
46
|
+
"inpi": {
|
|
47
|
+
"label": "INPI DNE",
|
|
48
|
+
"description": "National companies database: filed annual accounts, corporate officers."
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"connectors": {
|
|
52
|
+
"save_via_settings_note": "Credential entry and storage is handled by the host's Settings → Connectors page. This panel lists the expected fields and lets you test stored credentials.",
|
|
53
|
+
"sirene": {
|
|
54
|
+
"title": "SIRENE connector (INSEE)",
|
|
55
|
+
"description": "OAuth2 client_credentials. Create your application on the PISTE portal to get consumer_key + consumer_secret.",
|
|
56
|
+
"consumer_key": "App key (consumer_key)",
|
|
57
|
+
"consumer_secret": "App secret (consumer_secret)",
|
|
58
|
+
"help_link": "Create an INSEE developer account"
|
|
59
|
+
},
|
|
60
|
+
"inpi": {
|
|
61
|
+
"title": "INPI DNE connector",
|
|
62
|
+
"description": "Basic auth (username + password) on the INPI pro portal. The client exchanges these for a 1-hour JWT internally.",
|
|
63
|
+
"username": "INPI username",
|
|
64
|
+
"password": "INPI password",
|
|
65
|
+
"help_link": "Create an INPI developer account"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"cache": {
|
|
69
|
+
"intro": "Default cache TTLs, aligned on each API's typical data freshness. The Redis prefix ``plugin:fropendata:`` is added automatically by the SDK.",
|
|
70
|
+
"v01_readonly_warning": "v0.1: TTLs are fixed (read-only). Per-tenant customization lands in v0.2.",
|
|
71
|
+
"api": "API",
|
|
72
|
+
"default_ttl": "Default TTL",
|
|
73
|
+
"justification": "Rationale",
|
|
74
|
+
"justification_recherche": "Volatile metadata, daily refresh is enough",
|
|
75
|
+
"justification_sirene": "INSEE refresh is ~monthly, 7 days = good freshness/quota tradeoff",
|
|
76
|
+
"justification_ban": "Addresses are very stable, 30 days avoid redundant hits",
|
|
77
|
+
"justification_geo": "Admin breakdown is quasi-immutable apart from commune mergers",
|
|
78
|
+
"justification_bodacc": "Legal watch needs same-day freshness",
|
|
79
|
+
"justification_inpi": "Annual accounts = rare deposits, 7 days is plenty"
|
|
80
|
+
},
|
|
81
|
+
"metrics": {
|
|
82
|
+
"intro": "Redis cache hits / misses for each API. Counters are kept for 30 days then auto-reset.",
|
|
83
|
+
"api": "API",
|
|
84
|
+
"hits": "Hits",
|
|
85
|
+
"misses": "Misses",
|
|
86
|
+
"hit_rate": "Hit rate"
|
|
87
|
+
},
|
|
88
|
+
"errors": {
|
|
89
|
+
"health_fetch_failed": "Could not fetch the plugin status",
|
|
90
|
+
"not_configured": "Connector not configured for your organization",
|
|
91
|
+
"invalid_siren": "Invalid SIREN (must be 9 digits)",
|
|
92
|
+
"invalid_siret": "Invalid SIRET (must be 14 digits)"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fropendata": {
|
|
3
|
+
"modules": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"title": "Open Data France",
|
|
6
|
+
"description": "Configurez vos connecteurs SIRENE et INPI, ajustez le cache et consultez les métriques d'usage des 6 APIs gouvernementales agrégées par le plugin."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"tabs": {
|
|
10
|
+
"apis": "APIs",
|
|
11
|
+
"auth": "Authentification",
|
|
12
|
+
"cache": "Cache",
|
|
13
|
+
"metrics": "Métriques"
|
|
14
|
+
},
|
|
15
|
+
"common": {
|
|
16
|
+
"loading": "Chargement…",
|
|
17
|
+
"refresh": "Rafraîchir",
|
|
18
|
+
"test_connection": "Tester la connexion",
|
|
19
|
+
"testing": "Test en cours…",
|
|
20
|
+
"save": "Enregistrer",
|
|
21
|
+
"cancel": "Annuler"
|
|
22
|
+
},
|
|
23
|
+
"apis": {
|
|
24
|
+
"requires_auth": "BYOK requis",
|
|
25
|
+
"no_auth": "Sans authentification",
|
|
26
|
+
"recherche": {
|
|
27
|
+
"label": "Recherche entreprises",
|
|
28
|
+
"description": "API DINUM : lookup rapide par nom, SIREN, SIRET ou dirigeant. Quota 7 req/s."
|
|
29
|
+
},
|
|
30
|
+
"sirene": {
|
|
31
|
+
"label": "SIRENE V3",
|
|
32
|
+
"description": "API officielle INSEE. Source de vérité pour les données légales et établissements. 30 req/min en authentifié."
|
|
33
|
+
},
|
|
34
|
+
"ban": {
|
|
35
|
+
"label": "Base Adresse Nationale",
|
|
36
|
+
"description": "Géocodage et normalisation d'adresses françaises. 50 req/s."
|
|
37
|
+
},
|
|
38
|
+
"geo": {
|
|
39
|
+
"label": "Découpage administratif",
|
|
40
|
+
"description": "Communes, départements, régions, EPCIs par code INSEE."
|
|
41
|
+
},
|
|
42
|
+
"bodacc": {
|
|
43
|
+
"label": "Bodacc",
|
|
44
|
+
"description": "Annonces juridiques et commerciales (procédures collectives, ventes, modifications)."
|
|
45
|
+
},
|
|
46
|
+
"inpi": {
|
|
47
|
+
"label": "INPI DNE",
|
|
48
|
+
"description": "Données nationales des entreprises : comptes annuels déposés, mandataires sociaux."
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"connectors": {
|
|
52
|
+
"save_via_settings_note": "La saisie et la sauvegarde des credentials passent par la page Settings → Connecteurs du host. Cette section liste les champs attendus et permet de tester les credentials déjà stockés.",
|
|
53
|
+
"sirene": {
|
|
54
|
+
"title": "Connecteur SIRENE (INSEE)",
|
|
55
|
+
"description": "OAuth2 client_credentials. Créez votre application sur le portail PISTE pour obtenir consumer_key + consumer_secret.",
|
|
56
|
+
"consumer_key": "Clé applicative (consumer_key)",
|
|
57
|
+
"consumer_secret": "Secret applicatif (consumer_secret)",
|
|
58
|
+
"help_link": "Créer un compte INSEE développeur"
|
|
59
|
+
},
|
|
60
|
+
"inpi": {
|
|
61
|
+
"title": "Connecteur INPI DNE",
|
|
62
|
+
"description": "Authentification basique (login + mot de passe) sur l'espace pro INPI. Le client échange ces credentials contre un JWT 1h en interne.",
|
|
63
|
+
"username": "Identifiant INPI",
|
|
64
|
+
"password": "Mot de passe INPI",
|
|
65
|
+
"help_link": "Créer un compte INPI développeur"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"cache": {
|
|
69
|
+
"intro": "TTLs de cache par défaut, alignés sur la fraîcheur typique de chaque API. Le préfixe Redis ``plugin:fropendata:`` est ajouté automatiquement par le SDK.",
|
|
70
|
+
"v01_readonly_warning": "v0.1 : les TTLs sont fixes (lecture seule). La personnalisation par cabinet arrive en v0.2.",
|
|
71
|
+
"api": "API",
|
|
72
|
+
"default_ttl": "TTL par défaut",
|
|
73
|
+
"justification": "Justification",
|
|
74
|
+
"justification_recherche": "Métadonnées volatiles, recopie quotidienne suffit",
|
|
75
|
+
"justification_sirene": "INSEE refresh ~mensuel, 7 jours = bon compromis fraîcheur/quota",
|
|
76
|
+
"justification_ban": "Adresses très stables, 30 jours évitent les hits redondants",
|
|
77
|
+
"justification_geo": "Découpage admin quasi-immutable hors fusions de communes",
|
|
78
|
+
"justification_bodacc": "Veille juridique nécessite la fraîcheur du jour",
|
|
79
|
+
"justification_inpi": "Comptes annuels = dépôt rare, 7 jours largement suffisant"
|
|
80
|
+
},
|
|
81
|
+
"metrics": {
|
|
82
|
+
"intro": "Hits / misses du cache Redis pour chaque API. Compteurs maintenus pendant 30 jours puis remis à zéro automatiquement.",
|
|
83
|
+
"api": "API",
|
|
84
|
+
"hits": "Hits",
|
|
85
|
+
"misses": "Misses",
|
|
86
|
+
"hit_rate": "Taux de hit"
|
|
87
|
+
},
|
|
88
|
+
"errors": {
|
|
89
|
+
"health_fetch_failed": "Impossible de récupérer l'état du plugin",
|
|
90
|
+
"not_configured": "Connecteur non configuré pour votre organisation",
|
|
91
|
+
"invalid_siren": "SIREN invalide (doit faire 9 chiffres)",
|
|
92
|
+
"invalid_siret": "SIRET invalide (doit faire 14 chiffres)"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|