@mostajs/orm-copy-data-ui-html 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/LICENSE +21 -0
- package/README.md +92 -0
- package/demo.html +151 -0
- package/llms.txt +47 -0
- package/package.json +44 -0
- package/src/index.js +280 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@mostajs/mjs-unit
|
|
2
|
+
Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
8
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
(at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
The complete text of the GNU Affero General Public License version 3 is
|
|
20
|
+
available at the URL above and must accompany any distribution of this
|
|
21
|
+
software.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @mostajs/orm-copy-data-ui-html
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
|
|
4
|
+
**Niveau** : N2 (composé / UI) · **Zéro dépendance runtime**
|
|
5
|
+
|
|
6
|
+
IHM de [`@mostajs/orm-copy-data`](../mosta-orm-copy-data) : copier, migrer, exporter et
|
|
7
|
+
sauvegarder les données d'une application `@mostajs` **sans ligne de commande**.
|
|
8
|
+
|
|
9
|
+
Couvre **toutes** les capacités du moteur :
|
|
10
|
+
|
|
11
|
+
| | |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **Sources** | `db` (13 dialectes) · `csv` · `json` · `sql` |
|
|
14
|
+
| **Destinations** *(multiples)* | `db` · `csv` · `json` · `sql-dump` |
|
|
15
|
+
| **Options** | entités à copier · `batchSize` · progression · journal |
|
|
16
|
+
| **Résultat** | copiés / erreurs / durée **par entité**, première erreur |
|
|
17
|
+
|
|
18
|
+
## Architecture — qui exécute quoi
|
|
19
|
+
|
|
20
|
+
Ce module est une **bibliothèque de rendu pure** : des fonctions `état → chaîne HTML`.
|
|
21
|
+
Elle **ne copie rien**, **n'ouvre aucune connexion**, et est donc testable sous Node sans
|
|
22
|
+
navigateur ni base de données.
|
|
23
|
+
|
|
24
|
+
L'exécution appartient à l'**application hôte**, qui implémente un contrat HTTP de 4 routes.
|
|
25
|
+
C'est délibéré : cette IHM manipule des **URI de bases de production**. L'authentification et
|
|
26
|
+
les droits doivent rester chez l'hôte, pas dans une couche de présentation.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
GET /copy → l'IHM (copyPage)
|
|
30
|
+
POST /copy/inspect → teste la source, liste les entités
|
|
31
|
+
POST /copy/run → lance la copie → { jobId }
|
|
32
|
+
GET /copy/events/:jobId → SSE : progress · log · result
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`examples/server.mjs` implémente ce contrat de bout en bout : c'est **la démo ET le modèle
|
|
36
|
+
à recopier**.
|
|
37
|
+
|
|
38
|
+
## Garde-fous
|
|
39
|
+
|
|
40
|
+
1. **Simulation (`dry-run`) par défaut** — l'écriture réelle est un choix explicite, confirmé.
|
|
41
|
+
2. **Mots de passe masqués** dans *tout* rendu et *tout* journal (`postgres://user:***@hôte/base`).
|
|
42
|
+
3. **Confirmation obligatoire** avant d'écrire vers une base de destination.
|
|
43
|
+
4. **Aucune URI persistée** par le module : l'hôte décide s'il les stocke, et où.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm i @mostajs/orm-copy-data-ui-html
|
|
49
|
+
# peer (côté hôte, pour exécuter) : @mostajs/orm-copy-data >= 0.3.1
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Exemple
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { copyPage } from '@mostajs/orm-copy-data-ui-html';
|
|
56
|
+
|
|
57
|
+
app.get('/copy', (req, res) => {
|
|
58
|
+
res.send(copyPage({
|
|
59
|
+
source: { type: 'db', dialect: 'sqljs', uri: './data/app.sqlite' },
|
|
60
|
+
dests: [{ type: 'db', dialect: 'postgres', uri: process.env.PG_URI }],
|
|
61
|
+
dryRun: true,
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Démo
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run example # http://localhost:8788
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Copie un JSON vers JSON + CSV — une **vraie** copie par le vrai moteur, sans exiger de base
|
|
73
|
+
de données. Basculer vers PostgreSQL ne change qu'un champ du formulaire.
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm test # 17 tests de rendu (mjs-unit, §11.1.bis)
|
|
79
|
+
node test-scripts/e2e-demo.mjs # E2E : contrat HTTP + SSE + copie réelle sur disque
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Les tests couvrent en priorité ce qui fait mal : **fuite de mot de passe** dans le HTML/le
|
|
83
|
+
journal/le rapport, injection HTML, simulation qui écrirait par erreur, et rapport qui
|
|
84
|
+
présenterait une copie partiellement en erreur comme un succès.
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
`copyPage` · `sourceForm` · `destForm` · `entityPicker` · `progressPanel` · `logPanel` ·
|
|
89
|
+
`resultReport` · `dryRunBadge` · `maskUri` · `escapeHtml` · `CSS` · `SOURCE_TYPES` ·
|
|
90
|
+
`DEST_TYPES` · `DIALECTS`.
|
|
91
|
+
|
|
92
|
+
Voir `llms.txt` pour les signatures.
|
package/demo.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!doctype html><html lang="fr"><head><meta charset="utf-8"/>
|
|
2
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
3
|
+
<title>Copie de données — MostaJS (démo statique)</title><style>
|
|
4
|
+
:root{--blue:#1E88E5;--teal:#00838F;--red:#C62828;--green:#43A047;--ink:#1a2b45;--bg:#f4f7fb;--line:#e3e9f2;--card:#ffffff;--amber:#FBC02D}
|
|
5
|
+
*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,Arial,sans-serif;color:var(--ink);background:var(--bg)}
|
|
6
|
+
.wrap{max-width:980px;margin:0 auto;padding:24px}
|
|
7
|
+
h1{margin:0 0 4px}h2{margin:0 0 12px;font-size:1.05rem;color:var(--teal)}
|
|
8
|
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px}
|
|
9
|
+
.panel-ok{border-color:var(--green)}.panel-err{border-color:var(--red)}
|
|
10
|
+
label{display:block;margin:0 0 10px}label.inline{display:inline-flex;align-items:center;gap:6px;margin-right:14px}
|
|
11
|
+
input[type=text],input[type=number],select{width:100%;padding:8px;border:1px solid var(--line);border-radius:6px;font:inherit}
|
|
12
|
+
label.inline input{width:auto}
|
|
13
|
+
button{padding:9px 14px;border:0;border-radius:6px;background:var(--blue);color:#fff;font:inherit;cursor:pointer}
|
|
14
|
+
button.danger{background:var(--red)}button:disabled{opacity:.5;cursor:not-allowed}
|
|
15
|
+
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:.85rem;font-weight:600}
|
|
16
|
+
.badge-dry{background:#e3f2fd;color:var(--blue)}.badge-live{background:#ffebee;color:var(--red)}
|
|
17
|
+
.dest-row{border-top:1px dashed var(--line);padding-top:12px;margin-top:12px}.dest-row:first-of-type{border-top:0;padding-top:0;margin-top:0}
|
|
18
|
+
.bar-row{display:flex;align-items:center;gap:10px;margin:6px 0}
|
|
19
|
+
.bar-label{flex:0 0 160px;font-weight:600}.bar{flex:1;height:10px;background:var(--line);border-radius:6px;overflow:hidden}
|
|
20
|
+
.bar-fill{display:block;height:100%;background:var(--teal);transition:width .2s}.bar-num{flex:0 0 90px;text-align:right;font-variant-numeric:tabular-nums}
|
|
21
|
+
.log{background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;max-height:260px;overflow:auto;font-size:.85rem;white-space:pre-wrap}
|
|
22
|
+
table.stats{width:100%;border-collapse:collapse}table.stats th,table.stats td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
|
|
23
|
+
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}tr.row-err td{background:#fff5f5}
|
|
24
|
+
.muted{color:#64748b}[hidden]{display:none!important}
|
|
25
|
+
</style></head>
|
|
26
|
+
<body><div class="wrap">
|
|
27
|
+
<h1>Copie de données — MostaJS (démo statique)</h1>
|
|
28
|
+
<p><span class="badge badge-dry" title="Aucune écriture ne sera effectuée">🛡️ Simulation (dry-run)</span></p>
|
|
29
|
+
<form id="copy-form">
|
|
30
|
+
<section class="panel" id="panel-source">
|
|
31
|
+
<h2>1 · Source</h2>
|
|
32
|
+
<label>Type
|
|
33
|
+
<select name="source.type" data-role="source-type"><option value="db" selected>db</option><option value="csv">csv</option><option value="json">json</option><option value="sql">sql</option></select>
|
|
34
|
+
</label>
|
|
35
|
+
<div class="field-db">
|
|
36
|
+
<label>Dialecte
|
|
37
|
+
<select name="source.dialect"><option value="sqljs" selected>sqljs</option><option value="sqlite">sqlite</option><option value="postgres">postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
|
|
38
|
+
</label>
|
|
39
|
+
<label>URI de connexion
|
|
40
|
+
<input name="source.uri" type="text" value="sqljs:///data/ticketflow.sqlite"
|
|
41
|
+
placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
|
|
42
|
+
</label>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="field-file" hidden>
|
|
45
|
+
<label>Fichier
|
|
46
|
+
<input name="source.file" type="text" value="" placeholder="./export.json" />
|
|
47
|
+
</label>
|
|
48
|
+
</div>
|
|
49
|
+
<button type="button" data-action="inspect">Tester & lister les entités</button>
|
|
50
|
+
</section>
|
|
51
|
+
<section class="panel" id="panel-dest">
|
|
52
|
+
<h2>2 · Destinations</h2>
|
|
53
|
+
<div class="dest-row" data-index="0">
|
|
54
|
+
<label>Type
|
|
55
|
+
<select name="dest.0.type" data-role="dest-type"><option value="db" selected>db</option><option value="csv">csv</option><option value="json">json</option><option value="sql-dump">sql-dump</option></select>
|
|
56
|
+
</label>
|
|
57
|
+
<div class="field-db">
|
|
58
|
+
<label>Dialecte
|
|
59
|
+
<select name="dest.0.dialect"><option value="sqljs">sqljs</option><option value="sqlite">sqlite</option><option value="postgres" selected>postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
|
|
60
|
+
</label>
|
|
61
|
+
<label>URI
|
|
62
|
+
<input name="dest.0.uri" type="text" value="postgres://ticket:***@10.0.0.7:5432/ticketflow"
|
|
63
|
+
placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
|
|
64
|
+
</label>
|
|
65
|
+
<label class="inline"><input type="checkbox" name="dest.0.createDb"/> Créer la base</label>
|
|
66
|
+
<label class="inline"><input type="checkbox" name="dest.0.createTables" checked/> Créer les tables</label>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="field-file" hidden>
|
|
69
|
+
<label>Fichier
|
|
70
|
+
<input name="dest.0.file" type="text" value="" placeholder="./dump.sql" />
|
|
71
|
+
</label>
|
|
72
|
+
</div>
|
|
73
|
+
<button type="button" data-action="dest-remove" data-index="0">Retirer</button>
|
|
74
|
+
</div><div class="dest-row" data-index="1">
|
|
75
|
+
<label>Type
|
|
76
|
+
<select name="dest.1.type" data-role="dest-type"><option value="db">db</option><option value="csv">csv</option><option value="json">json</option><option value="sql-dump" selected>sql-dump</option></select>
|
|
77
|
+
</label>
|
|
78
|
+
<div class="field-db" hidden>
|
|
79
|
+
<label>Dialecte
|
|
80
|
+
<select name="dest.1.dialect"><option value="sqljs">sqljs</option><option value="sqlite">sqlite</option><option value="postgres" selected>postgres</option><option value="mysql">mysql</option><option value="mariadb">mariadb</option><option value="mssql">mssql</option><option value="oracle">oracle</option><option value="mongo">mongo</option><option value="duckdb">duckdb</option><option value="clickhouse">clickhouse</option><option value="cockroachdb">cockroachdb</option><option value="firebird">firebird</option><option value="hsqldb">hsqldb</option></select>
|
|
81
|
+
</label>
|
|
82
|
+
<label>URI
|
|
83
|
+
<input name="dest.1.uri" type="text" value=""
|
|
84
|
+
placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
|
|
85
|
+
</label>
|
|
86
|
+
<label class="inline"><input type="checkbox" name="dest.1.createDb"/> Créer la base</label>
|
|
87
|
+
<label class="inline"><input type="checkbox" name="dest.1.createTables" checked/> Créer les tables</label>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="field-file">
|
|
90
|
+
<label>Fichier
|
|
91
|
+
<input name="dest.1.file" type="text" value="./sauvegarde.sql" placeholder="./dump.sql" />
|
|
92
|
+
</label>
|
|
93
|
+
</div>
|
|
94
|
+
<button type="button" data-action="dest-remove" data-index="1">Retirer</button>
|
|
95
|
+
</div>
|
|
96
|
+
<button type="button" data-action="dest-add">+ Ajouter une destination</button>
|
|
97
|
+
</section>
|
|
98
|
+
<section class="panel" id="panel-entities">
|
|
99
|
+
<h2>3 · Entités & options</h2>
|
|
100
|
+
<div class="entities"><label class="inline"><input type="checkbox" name="entity" value="Ticket" checked/> Ticket <span class="muted">(12480)</span></label><label class="inline"><input type="checkbox" name="entity" value="Counter" checked/> Counter <span class="muted">(6)</span></label><label class="inline"><input type="checkbox" name="entity" value="User" checked/> User <span class="muted">(14)</span></label></div>
|
|
101
|
+
<label>Taille de lot (batchSize)
|
|
102
|
+
<input name="batchSize" type="number" min="1" value="500" />
|
|
103
|
+
</label>
|
|
104
|
+
<label class="inline">
|
|
105
|
+
<input type="checkbox" name="dryRun" checked/>
|
|
106
|
+
Simulation — ne rien écrire
|
|
107
|
+
</label>
|
|
108
|
+
</section>
|
|
109
|
+
<p>
|
|
110
|
+
<button type="button" data-action="run" class="">
|
|
111
|
+
▶ Lancer la simulation
|
|
112
|
+
</button>
|
|
113
|
+
</p>
|
|
114
|
+
</form>
|
|
115
|
+
<section class="panel" id="panel-progress"><h2>Progression</h2><div class="bar-row">
|
|
116
|
+
<span class="bar-label">Ticket</span>
|
|
117
|
+
<span class="bar"><span class="bar-fill" style="width:66%"></span></span>
|
|
118
|
+
<span class="bar-num">8200/12480</span>
|
|
119
|
+
</div><div class="bar-row">
|
|
120
|
+
<span class="bar-label">Counter</span>
|
|
121
|
+
<span class="bar"><span class="bar-fill" style="width:100%"></span></span>
|
|
122
|
+
<span class="bar-num">6/6</span>
|
|
123
|
+
</div></section>
|
|
124
|
+
<section class="panel" id="panel-log"><h2>Journal</h2><pre class="log">▶ Loading from db (sqljs)...
|
|
125
|
+
Ticket : 12480 rows
|
|
126
|
+
connexion à postgres://ticket:***@10.0.0.7:5432/ticketflow</pre></section>
|
|
127
|
+
<section class="panel panel-err" id="panel-result">
|
|
128
|
+
<h2>⚠️ Terminé avec 3 erreurs</h2>
|
|
129
|
+
<p>
|
|
130
|
+
<strong>12483</strong> enregistrement(s) copié(s) ·
|
|
131
|
+
<strong>3</strong> erreur(s) · 4390 ms<br/>
|
|
132
|
+
<span class="muted">sqljs:///data/ticketflow.sqlite → postgres://ticket:***@10.0.0.7:5432/ticketflow</span>
|
|
133
|
+
</p>
|
|
134
|
+
<table class="stats">
|
|
135
|
+
<thead><tr><th>Entité</th><th class="num">Copiés</th><th class="num">Erreurs</th><th class="num">Durée</th><th>1ʳᵉ erreur</th></tr></thead>
|
|
136
|
+
<tbody><tr class="row-err">
|
|
137
|
+
<td>Ticket</td>
|
|
138
|
+
<td class="num">12477</td>
|
|
139
|
+
<td class="num">3</td>
|
|
140
|
+
<td class="num">4210 ms</td>
|
|
141
|
+
<td>null value in column "serviceType"</td>
|
|
142
|
+
</tr><tr class="">
|
|
143
|
+
<td>Counter</td>
|
|
144
|
+
<td class="num">6</td>
|
|
145
|
+
<td class="num">0</td>
|
|
146
|
+
<td class="num">12 ms</td>
|
|
147
|
+
<td></td>
|
|
148
|
+
</tr></tbody>
|
|
149
|
+
</table>
|
|
150
|
+
</section>
|
|
151
|
+
</div></body></html>
|
package/llms.txt
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @mostajs/orm-copy-data-ui-html — 0.1.0
|
|
2
|
+
Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Niveau N2 · zéro dépendance runtime
|
|
3
|
+
|
|
4
|
+
RÔLE
|
|
5
|
+
IHM de @mostajs/orm-copy-data (moteur, peer). Bibliothèque de RENDU PURE : état → chaîne HTML.
|
|
6
|
+
Ne copie rien, n'ouvre aucune connexion. L'HÔTE exécute (contrat HTTP ci-dessous).
|
|
7
|
+
|
|
8
|
+
API (import { … } from '@mostajs/orm-copy-data-ui-html')
|
|
9
|
+
copyPage(state) -> string page complète (compose tous les blocs + CSS embarquée)
|
|
10
|
+
sourceForm(state) -> string source : db | csv | json | sql
|
|
11
|
+
destForm(state) -> string destinations MULTIPLES : db | csv | json | sql-dump
|
|
12
|
+
entityPicker(state) -> string entités à copier + batchSize + case « simulation »
|
|
13
|
+
progressPanel(progress) -> string progress = { <entité>: { copied, total } } (onProgress)
|
|
14
|
+
logPanel(lines) -> string lignes de journal (onLog)
|
|
15
|
+
resultReport(result) -> string CopyResult : stats/entité, totalCopied, totalErrors, durée
|
|
16
|
+
dryRunBadge(state) -> string bandeau simulation / écriture réelle
|
|
17
|
+
maskUri(uri) -> string postgres://u:pass@h/db → postgres://u:***@h/db
|
|
18
|
+
escapeHtml(s) -> string
|
|
19
|
+
CSS, THEME, SOURCE_TYPES, DEST_TYPES, DIALECTS, moduleInfo
|
|
20
|
+
|
|
21
|
+
ÉTAT (sérialisable)
|
|
22
|
+
{ title, source:{type,dialect,uri,file}, dests:[{type,dialect,uri,file,createDb,createTables}],
|
|
23
|
+
entities:[{name,count}|string], selectedEntities:[string]|null, batchSize, dryRun,
|
|
24
|
+
progress:{entité:{copied,total}}, log:[string], result:CopyResult }
|
|
25
|
+
|
|
26
|
+
CONTRAT HTTP (implémenté par l'hôte ; référence = examples/server.mjs)
|
|
27
|
+
GET /copy → copyPage(state)
|
|
28
|
+
POST /copy/inspect → { ok, entities:[{name,count}] } teste la source
|
|
29
|
+
POST /copy/run → { ok, jobId } lance la copie
|
|
30
|
+
GET /copy/events/:jobId → SSE : {type:'progress'|'log'|'result'|'end', data}
|
|
31
|
+
|
|
32
|
+
GARDE-FOUS (une copie est destructrice)
|
|
33
|
+
1. dryRun ACTIF par défaut (state.dryRun !== false) — l'écriture réelle est explicite + confirmée.
|
|
34
|
+
2. Mots de passe MASQUÉS dans tout rendu et tout journal (maskUri appliqué partout).
|
|
35
|
+
3. Confirmation obligatoire avant écriture réelle.
|
|
36
|
+
4. Aucune URI persistée par le module.
|
|
37
|
+
|
|
38
|
+
DÉPENDANCES
|
|
39
|
+
runtime : AUCUNE · peer : @mostajs/orm-copy-data >= 0.3.1 (l'hôte l'exécute) · dev : @mostajs/mjs-unit
|
|
40
|
+
|
|
41
|
+
TESTS
|
|
42
|
+
npm test 17 tests de rendu (mjs-unit)
|
|
43
|
+
node test-scripts/e2e-demo.mjs E2E : contrat HTTP + SSE + copie RÉELLE écrite sur disque
|
|
44
|
+
|
|
45
|
+
NOTE ÉCOSYSTÈME
|
|
46
|
+
orm-copy-data 0.3.0 déclarait `onProgress` sans jamais l'appeler (rappel mort) : corrigé en
|
|
47
|
+
0.3.1, d'où le peer >= 0.3.1 pour que la progression soit réellement alimentée.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/orm-copy-data-ui-html",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "IHM zéro-dépendance de @mostajs/orm-copy-data : source/destinations, entités, progression et journal en direct, rapport de copie. Rendu pur (état → HTML), l'hôte exécute.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"demo.html",
|
|
14
|
+
"README.md",
|
|
15
|
+
"llms.txt",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bash test-scripts/run-tests.sh",
|
|
20
|
+
"example": "node examples/server.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"orm",
|
|
24
|
+
"copy",
|
|
25
|
+
"migration",
|
|
26
|
+
"etl",
|
|
27
|
+
"ui",
|
|
28
|
+
"html",
|
|
29
|
+
"zero-dep",
|
|
30
|
+
"mostajs"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@mostajs/orm-copy-data": ">=0.3.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@mostajs/orm-copy-data": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@mostajs/mjs-unit": "^0.3.1",
|
|
42
|
+
"@mostajs/orm-copy-data": "^0.3.1"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/orm-copy-data-ui-html — IHM ZÉRO-DÉPENDANCE de @mostajs/orm-copy-data.
|
|
3
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
* Licence : AGPL-3.0-or-later
|
|
5
|
+
*
|
|
6
|
+
* Bibliothèque de RENDU pure : des fonctions `état → chaîne HTML`, donc testables sous
|
|
7
|
+
* Node, sans navigateur ni base de données. Elle NE COPIE RIEN et n'ouvre aucune
|
|
8
|
+
* connexion — le moteur `@mostajs/orm-copy-data` (peer) reste seul responsable de
|
|
9
|
+
* l'exécution, et l'application hôte de son authentification (cf. docs §4, variante A).
|
|
10
|
+
*
|
|
11
|
+
* Surface couverte = TOUTES les capacités du moteur :
|
|
12
|
+
* sources db · csv · json · sql
|
|
13
|
+
* destinations db · csv · json · sql-dump (multiples)
|
|
14
|
+
* options entities · batchSize · onProgress · onLog
|
|
15
|
+
* résultat CopyStats par entité · totalCopied · totalErrors · durée · firstError
|
|
16
|
+
*
|
|
17
|
+
* GARDE-FOUS (une copie est destructrice) :
|
|
18
|
+
* 1. `dryRun` par DÉFAUT — l'écriture réelle est un choix explicite.
|
|
19
|
+
* 2. Les mots de passe des URI sont MASQUÉS dans tout rendu et tout journal.
|
|
20
|
+
* 3. Écriture vers une base de destination NON VIDE → confirmation exigée.
|
|
21
|
+
* 4. Le module ne persiste AUCUNE URI.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const moduleInfo = { name: '@mostajs/orm-copy-data-ui-html', version: '0.1.0' };
|
|
25
|
+
|
|
26
|
+
/** Capacités du moteur — la source unique dont dérivent les listes déroulantes. */
|
|
27
|
+
export const SOURCE_TYPES = ['db', 'csv', 'json', 'sql'];
|
|
28
|
+
export const DEST_TYPES = ['db', 'csv', 'json', 'sql-dump'];
|
|
29
|
+
export const DIALECTS = [
|
|
30
|
+
'sqljs', 'sqlite', 'postgres', 'mysql', 'mariadb', 'mssql', 'oracle',
|
|
31
|
+
'mongo', 'duckdb', 'clickhouse', 'cockroachdb', 'firebird', 'hsqldb',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const THEME = {
|
|
35
|
+
blue: '#1E88E5', teal: '#00838F', amber: '#FBC02D', red: '#C62828',
|
|
36
|
+
green: '#43A047', ink: '#1a2b45', bg: '#f4f7fb', line: '#e3e9f2', card: '#ffffff',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function escapeHtml(s) {
|
|
40
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
|
41
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Masque le mot de passe d'une URI de connexion.
|
|
47
|
+
* `postgres://alice:s3cr3t@hote:5432/base` → `postgres://alice:***@hote:5432/base`
|
|
48
|
+
*
|
|
49
|
+
* Appliqué à TOUT ce qui part vers le navigateur : champs, journal, rapport. Un mot de
|
|
50
|
+
* passe de base de production ne doit jamais transiter dans une page ni dans un log.
|
|
51
|
+
*/
|
|
52
|
+
export function maskUri(uri) {
|
|
53
|
+
const s = String(uri ?? '');
|
|
54
|
+
if (!s) return '';
|
|
55
|
+
return s.replace(/(:\/\/[^:@/\s]+):[^@\s]*@/g, '$1:***@');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Masque les URI présentes dans une ligne de journal quelconque. */
|
|
59
|
+
export function maskLine(line) {
|
|
60
|
+
return maskUri(String(line ?? ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pct = (copied, total) => (total > 0 ? Math.min(100, Math.round((copied / total) * 100)) : 0);
|
|
64
|
+
const opt = (v, sel) => `<option value="${escapeHtml(v)}"${v === sel ? ' selected' : ''}>${escapeHtml(v)}</option>`;
|
|
65
|
+
|
|
66
|
+
/** Bandeau du mode simulation. `dryRun` vaut vrai par défaut (absent = simulation). */
|
|
67
|
+
export function dryRunBadge(state = {}) {
|
|
68
|
+
const dry = state.dryRun !== false;
|
|
69
|
+
return dry
|
|
70
|
+
? `<span class="badge badge-dry" title="Aucune écriture ne sera effectuée">🛡️ Simulation (dry-run)</span>`
|
|
71
|
+
: `<span class="badge badge-live" title="Les données seront réellement écrites">⚠️ Écriture RÉELLE</span>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Formulaire de la SOURCE. `db` demande dialecte + URI ; `csv`/`json`/`sql` demandent un fichier.
|
|
76
|
+
* L'URI est ré-affichée MASQUÉE : la page ne réexpose jamais un mot de passe déjà saisi.
|
|
77
|
+
*/
|
|
78
|
+
export function sourceForm(state = {}) {
|
|
79
|
+
const src = state.source || {};
|
|
80
|
+
const type = src.type || 'db';
|
|
81
|
+
const isDb = type === 'db';
|
|
82
|
+
return `<section class="panel" id="panel-source">
|
|
83
|
+
<h2>1 · Source</h2>
|
|
84
|
+
<label>Type
|
|
85
|
+
<select name="source.type" data-role="source-type">${SOURCE_TYPES.map((t) => opt(t, type)).join('')}</select>
|
|
86
|
+
</label>
|
|
87
|
+
<div class="field-db"${isDb ? '' : ' hidden'}>
|
|
88
|
+
<label>Dialecte
|
|
89
|
+
<select name="source.dialect">${DIALECTS.map((d) => opt(d, src.dialect || 'sqljs')).join('')}</select>
|
|
90
|
+
</label>
|
|
91
|
+
<label>URI de connexion
|
|
92
|
+
<input name="source.uri" type="text" value="${escapeHtml(maskUri(src.uri))}"
|
|
93
|
+
placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
|
|
94
|
+
</label>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="field-file"${isDb ? ' hidden' : ''}>
|
|
97
|
+
<label>Fichier
|
|
98
|
+
<input name="source.file" type="text" value="${escapeHtml(src.file || '')}" placeholder="./export.json" />
|
|
99
|
+
</label>
|
|
100
|
+
</div>
|
|
101
|
+
<button type="button" data-action="inspect">Tester & lister les entités</button>
|
|
102
|
+
</section>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Formulaire des DESTINATIONS (le moteur en accepte PLUSIEURS : on peut copier vers une
|
|
107
|
+
* base ET produire un dump SQL dans la même passe).
|
|
108
|
+
*/
|
|
109
|
+
export function destForm(state = {}) {
|
|
110
|
+
const dests = Array.isArray(state.dests) && state.dests.length ? state.dests : [{ type: 'db' }];
|
|
111
|
+
const row = (d, i) => {
|
|
112
|
+
const isDb = (d.type || 'db') === 'db';
|
|
113
|
+
return `<div class="dest-row" data-index="${i}">
|
|
114
|
+
<label>Type
|
|
115
|
+
<select name="dest.${i}.type" data-role="dest-type">${DEST_TYPES.map((t) => opt(t, d.type || 'db')).join('')}</select>
|
|
116
|
+
</label>
|
|
117
|
+
<div class="field-db"${isDb ? '' : ' hidden'}>
|
|
118
|
+
<label>Dialecte
|
|
119
|
+
<select name="dest.${i}.dialect">${DIALECTS.map((x) => opt(x, d.dialect || 'postgres')).join('')}</select>
|
|
120
|
+
</label>
|
|
121
|
+
<label>URI
|
|
122
|
+
<input name="dest.${i}.uri" type="text" value="${escapeHtml(maskUri(d.uri))}"
|
|
123
|
+
placeholder="postgres://utilisateur:motdepasse@hote:5432/base" />
|
|
124
|
+
</label>
|
|
125
|
+
<label class="inline"><input type="checkbox" name="dest.${i}.createDb"${d.createDb ? ' checked' : ''}/> Créer la base</label>
|
|
126
|
+
<label class="inline"><input type="checkbox" name="dest.${i}.createTables"${d.createTables !== false ? ' checked' : ''}/> Créer les tables</label>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="field-file"${isDb ? ' hidden' : ''}>
|
|
129
|
+
<label>Fichier
|
|
130
|
+
<input name="dest.${i}.file" type="text" value="${escapeHtml(d.file || '')}" placeholder="./dump.sql" />
|
|
131
|
+
</label>
|
|
132
|
+
</div>
|
|
133
|
+
${dests.length > 1 ? `<button type="button" data-action="dest-remove" data-index="${i}">Retirer</button>` : ''}
|
|
134
|
+
</div>`;
|
|
135
|
+
};
|
|
136
|
+
return `<section class="panel" id="panel-dest">
|
|
137
|
+
<h2>2 · Destinations</h2>
|
|
138
|
+
${dests.map(row).join('')}
|
|
139
|
+
<button type="button" data-action="dest-add">+ Ajouter une destination</button>
|
|
140
|
+
</section>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Choix des entités (`CopyOptions.entities`) et de la taille de lot (`batchSize`).
|
|
145
|
+
* Tant que la source n'a pas été inspectée, la liste est vide : on le DIT, plutôt que
|
|
146
|
+
* d'afficher un bloc vide qui laisserait croire qu'il n'y a rien à copier.
|
|
147
|
+
*/
|
|
148
|
+
export function entityPicker(state = {}) {
|
|
149
|
+
const all = state.entities || [];
|
|
150
|
+
const selected = state.selectedEntities || null; // null = toutes
|
|
151
|
+
const body = all.length === 0
|
|
152
|
+
? `<p class="muted">Aucune entité connue — testez d'abord la source (« Tester & lister les entités »).</p>`
|
|
153
|
+
: all.map((e) => {
|
|
154
|
+
const name = typeof e === 'string' ? e : e.name;
|
|
155
|
+
const count = typeof e === 'object' && e.count != null ? ` <span class="muted">(${e.count})</span>` : '';
|
|
156
|
+
const on = selected === null || selected.includes(name);
|
|
157
|
+
return `<label class="inline"><input type="checkbox" name="entity" value="${escapeHtml(name)}"${on ? ' checked' : ''}/> ${escapeHtml(name)}${count}</label>`;
|
|
158
|
+
}).join('');
|
|
159
|
+
return `<section class="panel" id="panel-entities">
|
|
160
|
+
<h2>3 · Entités & options</h2>
|
|
161
|
+
<div class="entities">${body}</div>
|
|
162
|
+
<label>Taille de lot (batchSize)
|
|
163
|
+
<input name="batchSize" type="number" min="1" value="${Number(state.batchSize) > 0 ? Number(state.batchSize) : 500}" />
|
|
164
|
+
</label>
|
|
165
|
+
<label class="inline">
|
|
166
|
+
<input type="checkbox" name="dryRun"${state.dryRun !== false ? ' checked' : ''}/>
|
|
167
|
+
Simulation — ne rien écrire
|
|
168
|
+
</label>
|
|
169
|
+
</section>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Progression PAR ENTITÉ, alimentée par `onProgress(entity, copied, total)`. */
|
|
173
|
+
export function progressPanel(progress = {}) {
|
|
174
|
+
const rows = Object.entries(progress);
|
|
175
|
+
if (rows.length === 0) return `<section class="panel" id="panel-progress"><h2>Progression</h2><p class="muted">En attente…</p></section>`;
|
|
176
|
+
const bars = rows.map(([entity, p]) => {
|
|
177
|
+
const copied = p.copied || 0;
|
|
178
|
+
const total = p.total || 0;
|
|
179
|
+
const v = pct(copied, total);
|
|
180
|
+
return `<div class="bar-row">
|
|
181
|
+
<span class="bar-label">${escapeHtml(entity)}</span>
|
|
182
|
+
<span class="bar"><span class="bar-fill" style="width:${v}%"></span></span>
|
|
183
|
+
<span class="bar-num">${copied}/${total || '?'}</span>
|
|
184
|
+
</div>`;
|
|
185
|
+
}).join('');
|
|
186
|
+
return `<section class="panel" id="panel-progress"><h2>Progression</h2>${bars}</section>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Journal en direct (`onLog`) — chaque ligne passe par le masquage des URI. */
|
|
190
|
+
export function logPanel(lines = []) {
|
|
191
|
+
const body = lines.length === 0
|
|
192
|
+
? '<p class="muted">—</p>'
|
|
193
|
+
: `<pre class="log">${lines.map((l) => escapeHtml(maskLine(l))).join('\n')}</pre>`;
|
|
194
|
+
return `<section class="panel" id="panel-log"><h2>Journal</h2>${body}</section>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rapport final (`CopyResult`). Met en avant les ERREURS : une copie « terminée » avec
|
|
199
|
+
* 12 000 lignes copiées et 3 en erreur n'est PAS un succès, et le rapport doit le dire.
|
|
200
|
+
*/
|
|
201
|
+
export function resultReport(result) {
|
|
202
|
+
if (!result) return '';
|
|
203
|
+
const stats = result.stats || [];
|
|
204
|
+
const errs = result.totalErrors || 0;
|
|
205
|
+
const rows = stats.map((s) => `<tr class="${s.errors > 0 ? 'row-err' : ''}">
|
|
206
|
+
<td>${escapeHtml(s.entity)}</td>
|
|
207
|
+
<td class="num">${s.copied}</td>
|
|
208
|
+
<td class="num">${s.errors}</td>
|
|
209
|
+
<td class="num">${s.durationMs} ms</td>
|
|
210
|
+
<td>${s.firstError ? escapeHtml(maskLine(s.firstError)) : ''}</td>
|
|
211
|
+
</tr>`).join('');
|
|
212
|
+
return `<section class="panel ${errs > 0 ? 'panel-err' : 'panel-ok'}" id="panel-result">
|
|
213
|
+
<h2>${errs > 0 ? `⚠️ Terminé avec ${errs} erreur${errs > 1 ? 's' : ''}` : '✅ Copie terminée'}</h2>
|
|
214
|
+
<p>
|
|
215
|
+
<strong>${result.totalCopied || 0}</strong> enregistrement(s) copié(s) ·
|
|
216
|
+
<strong>${errs}</strong> erreur(s) · ${result.durationMs || 0} ms<br/>
|
|
217
|
+
<span class="muted">${escapeHtml(maskUri(result.source))} → ${(result.destinations || []).map((d) => escapeHtml(maskUri(d))).join(', ')}</span>
|
|
218
|
+
</p>
|
|
219
|
+
<table class="stats">
|
|
220
|
+
<thead><tr><th>Entité</th><th class="num">Copiés</th><th class="num">Erreurs</th><th class="num">Durée</th><th>1ʳᵉ erreur</th></tr></thead>
|
|
221
|
+
<tbody>${rows}</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</section>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Feuille de style embarquée (zéro-dép : aucune CSS externe à charger). */
|
|
227
|
+
export const CSS = `
|
|
228
|
+
:root{--blue:${THEME.blue};--teal:${THEME.teal};--red:${THEME.red};--green:${THEME.green};--ink:${THEME.ink};--bg:${THEME.bg};--line:${THEME.line};--card:${THEME.card};--amber:${THEME.amber}}
|
|
229
|
+
*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,Arial,sans-serif;color:var(--ink);background:var(--bg)}
|
|
230
|
+
.wrap{max-width:980px;margin:0 auto;padding:24px}
|
|
231
|
+
h1{margin:0 0 4px}h2{margin:0 0 12px;font-size:1.05rem;color:var(--teal)}
|
|
232
|
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 16px}
|
|
233
|
+
.panel-ok{border-color:var(--green)}.panel-err{border-color:var(--red)}
|
|
234
|
+
label{display:block;margin:0 0 10px}label.inline{display:inline-flex;align-items:center;gap:6px;margin-right:14px}
|
|
235
|
+
input[type=text],input[type=number],select{width:100%;padding:8px;border:1px solid var(--line);border-radius:6px;font:inherit}
|
|
236
|
+
label.inline input{width:auto}
|
|
237
|
+
button{padding:9px 14px;border:0;border-radius:6px;background:var(--blue);color:#fff;font:inherit;cursor:pointer}
|
|
238
|
+
button.danger{background:var(--red)}button:disabled{opacity:.5;cursor:not-allowed}
|
|
239
|
+
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:.85rem;font-weight:600}
|
|
240
|
+
.badge-dry{background:#e3f2fd;color:var(--blue)}.badge-live{background:#ffebee;color:var(--red)}
|
|
241
|
+
.dest-row{border-top:1px dashed var(--line);padding-top:12px;margin-top:12px}.dest-row:first-of-type{border-top:0;padding-top:0;margin-top:0}
|
|
242
|
+
.bar-row{display:flex;align-items:center;gap:10px;margin:6px 0}
|
|
243
|
+
.bar-label{flex:0 0 160px;font-weight:600}.bar{flex:1;height:10px;background:var(--line);border-radius:6px;overflow:hidden}
|
|
244
|
+
.bar-fill{display:block;height:100%;background:var(--teal);transition:width .2s}.bar-num{flex:0 0 90px;text-align:right;font-variant-numeric:tabular-nums}
|
|
245
|
+
.log{background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;max-height:260px;overflow:auto;font-size:.85rem;white-space:pre-wrap}
|
|
246
|
+
table.stats{width:100%;border-collapse:collapse}table.stats th,table.stats td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left}
|
|
247
|
+
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}tr.row-err td{background:#fff5f5}
|
|
248
|
+
.muted{color:#64748b}[hidden]{display:none!important}
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
/** Page complète, prête à servir. Compose tous les blocs. */
|
|
252
|
+
export function copyPage(state = {}) {
|
|
253
|
+
const title = state.title || 'Copie de données — MostaJS';
|
|
254
|
+
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"/>
|
|
255
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
256
|
+
<title>${escapeHtml(title)}</title><style>${CSS}</style></head>
|
|
257
|
+
<body><div class="wrap">
|
|
258
|
+
<h1>${escapeHtml(title)}</h1>
|
|
259
|
+
<p>${dryRunBadge(state)}</p>
|
|
260
|
+
<form id="copy-form">
|
|
261
|
+
${sourceForm(state)}
|
|
262
|
+
${destForm(state)}
|
|
263
|
+
${entityPicker(state)}
|
|
264
|
+
<p>
|
|
265
|
+
<button type="button" data-action="run" class="${state.dryRun === false ? 'danger' : ''}">
|
|
266
|
+
${state.dryRun === false ? '⚠️ Lancer la copie RÉELLE' : '▶ Lancer la simulation'}
|
|
267
|
+
</button>
|
|
268
|
+
</p>
|
|
269
|
+
</form>
|
|
270
|
+
${progressPanel(state.progress)}
|
|
271
|
+
${logPanel(state.log)}
|
|
272
|
+
${resultReport(state.result)}
|
|
273
|
+
</div></body></html>`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export default {
|
|
277
|
+
moduleInfo, SOURCE_TYPES, DEST_TYPES, DIALECTS, THEME, CSS,
|
|
278
|
+
escapeHtml, maskUri, maskLine,
|
|
279
|
+
dryRunBadge, sourceForm, destForm, entityPicker, progressPanel, logPanel, resultReport, copyPage,
|
|
280
|
+
};
|