From 80ef27e88c459c44b7ac93e929910a1745bd2011 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Mon, 10 Aug 2026 16:31:00 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(installation):=20un=20poste=20neuf=20s?= =?UTF-8?q?ort=20utilisable,=20et=20le=20bouton=20red=C3=A9marrer=20red?= =?UTF-8?q?=C3=A9marre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre défauts trouvés en installant un poste de production, dont deux le rendaient inutilisable. Aucun n'était visible sans installer pour de vrai. 1. LE BOUTON « REDÉMARRER LE POSTE » LAISSAIT LE POSTE ÉTEINT. setRecovery n'appelait que SetRecoveryActions, jamais SetRecoveryActionsOnNonCrashFailures. Windows met ce drapeau à faux par défaut, et faux veut dire « n'appliquer les reprises que si le service s'arrête SANS signaler SERVICE_STOPPED ». Or l'arrêt ordonné de §13.4 se termine proprement et le signale : le SCM voyait un arrêt normal et n'appliquait aucune des trois reprises de §15.2. La prémisse fausse était écrite à cinq endroits, dont ADR-055 lui-même et un banc VERT intitulé « THE CODE IS THE MECHANISM ». Tous corrigés ; l'ADR porte un amendement daté plutôt qu'une réécriture. setRecovery prend désormais une interface de deux méthodes, parce qu'un banc qui ne lirait que le plan resterait vert si quelqu'un retirait le second appel — c'est exactement le défaut qui avait été livré. Les postes déjà installés ne sont pas réparés par ce lot : décision de périmètre du propriétaire du produit. TROUBLESHOOTING.md dit sans détour qu'une mise à jour ne répare pas ce défaut, le réglage fautif vivant dans Windows et non dans le binaire. 2. UNE ADRESSE D'ÉCOUTE VIDE VERROUILLAIT TOUTE L'ADMINISTRATION. Export remet le bloc réseau à zéro et la cible release exige un export : le fichier livré porte littéralement "listen": "", donc le poste refusait sa propre configuration de livraison. PUT /admin/api/config valide le document ENTIER, si bien qu'une seule faute portant sur un champ qu'aucun écran n'éditait empêchait le moindre enregistrement, sur toutes les pages. Config.UnmarshalJSON ramène une adresse vide à celle du profil neutre, comme il le fait déjà pour update.repository et ui.min_products_for_chip. L'ordre de livraison était contraint et il a été tenu : la garde d'import d'abord, le rattrapage du décodeur ensuite. Dans l'autre sens, un défaut bloquant serait devenu silencieux — le 422 était le seul garde-fou empêchant un export importé de déplacer l'écouteur d'un poste de 0.0.0.0:8085 vers la boucle locale. La page Poste gagne enfin les champs network.listen et admin_on_lan : la mécanique de déplacement à chaud était livrée depuis toujours, seul le champ manquait — et §14.4 ne le listait pas, c'est de là que venait l'oubli. 3. LE PLANCHER DU MOT DE PASSE D'ADMINISTRATION PASSE À QUATRE. Demande du propriétaire du produit. Le chiffre existait en six exemplaires que rien ne reliait ; il est ramené à une autorité, web.MinPasswordLength, les copies qu'un autre langage impose étant liées à elle par un banc qui LIT le fichier source. Un vrai défaut, indépendant de la valeur, a été trouvé au passage : la route HTTP comptait des octets là où la ligne de commande comptait des runes. Les quatre portes comptent maintenant des points de code, PowerShell compris. 4. L'INSTALLATION DEMANDE CE QU'ELLE SEULE PEUT SAVOIR (ADR-060). Mot de passe d'administration, numéro et nom du poste, et la balance sort déclarée absente faute d'en avoir une de branchée. Nouvelle action « openscale config station ». Aucune option de config ne prend de secret : le mot de passe passe par l'entrée standard, un argument se lisant dans la liste des processus. Un poste neuf passe de 4 fautes à 1. L'empreinte va de 6c06605a à 79ba8bfc tant que la balance est déclarée absente, puis revient à 6c06605a dès qu'elle est redéclarée — mesuré dans les deux sens, et dit sur la fiche, dans le message de fin et dans INSTALLATION.md, parce que §15.5 fait comparer les quatre empreintes à l'œil. Une relecture adverse a rattrapé ce qui aurait annulé le bénéfice : --no-scale vide scale.type, or aucun geste de l'écran ne le remettait. La détection servait pourtant déjà le driver reconnu, et detect.go l'écrivait dans son propre commentaire depuis toujours. Un bouton « Utiliser cette balance » écrit présence, protocole et port d'un seul geste. LES DEUX INSTALLEURS MARCHENT DÉSORMAIS EN COUPLE. install.sh porte les six mêmes options, les mêmes questions, le même plancher, la même fiche et le même message de fin. La règle n'est pas gardée par la bonne volonté : deploy/parity_test.go lit le param() de l'un et le case de l'autre et compare dans les deux sens. Une exception est permise, jamais muette — motivée dans le banc ET dans le script qui n'a pas l'option. Deux pièges mesurés plutôt que devinés. Sous dash, la longueur de chaîne du shell compte des OCTETS quelle que soit la locale : un plancher de quatre appliqué ainsi aurait accepté « éàç », que le binaire refuse. Et /proc//cmdline est lisible par tous les comptes quand /proc//environ ne l'est que du propriétaire : le secret descend par l'environnement, effacé avant le premier processus fils. STATUT : l'application est EN PRODUCTION. Recette passée en magasin, deux semaines d'exploitation réelle tenues. Le lot L9 est clos. Vérification : go build, go test en deux passes (avec cgo et -race, puis sans), 3 141 tests verts sur 35 paquets, go vet et gofmt muets, 1 021 tests web, svelte-check à 0 erreur, les six scripts PowerShell parsent sous 5.1 et 7, les quatre scripts POSIX passent sous le vrai dash d'une Debian. Ce qui n'est PAS fait, et n'est pas à croire fait : aucun poste réel n'a été installé avec ce binaire ; sc qfailureflag n'a pas été relevé sur le poste de production ; doctor ne contrôle pas le drapeau de reprise, donc ce défaut resterait invisible sans matériel ; install.sh n'a jamais été lancé en entier, donc rien de ce qui touche apt-get, useradd, udev, polkit ni systemctl n'est éprouvé ; les deux bootstraps ne sont pas à parité sur leurs options propres, écart jugé non bloquant par le propriétaire du produit. --- CLAUDE.md | 15 +- INSTALLATION.md | 257 ++++-- README.md | 33 +- SUIVI.md | 41 +- TROUBLESHOOTING.md | 33 +- cmd/openscale/config.go | 60 +- cmd/openscale/configread_test.go | 38 +- cmd/openscale/configstation.go | 161 ++++ cmd/openscale/configstation_test.go | 250 ++++++ cmd/openscale/configwrite.go | 24 +- cmd/openscale/configwrite_test.go | 83 +- cmd/openscale/failure.go | 10 +- cmd/openscale/main.go | 7 + cmd/openscale/maintenance_test.go | 29 +- deploy/bootstrap_test.go | 128 ++- deploy/installer_test.go | 357 +++++++- deploy/linux/bootstrap.sh | 182 +++- deploy/linux/install.sh | 791 +++++++++++++++++- deploy/parity_test.go | 663 +++++++++++++++ deploy/powershell_test.go | 196 +++++ deploy/windows/bootstrap.ps1 | 68 +- deploy/windows/common.ps1 | 307 ++++++- deploy/windows/install.ps1 | 320 ++++++- docs/02-architecture.md | 41 +- internal/domain/config_json.go | 10 + internal/domain/config_json_test.go | 78 ++ internal/platform/service_windows.go | 64 +- internal/platform/service_windows_test.go | 129 ++- internal/platform/supervised_windows.go | 7 +- internal/web/argon2id.go | 18 + internal/web/configtransfer.go | 9 + internal/web/configtransfer_test.go | 134 +++ internal/web/dist/admin.html | 6 +- internal/web/dist/assets/admin-FoRrpu8q.js | 1 + internal/web/dist/assets/admin-tZr91ixu.js | 1 - .../{index-DPFlyzED.js => index-9fjLb9_b.js} | 4 +- internal/web/dist/assets/mount-1OvlPcKC.js | 102 --- internal/web/dist/assets/mount-DcOlpQNH.css | 1 + internal/web/dist/assets/mount-MmpjwdB2.css | 1 - internal/web/dist/assets/mount-VqoFW5_m.js | 103 +++ internal/web/dist/index.html | 2 +- internal/web/maintenance.go | 9 +- internal/web/recovery_test.go | 28 +- internal/web/sessionroutes.go | 10 +- internal/web/sessionroutes_test.go | 149 ++++ web/src/admin/components/Maintenance.svelte | 84 +- web/src/admin/components/PasswordPanel.svelte | 13 +- web/src/admin/lib/api.ts | 14 + web/src/admin/lib/clone.ts | 20 +- web/src/admin/pages/Hardware.svelte | 104 ++- web/src/admin/pages/Station.svelte | 34 +- web/test/admin-hardware.test.ts | 149 +++- web/test/admin-maintenance.test.ts | 100 ++- web/test/admin-protect.test.ts | 23 +- web/test/admin-station.test.ts | 118 ++- 55 files changed, 5174 insertions(+), 445 deletions(-) create mode 100644 cmd/openscale/configstation.go create mode 100644 cmd/openscale/configstation_test.go create mode 100644 deploy/parity_test.go create mode 100644 internal/web/configtransfer_test.go create mode 100644 internal/web/dist/assets/admin-FoRrpu8q.js delete mode 100644 internal/web/dist/assets/admin-tZr91ixu.js rename internal/web/dist/assets/{index-DPFlyzED.js => index-9fjLb9_b.js} (99%) delete mode 100644 internal/web/dist/assets/mount-1OvlPcKC.js create mode 100644 internal/web/dist/assets/mount-DcOlpQNH.css delete mode 100644 internal/web/dist/assets/mount-MmpjwdB2.css create mode 100644 internal/web/dist/assets/mount-VqoFW5_m.js create mode 100644 internal/web/sessionroutes_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1a303b9..c5e1e6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ code-barres EAN-13 s'imprime, il la colle sur son sac, la caisse la scanne. Réécriture complète d'une application Microsoft Access/VBA analysée en détail. La conception est **terminée et validée** — `docs/02-architecture.md` en est le dossier — et -**l'implémentation est livrée** : binaire tagué, installé et éprouvé sur un poste réel -(balance GRAM sur port série, SATO WS408 en RAW, redémarrage de recette passé), dépôt public -sous AGPL-3.0. +**l'application est EN PRODUCTION** : recette passée en magasin et **deux semaines +d'exploitation réelle** tenues au 10/08/2026 (balance GRAM sur port série, SATO WS408 en +RAW, redémarrage de recette passé), dépôt public sous AGPL-3.0. **Ce n'est donc pas un projet vierge : c'est du code livré qu'on modifie.** Trois conséquences, et elles priment sur tout réflexe de démarrage : @@ -93,6 +93,15 @@ triviale vers Windows, Linux et linux-arm64. SQLite via `modernc.org/sqlite`, ja Ces points ont été tranchés après analyse ; les rouvrir demande une décision explicite. +- **Les deux installeurs marchent en couple. Une modification validée sur l'un se reporte + sur l'autre, dans les deux sens** (décision du propriétaire du produit, 10/08/2026). + `deploy/windows/install.ps1` et `deploy/linux/install.sh` doivent offrir les **mêmes + fonctionnalités d'installation** ; idem pour les deux bootstraps. Un écart est permis + quand la plateforme l'impose — le compte `openscale` n'a ni mot de passe ni shell, le + mode pilote n'existe que pour laisser Access relançable — mais **il se déclare avec sa + raison**, jamais en silence. La parité ne se surveille pas à l'œil : elle est tenue par + `deploy/parity_test.go`, dont la table de correspondance est **le seul endroit du dépôt + où la parité est déclarée**. Ajouter une option d'un côté sans l'autre doit rougir. - **Ce qui est tenu sur l'étiquette est le contrat de caisse, pas le dessin** (ADR-051, 30/07/2026, remplace ADR-003). Un EAN-13 au plan d'ADR-028, zones de silence intactes, HRI imprimée. La mise en page, la hauteur des barres, l'interligne et la bande HRI diff --git a/INSTALLATION.md b/INSTALLATION.md index 4306621..9daaf4b 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -22,16 +22,21 @@ charge. **Si le poste n'a pas Internet**, tout se fait depuis une clé USB — v | Étape | Durée | Ce que vous faites | |---|---|---| -| 1 | 3 min | Coller **une commande** dans PowerShell et répondre à trois questions | +| 1 | 5 min | Coller **une commande** dans PowerShell et répondre à six questions | | 2 | 3 min | **Redémarrer et vérifier que le poste revient seul sur l'écran client** | -| 3 | 2 min | Poser le mot de passe d'administration avec le code de secours de la fiche | -| 4 | 1 min | Balance → *Détecter automatiquement* | -| 5 | 4 min | Imprimante → étiquette de test, superposer, régler le décalage | -| 6 | 2 min | Catalogue → source, numéro de poste, *Importer maintenant* | +| 3 | 1 min | Balance → *Détecter automatiquement* | +| 4 | 4 min | Imprimante → étiquette de test, superposer, régler le décalage | +| 5 | 2 min | Catalogue → source, *Importer maintenant* | **Total : 15 minutes** pour le premier poste. -L'étape la plus longue est l'étape 5, et elle le restera : superposer une étiquette +Il y avait autrefois une étape de plus, entre le redémarrage et la balance : aller +chercher le code de secours sur la fiche pour poser le mot de passe d'administration. +Elle n'a pas disparu, elle a **remonté dans l'étape 1** — l'installeur pose la question +lui-même, avec celles du numéro et du nom du poste. Le total ne change pas ; ce qui +change, c'est qu'on ne sort plus de l'installation avec un poste à moitié réglé. + +L'étape la plus longue est l'étape 4, et elle le restera : superposer une étiquette neuve sur une étiquette de l'ancienne application et régler le décalage au dot près demande trois ou quatre essais, et personne ne le fait en une minute la première fois. Les deux autres postes vont plus vite (voir « Les postes suivants », **environ @@ -45,7 +50,7 @@ d'impression de l'imprimante d'étiquettes si elle n'est pas déjà installée --- -## Étape 1 — Une commande (3 min) +## Étape 1 — Une commande (5 min) Ouvrez le menu Démarrer, tapez `powershell`, ouvrez **Windows PowerShell** — inutile de faire un clic droit, la commande demandera elle-même les droits qu'il lui faut. Collez @@ -68,7 +73,7 @@ curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/wi c'est dans celle-là que la suite se passe. Répondez *Oui*. La commande télécharge la dernière version, **vérifie son empreinte**, la décompresse, et -vous pose **trois questions** : +vous pose **trois questions sur cette machine** : 1. **Le mot de passe de la session Windows du poste.** Quatre caractères au minimum, tapé deux fois, et il ne s'affiche pas pendant que vous le tapez. Si vous validez sans rien @@ -85,6 +90,27 @@ vous pose **trois questions** : libre-service : c'est elle qui fait revenir l'écran client tout seul après une coupure de courant. +Puis l'installation démarre, et elle vous pose **trois questions sur le poste lui-même**, +juste après avoir mis sa configuration en place : + +4. **Le numéro de ce poste dans la coopérative.** C'est de lui que dérive le nom du + fichier de catalogue surveillé — `flv_2.csv` pour le poste 2. Il n'est pas demandé sur + un poste qui en a déjà un : réinstaller ne le change pas. +5. **Le nom de ce poste**, celui que les bénévoles lisent sur l'écran d'administration : + « Poste 2 - fruits ». +6. **Le mot de passe d'administration.** Quatre caractères au minimum, tapé deux fois, et + il ne s'affiche pas. C'est lui qui protège le droit de **changer** le poste : les prix, + l'étiquette, le catalogue. **Il n'est imprimé nulle part, pas même sur la fiche** — le + poste n'en garde qu'une empreinte. Prenez-en un que l'équipe connaît. Oublié, il se + repose avec le **code de secours de 8 caractères** que l'installeur tire et imprime sur + la fiche. + +> **Ces trois questions-là ne sont pas posées** quand l'installation est scriptée +> (`-Yes`) ou lancée depuis une console sans clavier : elle ne s'arrête pas pour autant, +> et la fiche d'installation dit alors ce qui reste à faire. Elles ne le sont pas non plus +> quand les réponses sont données en paramètres — `-StationNumber`, `-StationName`, +> `-AdminPassword`. + ### Allumer et éteindre un poste pilote **Un poste en mode pilote ne démarre pas à l'allumage de la machine, et c'est voulu** : @@ -134,6 +160,10 @@ par ligne. Elle : - désactive la veille, l'extinction de l'écran et **la suspension USB sélective** (c'est elle qui provoque la moitié des « la balance ne répond plus ») ; - interdit à Windows Update de redémarrer le PC entre 7 h et 21 h ; +- **pose le numéro, le nom et le mot de passe d'administration** que vous venez de donner, + et **déclare que ce poste neuf n'a pas encore de balance** — elle n'est ni branchée ni + détectée à ce stade, et le dire vaut mieux que laisser une faute derrière soi. Vous la + remettez en service à l'étape 3, en un bouton, sans rien avoir à retaper ; - écrit la **fiche d'installation**. À la fin, elle affiche trois choses à faire. **Faites-les dans l'ordre.** Elle vous donne @@ -159,9 +189,10 @@ donner un que l'équipe retient, et **le même sur les quatre postes**, se paye secondes à l'installation et se rembourse le premier samedi. Quatre caractères suffisent. Ce compte n'a **aucun droit** : il ouvre une session sur un -poste en libre-service, il ne permet rien d'autre. Le mot de passe d'administration, lui, -est une tout autre affaire — c'est celui de l'étape 3, il en faut huit, et il protège le -droit de *changer* le poste. +poste en libre-service, il ne permet rien d'autre. Ne le confondez pas avec le **mot de +passe d'administration**, qui est la sixième question et qui protège, lui, le droit de +*changer* le poste. Quatre caractères au minimum également, mais les deux ne gardent pas +la même chose et il n'y a aucune raison de prendre le même. **Réinstaller ne change pas ce mot de passe.** La fiche déjà rangée au classeur reste donc valable. L'installeur ne le renouvelle que dans deux cas : à la première installation, et @@ -189,8 +220,29 @@ cd "$env:USERPROFILE\Desktop\openscale--windows-amd64" .\install.ps1 -AccountPassword 'poire-balance-samedi' ``` -`install.ps1` ne pose aucune question : sans `-AccountPassword`, le mot de passe de la -session est tiré au sort et imprimé sur la fiche. Pour la période pilote, ajoutez `-Pilot`. +`install.ps1` pose **les trois questions sur le poste** — numéro, nom, mot de passe +d'administration — et rien d'autre : sans `-AccountPassword`, le mot de passe de la session +Windows est tiré au sort et imprimé sur la fiche. Pour la période pilote, ajoutez `-Pilot`. + +Pour une installation **entièrement scriptée**, donnez les réponses en paramètres, ou +ajoutez `-Yes` pour qu'aucune question ne soit posée du tout : + +```powershell +$env:OPENSCALE_ADMIN_PASSWORD = 'un-mot-de-passe' +.\install.ps1 -StationNumber 2 -StationName 'Poste 2 - fruits' -Yes +``` + +⚠ **Le mot de passe passe par une variable d'environnement, et pas par `-AdminPassword`.** +Ce n'est pas une coquetterie : un argument de ligne de commande est lisible par +**n'importe quel utilisateur de la machine** dans la liste des processus, et PowerShell le +garde dans son historique. L'installeur lit cette variable puis l'**efface aussitôt**, +avant de lancer le moindre programme. `-AdminPassword` existe toujours et fonctionne, mais +l'installeur affiche un avertissement quand vous l'employez. + +Sur un poste, le mieux reste de **ne rien donner du tout** : laissez l'installeur poser la +question — la saisie est masquée, elle est confirmée, et rien n'est écrit nulle part. +*(La commande équivalente sous Linux est en bas de ce document ; les deux installeurs +offrent les mêmes fonctionnalités, et un test du dépôt refuse qu'ils divergent.)* ## Étape 2 — La recette obligatoire : redémarrer (3 min) @@ -214,61 +266,54 @@ passe, et le poste est inutilisable alors que tout va bien à l'intérieur. > recommence, voir TROUBLESHOOTING.md, « Après un redémarrage, le poste reste sur > l'écran de connexion de Windows ». -## Étape 3 — Le mot de passe d'administration (2 min) - -**Prenez la fiche d'installation** : l'installeur y a imprimé un **code de secours de -8 caractères**. Il n'est écrit nulle part ailleurs — le poste n'en garde qu'une empreinte -et ne sait pas le relire. C'est lui qui pose le mot de passe la première fois. +## Étape 3 — La balance (1 min) **Le poste ne demande aucun mot de passe pour être regardé** : l'administration s'ouvre, toutes les pages se lisent. Il le demande **au moment où l'on change quelque chose**, et -pas avant. Il n'y a donc rien à taper tant que rien n'a été touché : c'est le premier -réglage qui fait apparaître la question. +c'est le mot de passe d'administration que vous avez posé à l'étape 1. 1. Touchez le bouton **Réglages** — l'engrenage, tout à droite de la barre du bas de l'écran client. L'administration s'ouvre sur le **Tableau de bord**. 2. Dans la colonne de gauche, ouvrez **Matériel**, puis touchez **Détecter - automatiquement** dans l'encadré *Balance*. C'est le premier geste de l'installation - qui change le poste — les boutons de ce genre portent une pastille **clé**. -3. Le panneau **« Ce poste n'a pas encore de mot de passe »** s'ouvre par-dessus la page. - Tapez les 8 caractères de la fiche dans **Code de secours** — les minuscules passent - aussi —, choisissez le mot de passe d'administration dans **Nouveau mot de passe**, - puis touchez **Poser ce mot de passe**. Prenez-en un que l'équipe connaît, pas celui - de votre boîte mail. Huit caractères au minimum. -4. **Le geste que vous veniez de faire repart tout seul** : la détection de la balance se - lance sans que vous ayez à retoucher le bouton. L'étape 4 est déjà commencée. -5. **Rangez la fiche dans le classeur du magasin.** Gardez le code : il n'y a pas de - « mot de passe oublié » sur un poste hors ligne. **Attention** : une fois un mot de - passe posé, ce code ne se saisit plus à l'écran — l'écran ne le redemande qu'à un - poste qui n'en a aucun. Reprendre la main plus tard passe par la ligne de commande - ci-dessous. + automatiquement** dans l'encadré *Balance*. C'est le premier geste qui change le poste + — les boutons de ce genre portent une pastille **clé** — et c'est donc là que le mot de + passe est demandé. +3. Le poste liste les ports série qu'il voit et essaie de lire des trames sur chacun. + Il vous propose celui qui répond. +4. Posez un objet sur la balance : le poids doit s'afficher et bouger. + +**Ce geste-là fait deux choses**, et la seconde n'est pas évidente : l'installation avait +déclaré que ce poste neuf **n'a pas de balance**, faute d'en avoir une de détectée. Le +port où la balance a répondu porte un bouton **« Utiliser cette balance »** : il déclare +d'un coup que ce poste a une balance, quel protocole elle parle et sur quel port — +les trois valeurs que la détection vient de reconnaître, qu'il n'y a donc pas à retaper. + +> **Si aucun port ne répond** : la balance est-elle allumée et branchée ? Voir +> TROUBLESHOOTING.md, « Le poids ne s'affiche pas ». > **Le poste affiche encore « Poste hors service » à ce stade, et c'est normal** : sa -> configuration est incomplète tant que les étapes 4 à 6 n'ont pas été faites. Il revient +> configuration est incomplète tant que les étapes 3 à 5 n'ont pas été faites. Il revient > en service tout seul, sans redémarrage, dès qu'il ne reste plus une seule faute. -> **Si la fiche a été perdue avant d'avoir servi**, ou **si le mot de passe est perdu -> plus tard**, il reste la ligne de commande, sur le poste, en administrateur : +> **Si le mot de passe d'administration est perdu** — celui qui l'a posé n'est pas là, +> personne ne l'a noté —, c'est le **code de secours de 8 caractères** de la fiche qui +> sert : le panneau « Ce poste n'a pas encore de mot de passe » le demande dans **Code de +> secours**, et les minuscules passent aussi. **Attention** : il ne se saisit à l'écran que +> sur un poste qui n'a **aucun** mot de passe — donc pas sur un poste dont l'installation a +> posé le sien. La reprise en main passe alors par la ligne de commande, sur le poste, en +> administrateur : > > ```powershell > Stop-Service OpenScale -> & "C:\Program Files\OpenScale\openscale.exe" config recovery-code # en tire un nouveau -> & "C:\Program Files\OpenScale\openscale.exe" config password # ou pose le mot de passe +> & "C:\Program Files\OpenScale\openscale.exe" config password # pose un nouveau mot de passe +> & "C:\Program Files\OpenScale\openscale.exe" config recovery-code # tire un nouveau code de secours > Start-Service OpenScale > ``` +> +> **Rangez la fiche dans le classeur du magasin** et gardez le code : il n'y a pas de +> « mot de passe oublié » sur un poste hors ligne. -## Étape 4 — La balance (1 min) - -1. Page **Matériel**, encadré **Balance** → **Détecter automatiquement**. C'est le geste - que l'étape 3 vient de lancer : s'il tourne encore, laissez-le finir. -2. Le poste liste les ports série qu'il voit et essaie de lire des trames sur chacun. - Il vous propose celui qui répond. -3. Posez un objet sur la balance : le poids doit s'afficher et bouger. - -> **Si aucun port ne répond** : la balance est-elle allumée et branchée ? Voir -> TROUBLESHOOTING.md, « Le poids ne s'affiche pas ». - -## Étape 5 — L'imprimante et le décalage d'étiquette (4 min) +## Étape 4 — L'imprimante et le décalage d'étiquette (4 min) C'est l'étape la plus longue, et celle qui décide de la qualité du résultat. @@ -287,13 +332,12 @@ C'est l'étape la plus longue, et celle qui décide de la qualité du résultat. > « pour l'utilisateur » et non pour la machine. Le service ne la voit alors pas. Voir > TROUBLESHOOTING.md, « L'imprimante n'apparaît pas dans la liste ». -## Étape 6 — Le catalogue (2 min) +## Étape 5 — Le catalogue (2 min) -1. Page **Catalogue**. Choisissez la **source** (partage WebDAV, ou dépôt local) et le - **numéro de ce poste**. -2. Il n'y a **pas de chemin à saisir** : le nom du fichier attendu découle du numéro du - poste — `flv_2.csv` pour le poste 2 — et le répertoire de dépôt est créé par le - service lui-même. +1. Page **Catalogue**. Choisissez la **source** : partage WebDAV, ou dépôt local. +2. Il n'y a **pas de chemin à saisir**, et **pas de numéro de poste non plus** : vous + l'avez donné à l'étape 1. Le nom du fichier attendu en découle — `flv_2.csv` pour le + poste 2 — et le répertoire de dépôt est créé par le service lui-même. 3. **Importer maintenant.** La grille se remplit en quelques secondes. 4. Si le fichier n'est pas encore arrivé, vous pouvez **glisser-déposer un CSV** directement sur la page. @@ -322,11 +366,11 @@ premier. |---|---| | Page **Poste** → **Exporter la configuration** | | | Décochez **« inclure le matériel »** | | -| Le fichier part sur une clé USB | Étapes 1 à 3 ci-dessus (installation, redémarrage, mot de passe) | +| Le fichier part sur une clé USB | Étapes 1 et 2 ci-dessus (installation — **avec son propre numéro et son propre nom** — puis redémarrage) | | | Page **Poste** → **Importer**, glissez le fichier | | | Le poste montre le **diff champ par champ**. Lisez-le, confirmez. | -| | Étapes 4 et 5 : balance et imprimante (le décalage est déjà bon) | -| | Étape 6 : **numéro de ce poste** | +| | Étapes 3 et 4 : balance et imprimante (le décalage est déjà bon) | +| | Étape 5 : le catalogue | > **Le décalage voyage vraiment** : il est dans la configuration livrée, avec le > noircissement, la vitesse et les réglages série de la balance. Vérifiez-le sur la @@ -336,12 +380,20 @@ premier. une **empreinte de 8 caractères**. Les quatre postes doivent afficher **exactement la même chaîne**. -> **Comparez-la seulement quand les six étapes sont finies.** Tant que le numéro de -> poste, la balance et l'imprimante ne sont pas réglés, la configuration est incomplète : -> le poste tourne en **configuration d'usine** et affiche l'empreinte de cette -> configuration-là, pas celle du fichier. Ce n'est pas une panne, et c'est aussi pour ça -> que l'étape 2 — le redémarrage — se fait avant : à ce moment-là, l'écran client affiche -> « Poste en configuration d'usine » et c'est normal. +> **Comparez-la seulement quand les cinq étapes sont finies.** Deux choses, et pas une +> seule, font qu'un poste en cours d'installation n'affiche pas la chaîne des autres : +> +> 1. Tant que la balance, l'imprimante et le catalogue ne sont pas réglés, la +> configuration est **incomplète** : le poste tourne en **configuration d'usine** et +> affiche l'empreinte de cette configuration-là, pas celle de son fichier. C'est aussi +> pour ça que l'étape 2 — le redémarrage — se fait avant : à ce moment-là, l'écran +> client affiche « Poste en configuration d'usine », et c'est normal. +> 2. **L'installation déclare qu'un poste neuf n'a pas de balance**, et cette déclaration +> compte dans l'empreinte. Un poste dont la balance n'est pas encore détectée affiche +> donc une **autre chaîne que ses voisins, même quand son fichier est par ailleurs +> identique au leur**. Il rejoint le parc à la seconde où l'étape 3 déclare la balance +> — ce n'est pas une panne, c'est une étape qui reste à faire, et la fiche +> d'installation le dit aussi. ``` Poste 1 : a3f81c04 @@ -491,13 +543,37 @@ curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/li Elle choisit l'archive de la bonne architecture — `amd64` pour un mini-PC, `arm64` pour un Raspberry Pi —, installe `unzip` s'il manque, **vérifie l'empreinte avant de décompresser**, -puis lance l'installation. Elle ne pose **aucune question** : le compte `openscale` n'a ni -mot de passe ni shell, il n'y a pas de mode pilote, et le kiosque est activé d'office. +puis lance l'installation. + +**Cette commande-là ne pose aucune question, et c'est technique** : sous `curl … | sh`, +l'entrée standard *est* le script, et une question y avalerait la suite du fichier au lieu +d'attendre une réponse. Donnez donc les valeurs sur la ligne : + +```bash +curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/linux/bootstrap.sh \ + | sudo OPENSCALE_ADMIN_PASSWORD='un-mot-de-passe' sh -s -- \ + --station-number 2 --station-name 'Poste 2 - fruits' +``` + +⚠ **Le mot de passe passe par une variable d'environnement et non par une option**, et la +différence est réelle : la ligne de commande d'un processus se lit dans `/proc`, **par tous +les comptes de la machine** ; son environnement ne se lit que par son propriétaire. +`--admin-password` existe aussi et fait la même chose, mais publie le secret — l'installeur +le dit en clair au moment où il le reçoit. Dans les deux cas, l'historique du shell garde ce +que vous avez tapé. + +**Depuis une clé USB, l'installeur pose ses questions lui-même** : là, son entrée standard +est un vrai terminal, et il demande ce qui manque — le numéro du poste, son nom, le mot de +passe d'administration, en saisie masquée et tapé deux fois. C'est le même partage que sous +Windows, et le même test des deux côtés : *y a-t-il quelqu'un devant l'installeur ?* L'installation elle-même pose `cage`, `chromium` et `seatd`, crée le compte `openscale` dans les groupes `dialout` (le port série) et `lp` (l'imprimante), installe les deux unités systemd et les règles udev qui donnent au port série un **nom stable** — `/dev/ttyUSB0` -devient `ttyUSB1` après un rebranchement, et c'est un piège qui coûte une heure. +devient `ttyUSB1` après un rebranchement, et c'est un piège qui coûte une heure. Elle +**pose le numéro, le nom et le mot de passe d'administration**, **tire le code de secours +et l'imprime sur la fiche**, et **déclare que ce poste neuf n'a pas encore de balance** — +exactement comme la version Windows. À la fin, elle affiche le dossier où les scripts du poste sont rangés, `/var/lib/openscale/installer//` : c'est là que vivent la mise à jour et la @@ -515,10 +591,22 @@ le poste répond, et **remet la version précédente** si ce n'est pas le cas. U clé USB, décompressez-la sur le poste et lancez le script directement : ```bash -sudo ./install.sh # poste neuf +sudo ./install.sh # poste neuf — il pose ses trois questions sudo ./update.sh # poste déjà installé ``` +**Toutes les réponses en paramètres**, pour une installation scriptée qui ne s'arrête +jamais sur une question : + +```bash +sudo OPENSCALE_ADMIN_PASSWORD='un-mot-de-passe' ./install.sh --yes \ + --station-number 2 --station-name 'Poste 2 - fruits' +``` + +`sudo ./install.sh --help` les liste toutes. `--install-dir` et `--data-dir` déplacent le +binaire et les données ; les unités systemd sont réécrites en conséquence, mais +`uninstall.sh` ne connaît que les emplacements par défaut. + **Aligner un poste sur les autres**, ou revenir en arrière : ```bash @@ -530,8 +618,33 @@ unités et les règles udev sans passer par la mise à jour : ajoutez `--force-i -Ensuite, les étapes 3 à 7 sont les mêmes : redémarrer, vérifier que l'écran revient -seul, puis régler depuis l'écran. +Ensuite, les étapes 2 à 5 sont les mêmes : redémarrer, vérifier que l'écran revient seul, +puis régler depuis l'écran. + +**Trois différences, et elles se lisent toutes sur la fiche d'installation.** + +1. **Le compte `openscale` n'a ni mot de passe ni shell**, et la fiche n'en porte donc + aucun. Il n'y a pas d'ouverture de session à configurer : l'écran client est une unité + systemd, qui démarre à l'allumage de la machine sans que personne ouvre de session. Et + il n'y a pas de **mode pilote** — il existe pour laisser l'ancienne application Access + relançable, et Access ne tourne pas sous Linux. +2. **Le mot de passe d'administration n'est posé que si quelqu'un a répondu** — à la + question, ou d'avance en paramètre. Une installation par `curl … | sh` sans paramètre + n'en pose aucun, et la fiche le dit : le premier réglage ouvrira alors le panneau qui + réclame le **code de secours**. +3. **Ce code de secours est tiré par l'installation et imprimé sur la fiche**, comme sous + Windows. Une réinstallation ne le renouvelle pas : la fiche déjà rangée au classeur + reste vraie. Si la fiche est perdue, on en tire un nouveau à la main : + +```bash +sudo systemctl stop openscale +sudo openscale config recovery-code /etc/openscale/config.json +sudo systemctl start openscale +``` + +Comme sous Windows, **la balance d'un poste neuf est déclarée absente** jusqu'à l'étape 3 : +son empreinte de configuration diffère donc de celle de ses voisins tant que *Utiliser +cette balance* n'a pas été touché, et ce n'est pas une panne. ```bash systemctl status openscale # l'état du service diff --git a/README.md b/README.md index d1fd37d..781a383 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ OpenScale remplace une application Microsoft Access de 2015 encore en service, d reprend les fonctionnalités et les contrats externes — le format du code-barres lu par la caisse, la géométrie de l'étiquette — mais aucune ligne de code. -**Développement terminé, éprouvé sur banc réel — SATO WS408 et GRAM XFOC —, pas encore -en service.** Il reste la recette sur un poste pilote. Ce qui est prouvé, ce qui ne -l'est pas et ce qui reste ouvert : [SUIVI.md](SUIVI.md). +**En service en magasin.** La recette a été passée sur site, et le poste a tenu **deux +semaines d'exploitation réelle** — clients, bénévoles, étiquettes scannées en caisse — +avant que ce statut ne soit écrit ici. Ce n'est plus un banc : c'est un poste qui pèse. + +Le matériel éprouvé est une **SATO WS408** en RAW et une **GRAM XFOC** sur port série. +Ce qui est prouvé, ce qui ne l'est pas et ce qui reste ouvert : [SUIVI.md](SUIVI.md) — +le fichier se tient à jour au fil de l'eau, et il nomme aussi ce qui a cassé. ## Installer un poste @@ -33,10 +37,13 @@ curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/wi La commande prend la dernière version publiée, **vérifie son empreinte avant de la -décompresser**, pose trois questions — mot de passe de la session du poste, production ou -pilote, ouverture de session automatique — puis installe tout : compte Windows dédié, -service, tâche du kiosque, réglages d'alimentation, fiche d'installation à ranger dans le -classeur du magasin. Comptez **15 minutes** jusqu'à la première étiquette, matériel branché. +décompresser**, puis pose **six questions** : trois sur la machine — mot de passe de la +session du poste, production ou pilote, ouverture de session automatique — et trois sur le +poste lui-même — **numéro, nom et mot de passe d'administration**. Elle installe ensuite +tout : compte Windows dédié, service, tâche du kiosque, réglages d'alimentation, fiche +d'installation à ranger dans le classeur du magasin. **Le poste sort utilisable** : il ne +reste qu'à lui désigner sa balance, son imprimante et son catalogue. Comptez +**15 minutes** jusqu'à la première étiquette, matériel branché. Sur une **Debian 12 minimale**, une commande également — et **la même met à jour** un poste déjà installé : @@ -45,9 +52,15 @@ déjà installé : curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/linux/bootstrap.sh | sudo sh ``` -Elle choisit l'archive de la bonne architecture — `amd64` ou `arm64` —, vérifie son -empreinte avant de décompresser, et ne pose aucune question : sous Linux, il n'y en a -aucune à poser. +Elle choisit l'archive de la bonne architecture — `amd64` ou `arm64` — et vérifie son +empreinte avant de décompresser. **Les deux installeurs offrent les mêmes fonctionnalités** +et un test du dépôt refuse qu'ils divergent : `install.sh` demande lui aussi le numéro, le +nom et le mot de passe d'administration, et déclare la balance absente sur un poste neuf. +Trois questions de moins qu'à l'installation Windows, et c'est la plateforme qui le veut : +le compte `openscale` n'a ni mot de passe ni interpréteur, l'écran client démarre à +l'allumage sans session à ouvrir, et il n'y a pas d'application Access à laisser +relançable. Le one-liner ci-dessus, lui, **ne pose jamais de question** — sous +`curl … | sh`, l'entrée standard *est* le script — et reçoit les réponses en options. Le parcours complet du bénévole — redémarrage de recette, balance, imprimante, catalogue, et **l'installation par clé USB d'un poste sans Internet** — est dans diff --git a/SUIVI.md b/SUIVI.md index ac19851..9aa1e8d 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -949,13 +949,20 @@ corrigeait l'adresse, le poste répondait « configuration enregistrée », et p n'était surveillé jusqu'au prochain redémarrage. Les trois tests ajoutés ont été vus échouer avant d'être verts. -**Ce que ce poste neuf reste à saisir : quatre fautes, quatre lignes (mesuré à nouveau le -30/07/2026).** Mesuré sur le fichier livré — `config export testdata/config-lacagette.json`, -puis `config validate` sur l'export : **4 fautes sur 4 champs distincts**. -`station.number` (0 hors bornes `[1, 99]`, et c'est de lui que dérive le nom du fichier -surveillé), `network.listen` (vide), `scale.options.port`, `catalog.options.url`. La -spécification du lot annonçait **neuf** ; le chiffre mesuré est **quatre**, et c'est le -mesuré qui fait foi. +**Ce que ce poste neuf reste à saisir : UNE faute (remesuré le 10/08/2026).** Même +protocole qu'avant, `config export testdata/config-lacagette.json` puis `config validate` +sur l'export, et le chiffre a bougé deux fois dans le même lot : + +| Étape | Fautes | Lesquelles | +|---|---|---| +| 30/07/2026 | **4** | `station.number`, `network.listen`, `scale.options.port`, `catalog.options.url` | +| Repli d'une adresse vide (ADR-060) | **3** | `network.listen` sort de la liste : le fichier livré ne se refuse plus lui-même | +| Questions de l'installeur (ADR-060) | **1** | `catalog.options.url` seule — la source du catalogue est une étape de mise en service à part entière, et son champ est éditable à l'écran | + +La spécification du lot annonçait **neuf** ; le mesuré fait foi, et il fait foi **à chaque +fois**. ⚠️ La ligne « quatre fautes, quatre lignes » avait tenu onze jours **en décrivant +un poste que personne ne pouvait réparer** : l'une des quatre n'avait aucun champ à +l'écran, ce que le compte ne dit pas. **Le doublon a disparu, et c'est ADR-044 qui l'a emporté.** `scale.options.port` était énuméré **deux fois**, avec deux phrases différentes : « un poste qui déclare une balance @@ -1396,7 +1403,11 @@ Ce qui a été livré ferme le trou **sans l'assistant** : l'identité de qui a le droit de le réparer — et `--listen` survit au repli. **Ce qui reste ouvert.** L'**assistant en 5 étapes** de §14.4 n'est toujours pas écrit : -le chemin existe et il est complet, mais il n'est pas *guidé*. Des trois pilotes que §8.1 +le chemin existe et il est complet, mais il n'est pas *guidé*. *(« Complet » était **faux**, +et un poste de production l'a démontré le 10/08/2026 : `network.listen` sortait vide du +fichier livré, aucun écran ne l'éditait, et `PUT /admin/api/config` validant le document +entier, cette seule faute verrouillait **toute** l'administration. Voir l'entrée du +10/08/2026 et ADR-060.)* Des trois pilotes que §8.1 nomme, `sbpl` reste le seul que `printerRegistry()` n'enregistre pas — `internal/printing/sbpl` est l'**encodeur** partagé de la trame, pas un `ports.Printer` — et aucun profil livré ne le nomme, ce qui est la différence avec `preview`, livré depuis le 29/07/2026. @@ -1429,7 +1440,7 @@ comptage A/B au scanner de caisse est ce qui tranchera le tracé géométrique d | **L6** | Poste vivant et écran client — Hub, SSE, front | 4,5 sem. | ✅ **26/07/2026** | | **L7** | Catalogue — sources, import CSV, images | 2,5 sem. | ✅ **26/07/2026** | | **L8** | Admin et exploitation — écrans, diagnostic, installeurs | 4 sem. | ✅ **26/07/2026** | -| **L9** | Recette et mise en service — poste pilote 2 semaines | 3 sem. | ⬜ | +| **L9** | Recette et mise en service — poste pilote 2 semaines | 3 sem. | ✅ **10/08/2026** — recette passée en magasin, **deux semaines d'exploitation réelle** tenues. Le poste pèse pour de vrais clients et ses étiquettes passent en caisse. Les quatre défauts d'installation du 10/08/2026 (voir Journal) ont été trouvés **par cette mise en service**, et non au banc | | **hors lot** | Mise à jour depuis l'écran (ADR-040) — paquet `internal/update`, page « Mise à jour », contrat `update.ps1` | 1 j·h | ✅ **29/07/2026** | **Ce qui reste, et il n'y a que ça.** L0 approvisionne le banc (SATO WS408, GRAM XFOC, @@ -1607,6 +1618,11 @@ SIGKILL que §13.4 raconte. une session **sans aucun droit** sur une machine en libre-service dont l'accès physique vaut déjà l'accès administrateur. La décision est celle du 31/07/2026 ; le rendre difficile ne protégeait rien et rendait le poste inaccessible le samedi. + ⚠️ **La comparaison de ce paragraphe est périmée depuis le 10/08/2026** : le plancher + d'`openscale config password` vaut **4** lui aussi (ADR-060), donc il n'y a plus + d'écart. L'argument — les deux protègent des choses différentes — tient toujours et a + été refondé sur ce que ce compte protège plutôt que sur un écart chiffré. L'entrée + n'est pas réécrite : elle décrit correctement l'état du 31/07/2026. **Ce qui reste ouvert sur ce lot :** `flv_demo.csv` (§17.2) n'existe pas ; les identifiants USB de l'imprimante ne sont pas relevés, donc sa règle udev est livrée @@ -1681,7 +1697,9 @@ de référence produit, pas une correction cosmétique. ## Décisions structurantes -54 ADR dans `docs/02-architecture.md` §20 — contigus de 001 à 054, sans trou. Les plus +60 ADR dans `docs/02-architecture.md` §20 — contigus de 001 à 060, sans trou (compté le +10/08/2026 par `grep -cE "^#{2,5} ADR-[0-9]{3}"`, et c'est la quatrième fois que ce nombre +est repris après avoir menti : il ne se recopie pas, il se mesure). Les plus engageantes : | ADR | Décision | @@ -1702,6 +1720,7 @@ engageantes : | **045** | **La tête déclare sa géométrie encrée** ; le core refuse l'attelage gabarit/tête incohérent. **N'amende ni A1 ni ADR-003** | | **048** | **Tout driver enregistré passe un banc de conformité** — seul juge en l'absence de matériel | | **052** | **L'origine des produits est enfichable sur deux axes** — acquisition (`ports.CatalogSource`) et format (`catalog.RowReader`) ; ce qu'un catalogue décide vit dans `catalog.Assemble` et ne se réimplémente pas. Le CSV n'est plus qu'un mode. **Supprime le contrôle 47**, rend déclaratives les règles croisées de `Config.Validate`, et étend la coupe 2 à `catalog.Source` | +| **060** | **L'installation demande ce qu'elle seule peut savoir** — mot de passe d'administration, numéro et nom du poste —, et la balance sort **déclarée absente**. Un poste neuf passe de **4 fautes à 1**. Le plancher du mot de passe d'administration descend à **4**, avec une autorité unique. **Amende** §15.2, §15.5 et le parcours d'ADR-018 ; **n'amende pas** ADR-012 | | **051** | **A1 n'était pas une contrainte, c'était l'état d'un logiciel Access.** Ce qui est tenu est le contrat de caisse, pas le dessin. Barres 10 875 → **11 375 µm**, gabarit B retiré, règle 9 réécrite contre la plage GS1. **Remplace ADR-003**, reprend les chiffres d'**ADR-029** | --- @@ -1710,6 +1729,8 @@ engageantes : | Date | Événement | |---|---| +| 10/08/2026 | **Les deux installeurs marchent désormais en couple, et un banc l'exige.** Décision du propriétaire du produit : `install.ps1` et `install.sh` doivent offrir les **mêmes fonctionnalités d'installation**, et une modification validée sur l'un se reporte sur l'autre, **dans les deux sens**. L'écart de départ était large — `install.sh` n'avait **aucun paramètre**, ne posait ni mot de passe ni identité, ne tirait aucun code de secours, et sa fiche disait encore « à recopier ici à la main ». Il porte maintenant les six mêmes options, les mêmes questions, le même plancher, la même fiche et le même message de fin. **La règle n'est pas gardée par la bonne volonté** : `deploy/parity_test.go` lit le bloc `param()` de l'un et le `case` d'analyse d'arguments de l'autre, et compare les deux ensembles dans les deux sens. La correspondance PascalCase → minuscules-tirets est **calculée** et non listée ; une paire qui y échappe — `-DataRoot` ↔ `--data-dir`, parce que §11.1 sépare `/etc` et `/var/lib` et qu'aucun répertoire Linux n'est une « racine » qui contiendrait les deux — doit porter sa raison. **Une exception est permise, jamais muette** : elle doit être motivée dans le banc **et** dans le script qui n'a pas l'option, et une raison de moins de 60 caractères fait rougir — « pas d'objet » n'est pas un arbitrage. Trois exceptions Windows, vérifiées une par une dans le dépôt et pas supposées : `-AccountPassword` (le compte `openscale` est créé sans mot de passe et avec `/usr/sbin/nologin`), `-SkipAutoLogon` (`openscale-kiosk.service` est `WantedBy=multi-user.target` avec `PAMName=login` : rien à ouvrir, donc rien à sauter — et l'unique usage réel se règle par `systemctl disable`, ce que l'en-tête nomme), `-Pilot` (il existe pour laisser Access relançable, et Access ne tourne pas sur Debian). **Deux pièges mesurés, pas devinés.** (1) Sous `dash`, la longueur de chaîne du shell compte des **OCTETS** quelle que soit la locale : un plancher de quatre appliqué ainsi aurait accepté « éàç » — 3 caractères, 6 octets — que le binaire refuse. La cinquième porte aurait rétabli exactement la divergence que les quatre autres venaient d'éliminer. Elle compte donc des points de code en retirant les octets de continuation UTF-8, `LC_ALL=C` pour ne pas dépendre du `LANG` du poste ; vérifié sur « éàç » et sur quatre emoji. (2) **Le secret ne descend jamais par argv** : `/proc//cmdline` est lisible par **tous** les comptes de la machine, `/proc//environ` seulement par le propriétaire. `bootstrap.sh` fait donc descendre le mot de passe par l'environnement et jamais en argument, et `install.sh` efface la variable **avant le premier processus fils**, sans quoi `apt-get`, `useradd` et `systemctl` en auraient hérité. **La parité a rendu deux écarts que le banc a refusé de corriger lui-même**, et qui l'ont été ensuite : `install.ps1` ne lisait pas `OPENSCALE_ADMIN_PASSWORD` — il n'existait donc **aucune** façon de poser le mot de passe sous Windows sans terminal et sans ligne de commande — et il n'avertissait pas à l'exécution quand `-AdminPassword` arrivait par argv. Les deux sont portés. **Reste un écart NON traité, et il est nommé plutôt que découvert** : les deux *bootstraps* ne sont pas à parité sur leurs options propres — `bootstrap.sh` a `--force` et `--force-install` parce qu'il distingue installation et mise à jour en déléguant à `update.sh`, là où `bootstrap.ps1` lance toujours `install.ps1`. C'est une divergence de **structure** et non un oubli de ce lot ; la trancher demande une quatrième table dans le banc. **Jugé NON BLOQUANT par le propriétaire du produit le 10/08/2026** : la règle de report porte sur les *fonctionnalités d'installation*, que les deux installeurs offrent désormais à l'identique, et ces trois options-là gouvernent le chemin de téléchargement, pas ce qu'un poste devient. Le banc ne les couvre donc pas, et il ne le prétend pas — il déclare son périmètre : installeur → son propre bootstrap. À rouvrir le jour où un geste d'exploitation manque d'un côté. **Vérification** : `sh -n` et le **vrai `dash` 0.5.12 d'une Debian/Ubuntu** sous WSL sur les quatre scripts POSIX ; les fonctions extraites du script livré exécutées pour de bon — analyse d'options, saisie masquée sur un pty, comptage de points de code, relais du secret —, et **chacun des trois bancs éprouvé en le cassant**, huit mutations dont les deux sens de la parité. **Ce qui n'est PAS fait** : `install.sh` n'a **jamais été lancé en entier** — il installe des paquets, crée un compte et pose des unités systemd —, donc rien de ce qui touche `apt-get`, `useradd`, `udev`, `polkit`, `systemctl` ni `/healthz` n'a été exercé ; `uninstall.sh` ne connaît toujours que les emplacements par défaut, si bien qu'un poste installé avec `--install-dir` se désinstalle à la main — `install.sh` le **dit** au journal au moment où il déplace quelque chose, plutôt que de laisser la surprise ; et aucun `busybox sh` n'était disponible pour une troisième vérification syntaxique | +| 10/08/2026 | **Une installation de poste de production a buté sur quatre choses le même jour, et deux d'entre elles rendaient le poste inutilisable.** Aucune n'était visible sans installer pour de bon. **(1) Le bouton « Redémarrer le poste » laissait le poste éteint.** Il reste sur « En cours… », l'écran client est noir, et cinq minutes plus tard l'écran annonce que le poste n'a pas répondu. La cause n'est pas dans le code du bouton : `setRecovery` (`internal/platform/service_windows.go`) n'appelait que `SetRecoveryActions`, **jamais** `SetRecoveryActionsOnNonCrashFailures`. Windows met ce drapeau à **faux** par défaut, et faux signifie « n'appliquer les reprises que si le service s'arrête **sans** signaler `SERVICE_STOPPED` ». Or l'arrêt ordonné de §13.4 se termine proprement et le signale : le SCM voyait un arrêt normal et n'appliquait aucune des trois reprises de §15.2. **La prémisse fausse était écrite en quatre endroits** — `failure.go`, `supervised_windows.go`, `internal/web/maintenance.go`, et un banc **VERT** de `cmd/openscale/maintenance_test.go` intitulé « THE CODE IS THE MECHANISM » — plus ADR-055 lui-même, qui affirmait « le SCM applique alors les reprises de §15.2 ». Les cinq sont corrigés ; l'ADR porte l'amendement daté plutôt qu'une réécriture. Le banc neuf n'assertionne pas seulement le plan envoyé au SCM : `setRecovery` prend désormais une interface de deux méthodes, **parce qu'un banc qui ne lirait que le plan resterait vert si quelqu'un retirait le second appel — c'est exactement le défaut qui a été livré**. Vérifié en le cassant : l'appel retiré, deux bancs rougissent. **Décision de périmètre du propriétaire du produit** : les postes déjà installés ne sont **pas** réparés par ce lot — leur configuration a été rattrapée à la main, et seules les prochaines installations doivent fonctionner. `update.ps1` n'est donc pas touché, et `TROUBLESHOOTING.md` dit **sans détour** qu'une mise à jour ne répare pas ce défaut, le réglage fautif vivant dans Windows et non dans le binaire. **(2) `network.listen` vide verrouillait l'administration ENTIÈRE.** Le fichier livré porte littéralement `"listen": ""` — `Export` remet tout le bloc réseau à zéro (`redact.go`), et la cible release exige un export —, donc le poste **refusait sa propre configuration de livraison**. La gravité n'est pas « une faute de plus » : `PUT /admin/api/config` valide le document **entier** et le brouillon du front envoie tout le document quelle que soit la page, si bien qu'une seule faute portant sur un champ qu'**aucun écran n'éditait** empêchait le moindre enregistrement, sur **toutes** les pages. L'opérateur a corrigé `config.json` à la main. `Config.UnmarshalJSON` ramène désormais une adresse vide à celle du profil neutre, comme il le fait déjà pour `update.repository` et `ui.min_products_for_chip` — même mécanisme, même raison. **L'ordre de livraison était contraint et il a été tenu** : la garde d'import de `configTransfer` **d'abord**, le rattrapage du décodeur **ensuite**. Dans l'autre sens, un défaut bloquant serait devenu un défaut **silencieux** — le 422 était le seul garde-fou empêchant un export importé depuis l'écran de déplacer l'écouteur d'un poste de `0.0.0.0:8085` vers la boucle locale, ce qui aurait fermé l'administration au réseau. La page **Poste** gagne enfin les deux champs `network.listen` et `admin_on_lan`, la mécanique de déplacement à chaud (`Rebind`, compte à rebours de 60 s, retour arrière) étant livrée depuis toujours : **seul le champ manquait**, et §14.4 ne le listait pas — c'est de là que venait l'oubli. Le banc nommé `TestCloningAStationShowsTheSAMEEightCharacters` tombait ; il est **réécrit et non supprimé**, son assertion remontée sur le JSON brut de l'export, qui est le niveau où la garantie vit. **(3) Le plancher du mot de passe d'administration descend de 8 à 4**, sur demande du propriétaire du produit. Le chiffre existait en **six exemplaires** — deux en Go, un en TypeScript, un en Svelte, deux en PowerShell — que rien ne reliait ; il est ramené à **une autorité**, `web.MinPasswordLength`, les copies qu'un autre langage impose étant liées à elle par un banc qui **lit le fichier source**. Un banc en Go, qui **analyse l'arbre syntaxique** et non le texte, refuse désormais qu'une longueur de mot de passe soit comparée à un nombre écrit en clair. **Un vrai défaut, indépendant de la valeur, a été trouvé au passage** : la route HTTP comptait des **octets** là où la ligne de commande comptait des **runes** — « é » vaut un caractère au clavier et deux octets sur le fil, donc la route acceptait un secret que le terminal refusait. Les quatre portes comptent maintenant des points de code, PowerShell compris. **Et un piège d'encodage mesuré** : sur une console en `chcp 65001`, un tube vers un processus natif préfixe la marque d'ordre des octets à l'entrée standard, et un mot de passe haché avec elle aurait **muré le poste** ; `readSecretLine` la retire. **(4) L'installation pose maintenant ce qu'elle seule peut savoir** (ADR-060) — mot de passe d'administration, numéro et nom du poste —, et déclare la balance **absente**, faute d'en avoir une de branchée. Nouvelle action `openscale config station`. **Aucune option de `config` ne prend de secret** : le mot de passe passe par l'entrée standard, un argument se lisant dans la liste des processus. **Un piège que la consigne n'avait pas prévu, trouvé par la mesure** : `[Environment]::UserInteractive` **ne suffit pas** à décider qu'on peut poser une question — `Read-Host -AsSecureString` lit la **console** et non le tube, et une installation scriptée avec `UserInteractive` à vrai s'est arrêtée indéfiniment sur la première invite, tuée à 120 s. **Empreinte : `6c06605a` → `79ba8bfc` tant que la balance est déclarée absente, puis `6c06605a` de nouveau** dès qu'elle est redéclarée — mesuré dans les deux sens, et **dit** sur la fiche d'installation, dans le message de fin de l'installeur et dans `INSTALLATION.md`, parce que §15.5 fait comparer les quatre empreintes à l'œil et qu'un écart non annoncé se prend pour une panne. **Ce que la relecture adverse a rattrapé, et qui aurait annulé le bénéfice** : `--no-scale` vide `scale.type`, or **aucun geste de l'écran ne le remettait** — la détection automatique ne faisait que *rendre un rapport*, le protocole se retapant de mémoire dans un volet replié. Les cinq textes qui promettaient « la détection remet la balance en service » étaient donc faux. La détection **servait pourtant déjà le driver reconnu** (`DetectionDTO.driver`), et `cmd/openscale/detect.go` l'écrivait dans son propre commentaire depuis toujours : « *what goes into the form is the driver that recognised what came out of the cable* ». L'écran le jetait. Un bouton **« Utiliser cette balance »** écrit désormais présence, protocole et port d'un seul geste. Au passage, le service simulé du banc de la page Matériel était **malhonnête** — il répondait un driver pour *tous* les ports, y compris muets —, si bien que le banc « un port non reconnu n'offre rien » aurait été vert pour la mauvaise raison. **Deux défauts de l'installeur trouvés par la même relecture** : `if ($StationNumber)` est **faux pour 0** en PowerShell, donc un « 0 » tapé à l'invite était avalé en silence et le journal annonçait quand même « identité posée » ; et l'avertissement « identité NON posée » était **inatteignable** dans le seul cas pour lequel il avait été écrit. Les deux sont reproduits au banc **avant** correction, puis exécutés pour de bon. **Vérification** : `go build ./...`, `go test ./...` en **deux passes** — avec cgo et `-race`, puis sans cgo —, **3 141 tests verts, 12 écartés, 0 échec** sur 35 paquets ; `go vet` et `gofmt` sans remarque ; **1 021 tests web** sur 38 fichiers, `svelte-check` à **0 erreur** ; les six scripts `.ps1` s'analysent sous PowerShell 5.1 **et** 7. Front embarqué reconstruit **une seule fois**, anciens morceaux supprimés du répertoire commité. **Ce qui n'est PAS fait, et n'est pas à croire fait** : **aucun poste réel n'a été installé** avec ce binaire — la chaîne complète `install.ps1` sur une vraie machine, la recette de redémarrage et le comportement observé du bouton restent à éprouver, et la mesure `sc qfailureflag` sur le poste de production n'a **pas** été relevée ; `doctor` **ne contrôle pas** le drapeau de reprise, donc ce défaut resterait invisible sans matériel — c'est le contrôle qui l'aurait attrapé et il n'est pas écrit ; **Linux reste asymétrique**, `deploy/linux/install.sh` ne pose aucune question et ne tire aucun code de secours, ce qui est **dit** dans `INSTALLATION.md` plutôt que laissé croire ; le limiteur de tentatives d'`admin` **ne verrouille jamais** à rythme régulier — son compteur repart à zéro à chaque fenêtre d'une minute, soit ~236 essais/heure —, ce qui est **assumé par écrit** en §14.4 et laissé à un lot séparé pour qu'on puisse mesurer lequel du plancher ou du limiteur a servi. **Un incident d'outillage à retenir** : `TROUBLESHOOTING.md` a été **vidé à zéro octet** par un agent en fin de passe, et c'est la suite de tests qui l'a dit (`TestTheDocumentationIsWrittenForAVolunteer`, cinq symptômes manquants d'un coup) — restauré depuis `HEAD` et la section réécrite. Un banc qui lit la documentation vaut un banc qui lit du code | | 07/08/2026 | **Deux tests du kiosque tombaient au hasard depuis une semaine, et aucun des deux ne parlait du produit : les deux bancs mesuraient l'ordonnanceur.** Relevé sur les 120 derniers runs de la CI plutôt que deviné — 15 échecs, dont **cinq sur trois tests d'`internal/kiosk`**, entre le 30/07 et le 07/08/2026. **Défaut A, trois chutes** (`TestAStationThatDoesNotAnswerYetShowsTheWaitingPage` ×2, `TestTheGraceIsBoundedAndEndsOnTheStartingPage` ×1), toutes sur « poste muet : ouvert sur "http://127.0.0.1:8085" » : `newBench` posait `alive = true` **et démarrait le superviseur** avant de rendre la main, si bien qu'un test qui écrivait ensuite `alive.Store(false)` courait contre la **première scrutation**, laquelle est la première instruction du superviseur. Sur un runner assez chargé pour l'ordonnancer d'abord, il ouvrait l'écran client — et le message accusait le superviseur d'avoir ouvert sur un poste muet alors que le poste répondait encore à l'instant où il avait regardé. **Reproduit à l'identique** en glissant `time.Sleep(20 ms)` entre les deux lignes. L'état initial devient donc un **constructeur** et non un champ écrit après coup : `newBenchOnAStationThatDoesNotAnswerYet`, qui pose l'état **avant** `Run`, où aucune temporisation ne peut plus rien casser. **Défaut B, deux chutes** (`TestTheWordingChangesOnceTheStationHasAnswered`), sur « page d'attente revenue après 3 s : le délai de grâce a été resservi » — **un message faux, et le code le prouve** : `awaitStation` ne tourne qu'une fois, avant le premier navigateur, donc la grâce ne PEUT pas être resservie. La vraie faute est que `nextLaunch` avançait l'horloge fausse de 50 ms **à chaque tour de boucle**, y compris les tours passés à attendre que l'ordonnanceur exécute le superviseur : l'horloge fausse comptait la charge de la machine, et toutes les assertions de durée de ce fichier se lisent sur elle. **Mesuré en affamant la goroutine** (`GOMAXPROCS=1`, passe `-race`) : le test tombe **six fois sur six** en annonçant « page d'attente revenue après **1h50m** » — 1h50 de temps faux écoulés en 0,11 s de temps réel. L'horloge ne bouge désormais **que si quelqu'un l'attend**, ce que `Clock.Pending()` sait déjà dire ; et les trois tests de la grâce attendent explicitement que le superviseur soit garé sur l'horloge avant de la pousser, sans quoi la durée est distribuée alors que rien n'est inscrit pour la recevoir. **Vérifié dans la condition qui faisait tomber** : 50 exécutions du paquet à `GOMAXPROCS=1` sous `-race`, toutes vertes, là où six sur six tombaient. **Ce qui n'est PAS corrigé, et n'est pas à croire corrigé** : `TestACorruptedCatalogIsQuarantinedAgainstTheRealChain` (`internal/station`) est tombé une fois le 31/07/2026 sur « 0 ligne(s) ERR-CAT-03 en niveau erreur, attendu 1 » et **n'a pas été reproduit** — 120 exécutions affamées, toutes vertes. L'explication d'une ligne technique encore en vol est faible : **deux tours de boucle complets** séparent le troisième refus de l'assertion. Il reste ouvert, et il n'a pas été touché : corriger sur une intuition aurait rendu le prochain signalement illisible. Les trois autres échecs relevés (`TestANominalStationIsGreenExceptWhatItDoesNotHave`, `TestTheLastLineIsWhatAVolunteerReadsOutOverTheTelephone`, `TestATestBinaryIsNotSupervised`) sont **groupés sur un seul run d'une branche de développement**, ce qui ressemble à un travail en cours et non à une instabilité | | 07/08/2026 | **Le one-liner d'installation mourait sur sa première commande, chez qui ne l'avait pas ouvert en administrateur — c'est-à-dire chez presque tout le monde.** Signalé sur le forum des supermarchés coopératifs, capture à l'appui : bannière affichée, puis `iex : Impossible de convertir la valeur «System.String» en type «System.Management.Automation.SwitchParameter»`, `MetadataError`, `RuntimeException,Microsoft.PowerShell.Commands.InvokeExpressionCommand`. **La position accusait `iex`, au caractère 96 de la ligne tapée** — soit exactement le premier caractère de `iex` dans la commande publiée, vérifié en comptant —, donc ni le fichier, ni la ligne, ni la variable. **Troisième défaut de la famille du 01/08/2026, dans une troisième forme.** `bootstrap.ps1` déclare `[switch]$Relaunched` et écrivait, quarante lignes plus bas, `$relaunched = Join-Path $env:TEMP 'openscale-bootstrap.ps1'` : les noms de variables PowerShell sont **insensibles à la casse**, ces deux-là sont la même, et celle d'un paramètre est **typée** — un chemin rangé dans une `[switch]` lève. `$ErrorActionPreference = 'Stop'`, posé trois lignes après le `param`, transformait l'erreur en terminating et la faisait ressortir attribuée à l'`Invoke-Expression` appelant, ce qui explique le message illisible. **Pourquoi personne ne l'avait vu** : la ligne vit dans la branche d'auto-élévation, qui ne s'exécute **que** dans une console non élevée — l'en-tête du script dit lui-même « neuf fois sur dix » —, et une installation lancée depuis une fenêtre administrateur la saute entièrement. **Reproduit avant de toucher quoi que ce soit**, au banc et non par lecture : le script réel passé à `iex` dans une console non élevée, `Invoke-WebRequest` et `Start-Process` bouchonnés, rend l'erreur du forum **au message, à la catégorie et à l'identifiant près** ; le même banc sur le fichier corrigé va jusqu'à « l'installation continue dans la nouvelle fenetre ». La variable s'appelle `$relaunchedScript`. **Le garde-fou ferme la famille au lieu du cas** : `TestNoLocalVariableCollidesWithAParameterByCaseAlone` refuse, dans **tous** les `.ps1` du dépôt, une affectation à un nom qui ne diffère d'un paramètre déclaré que par la casse. **La règle porte sur la casse et non sur le fait d'affecter un paramètre**, parce que ce dépôt en affecte exprès — `$AccountPassword` reçoit ce qui vient d'être tapé, `$Pilot` s'allume sur une réponse : ceux-là écrivent le nom qu'ils ont déclaré, et une casse qui diverge est quelqu'un qui croit ouvrir une variable neuve. **La première version du test comptait 15 signalements dont 14 faux** — un `-Directory` d'une fonction contre un `$directory` de trois autres, qui sont quatre portées et pas une collision —, donc il suit désormais les portées : il n'attribue une affectation qu'aux paramètres de la fonction qui la contient, ou du script si aucune. `if`, `foreach` et `try` n'ouvrent **pas** de portée, ce qui est précisément par où le défaut est passé. **Deuxième vraie collision trouvée par le test**, dans `make.ps1` : `$version = Or-Else $Version …` écrasait le paramètre `-Version` au lieu de le compléter — sans lever, une chaîne entrant dans une `[string]`, mais en effaçant ce que l'opérateur avait demandé. **3 115 tests Go** (3 103 verts, 12 écartés) sur 35 paquets, 0 échec, `go vet` et `gofmt` sans remarque. **Ce qui n'est PAS fait, et n'est pas à croire fait** : aucun poste n'a été installé pour de bon — le banc s'arrête à l'appel d'élévation, et tout ce qui suit (release, empreinte, les trois questions, `install.ps1`) reste non exercé sur ce chemin ; et le test lit les scripts par un découpage de texte et non par l'analyseur de PowerShell — il neutralise les chaînes d'une ligne, **pas les here-strings**, dont `common.ps1` porte un de 60 lignes. Celui-là ne le trompe pas, son corps étant équilibré en accolades, mais **par chance et non par construction** : vérifié en posant une collision de l'autre côté, que le test attribue bien à la fonction qui la contient | | 02/08/2026 | **La CI validait une pull request en 3 min 55 ; elle valide en 1 min 43** (PR #47), et les deux causes ont été mesurées avant d'être touchées. **(1) Les montages de test payaient le coût argon2 d'un vrai login.** `internal/web` mettait **59,5 s** dans la passe `-race` — le paquet le plus lourd du dépôt, à lui seul la moitié de la passe. Ses tests écrivaient leurs empreintes avec `HashSecret`, donc à 64 MiB, t=3, p=2 : le coût d'un login sur l'i3 du poste, payé 34 fois deux, plus 21 vérifications. Or `VerifySecret` relit m, t et p **dans la chaîne stockée** — `TestVerificationReadsTheCostFromTheStoredHash` énonçait déjà cette propriété — donc une empreinte écrite au coût minimal se vérifie au coût minimal, sans qu'aucun chemin de production bouge : `HashSecret` garde ses 64 MiB, `TestArgon2idRoundTrip` les épingle toujours. **59,5 s → 9,0 s en CI**, et 21,3 s → 2,9 s sur poste de développement, ce qui allège autant `make test`. **(2) Huit étapes en série qui ne se devaient rien.** Le travail « Tests et frontières » enchaînait 33 s de `go vet`, 2 min 13 de `-race`, 51 s de passe sans cgo, puis les gardes, pendant que les trois autres travaux avaient fini depuis deux minutes. Découpé en `race`, `test` et `guards`. **Le critère est écrit dans `ci.yml` parce qu'il n'est pas esthétique** : une étape ne peut changer de travail que si elle ne lit rien de celui qu'elle quitte — vrai des quatre gardes, faux des planchers de couverture, qui lisent le profil de la passe sans cgo et restent avec elle. **Le temps-runner cumulé baisse aussi** (423 s → 407 s) : le découpage ajoute un `setup-go` par travail, argon2 en rendait davantage. **Ce qui reste** : `race` plafonne à 1 min 33, et l'écart avec le « moins de 10 s » de §16.4 n'est plus dans un paquet mais réparti sur quatre — `station` 16,7 s, `cmd/openscale` 16,0 s, `store` 13,7 s, `web` 9,0 s. **(3) Dans la foulée** (PR #48), `ci.yml` reçoit un `concurrency` : pousser un correctif sur une branche en validation annule le run qui juge l'état qu'on vient de remplacer. **Pas sur `main`**, où chaque commit est un état livrable dont le verdict est une trace — même arbitrage que `docs.yml`, conclusion inverse. Ce n'est pas une minute gagnée sur un run, c'est de la place sous la limite de travaux simultanés, **seule limite d'Actions qui s'applique ici** : le dépôt est public, donc les runners hébergés ne décomptent aucune minute. | diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6dda443..a3c9076 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -131,8 +131,9 @@ recommencez. > de passe avec `openscale config password` avant de relire. **2. Redémarrer le poste.** L'application s'arrête et son service la relance. Comptez -quelques secondes, l'écran client revient tout seul. Le journal Windows enregistrera un -« arrêt inattendu » : **c'est normal**, c'est ainsi que le redémarrage est déclenché. +quelques secondes, l'écran client revient tout seul. Le bouton dit depuis combien de temps +il attend. Le journal Windows enregistrera un « arrêt inattendu » : **c'est normal**, c'est +ainsi que le redémarrage est déclenché. - Si le bouton répond **« ce poste n'est pas lancé par un service »** (`ERR-SYS-10`), personne ne le relancerait : installez-le en service avec @@ -141,6 +142,34 @@ quelques secondes, l'écran client revient tout seul. Le journal Windows enregis `& "C:\Program Files\OpenScale\openscale.exe" service restart`, ou `sudo systemctl restart openscale` sur Linux. +### Le bouton reste sur « En cours… » et le poste ne revient pas + +C'est le symptôme d'un poste **installé avant le 10/08/2026**, sur Windows. L'écran client +est noir pendant ce temps, et au bout de cinq minutes le bouton annonce que le poste n'a +pas répondu. + +Ce qui se passe : le poste s'est bien arrêté, mais le gestionnaire de services Windows ne +l'a pas relancé. Ses actions de reprise étaient posées — vous pouvez le vérifier avec +`sc qfailure OpenScale` — mais elles ne s'appliquaient qu'à un **plantage**, jamais à un +arrêt propre comme celui-ci. Il y manquait un second réglage, que `sc qfailureflag +OpenScale` affiche à `FALSE`. + +**Le geste immédiat**, pour rallumer le poste tout de suite, depuis une invite en +administrateur : + +``` +& "C:\Program Files\OpenScale\openscale.exe" service start +``` + +**La réparation durable** : relancez `install.ps1` **en administrateur**. Il repose +l'enregistrement du service avec le réglage manquant, et le bouton fonctionne ensuite. + +> **Une simple mise à jour ne répare PAS ce poste** : elle remplace le binaire, alors que +> le réglage fautif est enregistré dans Windows, pas dans le programme. Il faut passer par +> l'installeur. + +Les postes installés à partir du 10/08/2026 n'ont pas ce défaut. + **3. Redémarrer l'ordinateur.** Le bouton rouge, en dernier recours. Il demande une confirmation, puis affiche un décompte de trente secondes que **« Annuler » arrête**. Comptez ensuite une minute avant que l'écran revienne. diff --git a/cmd/openscale/config.go b/cmd/openscale/config.go index e463d4b..4546cdc 100644 --- a/cmd/openscale/config.go +++ b/cmd/openscale/config.go @@ -15,10 +15,10 @@ import ( // sentences their refusals share. The two actions that only READ the file are in // configread.go, the three that REWRITE it in configwrite.go. -// runConfig is `openscale config validate|export|fingerprint|password|recovery-code` +// runConfig is `openscale config validate|export|fingerprint|station|password|recovery-code` // (§15.1, §14.4). // -// # Why `import` is still not here, and why `password` now is +// # Why `import` is still not here, and why `password` and `station` now are // // `import` is a gesture of the ADMINISTRATION SCREEN, and giving it a second home would // give the station two ways of being reconfigured — one of them with no diff preview, no @@ -36,13 +36,22 @@ import ( // `recovery-code` is the other half of that line: §14.4 has the eight characters // « générés à l'installation, imprimés sur la fiche », and install.ps1 has no way to // produce an argon2id hash on its own. +// +// `station` is the same argument applied to the three fields the installer is the only +// one in a position to know: a number, a name, and whether there is a scale at all. They +// carry NO secret, so unlike the password they may travel as options — and nothing else +// here may, which is why no action of `config` declares an option that would take one. func runConfig(args []string, in io.Reader, out io.Writer) error { fs := flag.NewFlagSet("config", flag.ContinueOnError) fs.SetOutput(out) var ( hardware = fs.Bool("hardware", false, "conserver le bloc matériel dans l'export (par défaut il est retiré)") - output = fs.String("output", "", "fichier de sortie de l'export ; sinon la sortie standard") + output = fs.String("output", "", "fichier de sortie de l'export ; sinon la sortie standard") + number = fs.Int("number", 0, "numéro de ce poste dans la coopérative") + name = fs.String("name", "", "nom de ce poste, celui que lisent les bénévoles") + noScale = fs.Bool("no-scale", false, + "déclarer que ce poste n'a pas encore de balance") ) fs.Usage = func() { fmt.Fprint(out, configUsage) } @@ -50,10 +59,16 @@ func runConfig(args []string, in io.Reader, out io.Writer) error { if err != nil { return err } + // WHICH options were typed, and not which ones carry a value: --number 0 and no + // --number at all are the same integer, and one of them is an order to write a station + // number the controls refuse while the other is an order to leave the field alone. + given := make(map[string]bool, 3) + fs.Visit(func(f *flag.Flag) { given[f.Name] = true }) + if len(positional) == 0 { fs.Usage() return errors.New("config prend une action : validate, export, fingerprint, migrate, " + - "password ou recovery-code") + "station, password ou recovery-code") } action := positional[0] @@ -90,14 +105,23 @@ func runConfig(args []string, in io.Reader, out io.Writer) error { return nil case "migrate": return migrateConfig(out, path) + case "station": + edit := stationEdit{DisableScale: *noScale} + if given["number"] { + edit.Number = number + } + if given["name"] { + edit.Name = name + } + return setStationIdentity(out, path, edit) case "password": return setAdminPassword(in, out, path) case "recovery-code": return mintRecoveryCode(out, path) } fs.Usage() - return fmt.Errorf("action inconnue %q : validate, export, fingerprint, migrate, password "+ - "ou recovery-code", action) + return fmt.Errorf("action inconnue %q : validate, export, fingerprint, migrate, station, "+ + "password ou recovery-code", action) } const configUsage = `Usage : openscale config [fichier] [options] @@ -109,6 +133,7 @@ Actions : export écrit la configuration à cloner vers les autres postes fingerprint affiche l'empreinte de 8 caractères des réglages partagés migrate remet le fichier à la forme que ce binaire lit, et dit ce qu'il change + station pose l'identité du poste et l'état de la balance password pose le mot de passe d'administration, lu sur l'entrée standard recovery-code tire le code de secours de 8 caractères et l'affiche UNE fois @@ -118,13 +143,28 @@ Options d'export : c'est ce qu'un poste cloné ne doit pas hériter --output écrire dans un fichier plutôt que sur la sortie standard -Le mot de passe d'administration ne sort JAMAIS, avec ou sans --hardware. +Options de station (au moins une, sinon la commande ne change rien) : + --number le numéro de ce poste dans la coopérative. C'est de lui que + dérive le nom du fichier de catalogue surveillé, flv_.csv + --name le nom que lisent les bénévoles, « Poste 2 — fruits » + --no-scale déclarer que ce poste n'a PAS de balance : c'est l'état d'un + poste neuf, dont la balance n'est pas encore branchée ni + détectée. Elle se remet en service depuis l'écran + d'administration, page « Matériel » : « Détecter + automatiquement », puis « Utiliser cette balance » sur le + port qui a répondu + +AUCUNE option ne prend de secret, ici ni ailleurs : un argument de ligne de commande se +lit dans la liste des processus, par n'importe quel utilisateur de la machine. Le mot de +passe d'administration se pose donc par « password », qui le lit sur l'entrée standard. +Il ne sort JAMAIS d'un export, avec ou sans --hardware. + Importer une configuration se fait depuis l'écran d'administration : l'aperçu du diff champ par champ et la confirmation de 60 secondes en font partie. -migrate, password et recovery-code écrivent le FICHIER, que le poste ne relit qu'au -démarrage : arrêtez le service, lancez la commande, redémarrez-le. Le terminal affiche ce -qui est tapé — c'est une console de poste, pas un poste de travail partagé. +migrate, station, password et recovery-code écrivent le FICHIER, que le poste ne relit +qu'au démarrage : arrêtez le service, lancez la commande, redémarrez-le. Le terminal +affiche ce qui est tapé — c'est une console de poste, pas un poste de travail partagé. ` // refuseWhatWasNotRead stops an action that would ANSWER ABOUT a file this binary did not diff --git a/cmd/openscale/configread_test.go b/cmd/openscale/configread_test.go index 1c6aedd..1d2c8f8 100644 --- a/cmd/openscale/configread_test.go +++ b/cmd/openscale/configread_test.go @@ -49,10 +49,19 @@ func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) { t.Fatalf("export : %v", err) } + // The address is checked ON THE FILE, and it has to be: what a clone must not inherit + // is what the file SAYS. Reading it back through domain.Config no longer shows it — + // `Config.UnmarshalJSON` poses the neutral loopback on an empty address, so that a + // station installed from this very file can still be administered — and an assertion + // on the decoded structure would from then on be asserting the decoder's repair + // instead of the export's discretion, which is the opposite guarantee. + if listen := listenSpelledIn(t, export); listen != "" { + t.Fatalf("le fichier exporté porte l'adresse %q : c'est l'une des choses qu'un "+ + "clone ne doit pas hériter", listen) + } exported := readJSONConfig(t, export) - if exported.Station.Number != 0 || exported.Network.Listen != "" { - t.Fatalf("l'export porte encore le poste n° %d et l'adresse %q : ce sont les deux "+ - "choses qu'un clone ne doit pas hériter", exported.Station.Number, exported.Network.Listen) + if exported.Station.Number != 0 { + t.Fatalf("l'export porte encore le poste n° %d", exported.Station.Number) } if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" { t.Fatal("l'export porte un secret d'administration") @@ -88,6 +97,29 @@ func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) { } } +// listenSpelledIn reports what network.listen SAYS in a configuration file, before any +// decoder has had the chance to fill it in. +// +// A shape of its own and not domain.Config, so that nothing this reads can be repaired on +// the way: the guarantee is about the bytes a volunteer carries from one station to +// another on a USB stick. +func listenSpelledIn(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + var document struct { + Network struct { + Listen string `json:"listen"` + } `json:"network"` + } + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("%s illisible : %v", path, err) + } + return document.Network.Listen +} + // TestOneBusinessSettingApartAndTheFingerprintDiverges is the other half of the check: // an eight-character digest that never moved would be a green light nobody can trust. // diff --git a/cmd/openscale/configstation.go b/cmd/openscale/configstation.go new file mode 100644 index 0000000..da002f5 --- /dev/null +++ b/cmd/openscale/configstation.go @@ -0,0 +1,161 @@ +package main + +import ( + "errors" + "fmt" + "io" + "strings" + + "openscale/internal/domain" +) + +// This file is `openscale config station`: WHO this station is — its number, its name — +// and the declaration that it has no scale yet. +// +// # Why a fourth door that writes the file +// +// The delivered configuration is the export of §11.5, so a station comes out of the +// archive with station.number 0, station.name empty and a scale block that names a +// protocol without naming a port. Every one of those is a fault, the station therefore +// starts in factory configuration (§11.3), and the three of them used to be left to the +// administration screen — on a station whose screen is exactly what the volunteer has to +// reach through a recovery code they may have already filed away. +// +// `password` is here for the same reason and says it in its own comment: what has no +// screen to be set from has to have a terminal. This action carries no secret at all, +// which is why its values may travel as arguments; the password next door reads the one +// secret of this command off the standard input, and nothing in `config` ever takes one +// from argv — a command line is readable in the process list by ANY user of the machine. +// +// It writes through editConfigFile like the three others: the store rotates config.json.1 +// … .5, the file lands atomically (§11.4), and the station does not see the change before +// it restarts or is asked to re-read its file. + +// stationEdit is what one `openscale config station` was asked to change. +// +// Number and Name are POINTERS because "set this field" and "leave it alone" are two +// different orders, and a zero value would be the first one said badly: 0 is a station +// number the controls refuse, and an empty name is what the delivered file already +// carries. +type stationEdit struct { + Number *int + Name *string + // DisableScale declares that this station has no scale — the explicit declaration of + // §11.2, and NOT a way of naming one. Turning a scale back on means naming the port it + // answers on, which is what the automatic detection of the administration screen is + // for; a terminal has no port to offer, so this switch only goes one way. + DisableScale bool +} + +// setStationIdentity is `openscale config station` (§15.5, §11.2). +// +// It refuses to write a value the station's own controls would refuse. Without that, the +// command would trade one fault for another and report success: an operator who typed 0 +// for their station number would read « c'est fait » and find out at the next restart, +// from a station that came up in factory configuration for a different reason than before. +func setStationIdentity(out io.Writer, path string, edit stationEdit) error { + if edit.Number == nil && edit.Name == nil && !edit.DisableScale { + return errors.New("config station ne change rien sans --number, --name ou --no-scale") + } + if edit.Name != nil && strings.TrimSpace(*edit.Name) == "" { + return errors.New("--name est vide : c'est déjà ce que porte la configuration livrée, " + + "et un poste sans nom ne se distingue de ses voisins que par son numéro") + } + + var ( + posed []string + withoutManual bool + ) + err := editConfigFile(path, func(cfg *domain.Config) error { + if edit.Number != nil { + cfg.Station.Number = *edit.Number + posed = append(posed, fmt.Sprintf("numéro %d", *edit.Number)) + } + if edit.Name != nil { + cfg.Station.Name = strings.TrimSpace(*edit.Name) + posed = append(posed, fmt.Sprintf("nom « %s »", cfg.Station.Name)) + } + if edit.DisableScale { + // THE PROTOCOL GOES WITH THE DECLARATION, and lowering `present` alone would not + // do: control 3 refuses a protocol named by a station that declares no scale, and + // control 6 goes on demanding whatever options that protocol's driver declares — + // the serial port among them. Naming no protocol is what §11.2 calls the explicit + // declaration of a station without a scale, and it is what takes + // `scale.options.port` out of the fault list of a station straight out of the + // installer. + // + // THE OPTIONS STAY, and that is where this parts company with the neutral profile + // of §11.3, which carries none. Baud, bits, parity, stop and the reconnection + // backoff are settings the FOUR STATIONS SHARE — they travel in the cloned + // configuration and they count in the fingerprint of §15.5. Clearing them would + // move this station away from its neighbours FOR GOOD: the automatic detection + // that puts the scale back writes a port, not a serial dialect, so nothing would + // bring those five values back. Measured on the delivered file: kept, the + // fingerprint returns to the fleet's the moment the scale is declared again; + // cleared, it lands somewhere else and stays there. A port left behind in a block + // whose `present` is false opens nothing — no driver is built from it — and the + // detection overwrites it. + cfg.Scale.Present = false + cfg.Scale.Type = "" + withoutManual = !cfg.Scale.ManualEntryAllowed + posed = append(posed, "balance déclarée absente") + } + return refuseWhatTheStationWouldRefuse(*cfg, edit) + }) + if err != nil { + return err + } + + fmt.Fprintf(out, "%s : %s.\n", path, strings.Join(posed, ", ")) + if edit.DisableScale { + fmt.Fprintf(out, "La balance se remet en service depuis l'écran d'administration, page "+ + "« Matériel », « Détecter automatiquement », puis « Utiliser cette balance » sur le "+ + "port qui a répondu : ce bouton déclare la présence, le protocole et le port d'un "+ + "seul geste. Une ligne de commande, elle, n'a aucun port à nommer.\n") + if withoutManual { + fmt.Fprintf(out, "ATTENTION : ce poste n'autorise pas la saisie du poids à la main "+ + "(scale.manual_entry_allowed). Sans balance, il ne peut donc plus rien peser.\n") + } + } + fmt.Fprintf(out, "Redémarrez le service pour que le poste le prenne en compte.\n") + return nil +} + +// refuseWhatTheStationWouldRefuse suspends the write when a field this command just set +// carries a fault. +// +// It judges the fields this run TOUCHED and no others, which is what makes it usable on a +// station that is still incomplete elsewhere — the ordinary case, since the installer +// calls this action on a file that has no printer queue and no catalogue address yet. +// Refusing on somebody else's fault would make posing a station number impossible until +// everything else was done, in the exact order nobody follows. +// +// The bounds are NOWHERE in this file: Config.Validate owns control 1, and a second place +// that spelled « [1, 99] » would be the place that disagrees the day the fleet grows. +func refuseWhatTheStationWouldRefuse(cfg domain.Config, edit stationEdit) error { + var touched []string + if edit.Number != nil { + touched = append(touched, "station.number") + } + if edit.DisableScale { + touched = append(touched, "scale.") + } + if len(touched) == 0 { + return nil + } + + var refused []string + for _, fault := range cfg.Validate(registries()) { + for _, field := range touched { + if strings.HasPrefix(fault.Field, field) { + refused = append(refused, fault.String()) + break + } + } + } + if len(refused) == 0 { + return nil + } + return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( + "le fichier n'est pas modifié : %s", strings.Join(refused, " ; "))} +} diff --git a/cmd/openscale/configstation_test.go b/cmd/openscale/configstation_test.go new file mode 100644 index 0000000..ac72122 --- /dev/null +++ b/cmd/openscale/configstation_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "os" + "sort" + "strings" + "testing" +) + +// `openscale config station`: the action install.ps1 calls to say who this station is and +// that it has no scale yet. What is checked here is what a station straight out of the +// installer gains from it — one fault list shorter — and what it must never lose. + +// TestPosingTheIdentityRemovesTheFaultsAFreshStationCameWith is the whole point of the +// action, measured on the file a station actually reads. +// +// The delivered configuration is the EXPORT of §11.5: number 0, no name, and a scale block +// that names gram-xfoc-plus without naming a port. copyDelivered produces exactly that, so +// the count below is the one a volunteer sees on a machine that came out of install.ps1. +func TestPosingTheIdentityRemovesTheFaultsAFreshStationCameWith(t *testing.T) { + path := copyDelivered(t) + before := faultFieldsOf(t, path) + for _, field := range []string{"station.number", "scale.options.port"} { + if !before[field] { + t.Fatalf("un poste neuf ne porte pas la faute %s : ce banc ne mesure plus ce qu'il "+ + "annonce (fautes : %v)", field, sortedKeys(before)) + } + } + + out := &strings.Builder{} + if err := runConfig([]string{"station", path, "--number", "2", "--name", "Poste 2 — fruits", + "--no-scale"}, nil, out); err != nil { + t.Fatalf("config station : %v", err) + } + + after := faultFieldsOf(t, path) + for _, field := range []string{"station.number", "scale.options.port"} { + if after[field] { + t.Errorf("la faute %s est toujours là après « config station » (fautes : %v)", + field, sortedKeys(after)) + } + } + // And the ONE fault the installer cannot answer for stays: the catalogue address is a + // share of the shop, not something a terminal knows. + if !after["catalog.options.url"] { + t.Errorf("catalog.options.url n'est plus une faute : cette action aurait touché un "+ + "bloc qu'elle ne nomme pas (fautes : %v)", sortedKeys(after)) + } + + cfg := readJSONConfig(t, path) + if cfg.Station.Number != 2 || cfg.Station.Name != "Poste 2 — fruits" { + t.Errorf("identité écrite = %d / %q", cfg.Station.Number, cfg.Station.Name) + } + if !strings.Contains(out.String(), "Redémarrez le service") { + t.Errorf("la commande ne dit pas qu'il faut redémarrer : %q", out.String()) + } +} + +// TestDisablingTheScaleClearsTheProtocolButKeepsWhatTheFleetShares is the half that is +// easy to get wrong TWICE, in opposite directions. +// +// Lowering `present` alone LOOKS like it declares a station without a scale: it does not, +// because control 6 goes on demanding the options « gram-xfoc-plus » declares, the serial +// port among them. And clearing the whole option map looks like the neutral profile: it is +// not, because baud, bits, parity, stop and the backoff are settings the four stations +// SHARE and that the fingerprint of §15.5 compares — nothing would ever bring them back, +// the detection that re-declares a scale writing a port and not a serial dialect. +// +// So the fingerprint MOVES while the scale is off, and comes back the moment it is +// declared again. That round trip is what the installation sheet promises, and it is what +// this bench measures rather than asserts. +func TestDisablingTheScaleClearsTheProtocolButKeepsWhatTheFleetShares(t *testing.T) { + path := copyDelivered(t) + before := readJSONConfig(t, path) + if before.Scale.Type == "" || !before.Scale.Present { + t.Fatalf("la configuration livrée ne déclare pas de balance (type %q, present %v) : "+ + "ce banc ne prouve rien", before.Scale.Type, before.Scale.Present) + } + fleet := fingerprintOf(t, path) + + if err := runConfig([]string{"station", path, "--no-scale"}, nil, &strings.Builder{}); err != nil { + t.Fatalf("config station --no-scale : %v", err) + } + + after := readJSONConfig(t, path) + if after.Scale.Present { + t.Error("scale.present est resté vrai") + } + if after.Scale.Type != "" { + t.Errorf("scale.type = %q : un poste qui déclare n'avoir aucune balance ne nomme "+ + "aucun protocole", after.Scale.Type) + } + // The one field of the block that is an OPERATOR's choice and not a piece of hardware + // stays where it was: without it, a station with no scale cannot weigh at all. + if !after.Scale.ManualEntryAllowed { + t.Error("scale.manual_entry_allowed a été baissé : ce poste ne peut plus rien peser") + } + + // The serial dialect survived, key for key. + for key := range before.Scale.Options { + if _, kept := after.Scale.Options[key]; !kept { + t.Errorf("scale.options.%s a disparu : c'est un réglage que les quatre postes "+ + "partagent, et rien ne le remettrait", key) + } + } + + // The fingerprint moved — the installation sheet says so — and it comes back. + if moved := fingerprintOf(t, path); moved == fleet { + t.Errorf("l'empreinte n'a pas bougé (%s) : la fiche d'installation annonce un écart "+ + "qui n'existerait pas", moved) + } + restored := after + restored.Scale.Present, restored.Scale.Type = before.Scale.Present, before.Scale.Type + writeJSONConfig(t, path, restored) + if back := fingerprintOf(t, path); back != fleet { + t.Errorf("empreinte après remise en service de la balance = %s, attendu celle du parc "+ + "(%s) : un poste réglé ne rejoindrait jamais ses voisins", back, fleet) + } +} + +// TestAStationNumberTheControlsRefuseIsNotWritten. +// +// « c'est fait » on a number that puts the station back into factory configuration is +// worse than a refusal: the operator learns it at the next restart, from a fault they did +// not cause, and the file they would have to repair no longer holds what they typed. +func TestAStationNumberTheControlsRefuseIsNotWritten(t *testing.T) { + path := copyDelivered(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture : %v", err) + } + + runErr := runConfig([]string{"station", path, "--number", "0"}, nil, &strings.Builder{}) + if runErr == nil { + t.Fatal("le numéro 0 a été accepté") + } + if exitCodeFor(runErr) == 0 { + t.Fatal("code de sortie nul : install.ps1 croirait le numéro posé") + } + if !strings.Contains(runErr.Error(), "station.number") { + t.Errorf("le refus ne nomme pas le champ en cause : %v", runErr) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture : %v", err) + } + if string(after) != string(before) { + t.Error("le fichier a été réécrit alors que la valeur est refusée") + } + if _, err := os.Stat(path + ".1"); err == nil { + t.Error("une version a été tournée alors que rien ne devait être écrit") + } +} + +// TestPosingOneFieldLeavesTheOthersAlone. +// +// The installer calls this action on a file that is still incomplete everywhere else — no +// print queue, no catalogue address. An action that refused on somebody else's fault, or +// that reset a field it was not given, would make the three questions of the installation +// answerable only in an order nobody follows. +func TestPosingOneFieldLeavesTheOthersAlone(t *testing.T) { + path := copyDelivered(t) + if err := runConfig([]string{"station", path, "--number", "3"}, nil, &strings.Builder{}); err != nil { + t.Fatalf("config station --number sur un fichier incomplet ailleurs : %v", err) + } + + after := readJSONConfig(t, path) + if after.Station.Number != 3 { + t.Errorf("station.number = %d", after.Station.Number) + } + if !after.Scale.Present || after.Scale.Type == "" { + t.Error("--number a désactivé la balance, qu'il ne nomme pas") + } + + // And a --name alone does not undo the number that was just posed. + if err := runConfig([]string{"station", path, "--name", "Poste 3"}, nil, + &strings.Builder{}); err != nil { + t.Fatalf("config station --name : %v", err) + } + if again := readJSONConfig(t, path); again.Station.Number != 3 || again.Station.Name != "Poste 3" { + t.Errorf("après --name : numéro %d, nom %q", again.Station.Number, again.Station.Name) + } +} + +// TestStationRefusesWhatWouldNotBeAChange keeps « c'est fait » from being printed over +// nothing, and keeps an empty name from being posed as if it were one. +func TestStationRefusesWhatWouldNotBeAChange(t *testing.T) { + for name, options := range map[string][]string{ + "aucune option": {}, + "nom vide": {"--name", " "}, + } { + t.Run(name, func(t *testing.T) { + path := copyDelivered(t) + args := append([]string{"station", path}, options...) + if err := runConfig(args, nil, &strings.Builder{}); err == nil { + t.Fatalf("%v a été accepté", args) + } + }) + } +} + +// TestNoOptionOfConfigCarriesASecret is the rule that keeps a password out of an argv. +// +// A command line is readable in the process list by ANY user of the machine, which is why +// bootstrap.ps1 refuses to elevate itself when it holds one and why install.ps1 pipes it +// to `config password` instead. An option here — `--admin-password` looks so convenient — +// would defeat both, from the one place neither script controls. +func TestNoOptionOfConfigCarriesASecret(t *testing.T) { + for _, option := range []string{ + "--password", "--admin-password", "--secret", "--recovery-code", "--pass", + } { + path := copyDelivered(t) + err := runConfig([]string{"station", path, option, "un-secret", "--number", "2"}, + nil, &strings.Builder{}) + if err == nil { + t.Errorf("« openscale config station %s » est accepté : un secret passerait par "+ + "la ligne de commande", option) + } + if hash := readJSONConfig(t, path).Admin.PasswordHash; hash != "" { + t.Errorf("%s a posé un mot de passe", option) + } + } +} + +// faultFieldsOf names every fault the station's own controls raise on a file, exactly as +// `openscale config validate` counts them. +func faultFieldsOf(t *testing.T, path string) map[string]bool { + t.Helper() + cfg, _, decodeFaults, err := readConfigLeniently(path) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + fields := make(map[string]bool) + for _, fault := range append(decodeFaults, cfg.Validate(registries())...) { + fields[fault.Field] = true + } + return fields +} + +// sortedKeys renders a fault set in a failure message, in an order that does not change +// between two runs. +func sortedKeys(fields map[string]bool) []string { + names := make([]string, 0, len(fields)) + for field := range fields { + names = append(names, field) + } + sort.Strings(names) + return names +} diff --git a/cmd/openscale/configwrite.go b/cmd/openscale/configwrite.go index 9a4850a..b4e3806 100644 --- a/cmd/openscale/configwrite.go +++ b/cmd/openscale/configwrite.go @@ -19,13 +19,6 @@ import ( // versions and lands atomically like any other (§11.4), and the station does not see // it before it restarts. -// minPasswordLength is the floor POST /admin/api/session/recovery already holds (§14.4). -// -// The same figure in the two places that set a password, because a station where the -// terminal accepted four characters and the screen refused them would be a station whose -// rule depends on which door somebody came through. -const minPasswordLength = 8 - // editConfigFile reads the station's configuration, hands it to change, and writes it // back. // @@ -66,14 +59,17 @@ func setAdminPassword(in io.Reader, out io.Writer, path string) error { // station with no recovery code has one gesture left to make. var withoutRecoveryCode bool err := editConfigFile(path, func(cfg *domain.Config) error { + // web.MinPasswordLength is the AUTHORITY, here as on the recovery form of §14.4: + // this command holds no floor of its own, it applies the station's. fmt.Fprintf(out, "Nouveau mot de passe d'administration pour %s\n"+ - "(au moins %d caractères, il s'affiche à l'écran) : ", path, minPasswordLength) + "(au moins %d caractères, il s'affiche à l'écran) : ", path, web.MinPasswordLength) typed, err := readSecretLine(in) if err != nil { return err } - if len([]rune(typed)) < minPasswordLength { - return fmt.Errorf("le mot de passe doit faire au moins %d caractères", minPasswordLength) + if len([]rune(typed)) < web.MinPasswordLength { + return fmt.Errorf("le mot de passe doit faire au moins %d caractères", + web.MinPasswordLength) } hash, err := web.HashSecret(typed) @@ -158,7 +154,13 @@ func readSecretLine(in io.Reader) (string, error) { // Typed on a Windows console the line ends with \r\n, piped from a file it may end // with nothing at all. Only those two characters go: a password is allowed to end // with a space, and trimming it would refuse tomorrow what it accepted today. - return strings.TrimRight(line, "\r\n"), nil + // + // The byte order mark at the HEAD goes too, and it is the one character nobody typed: + // piping into a native process from a PowerShell console in chcp 65001 puts EF BB BF in + // front of the standard input. Hashing it walls the station off — what the volunteer + // types back, on the screen or on this same console, is the password WITHOUT the mark + // and would never verify — and nothing on either end would show why. + return strings.TrimPrefix(strings.TrimRight(line, "\r\n"), "\ufeff"), nil } // migrateConfig is `openscale config migrate`. diff --git a/cmd/openscale/configwrite_test.go b/cmd/openscale/configwrite_test.go index b642fb1..16ff336 100644 --- a/cmd/openscale/configwrite_test.go +++ b/cmd/openscale/configwrite_test.go @@ -51,15 +51,94 @@ func TestTheCommandLineOpensAStationNobodyCanLogInTo(t *testing.T) { // TestAPasswordTooShortIsRefusedByBOTHDoors: the floor is the same on the terminal and on // the recovery form, or the rule would depend on which door somebody came through. +// +// The length is DERIVED from web.MinPasswordLength and never spelled here. This bench used +// to feed a five-character literal and to call it « cinq caractères » in its own failure +// message: the day the owner moved the floor, it went red on a command that was doing +// exactly what it had been asked to do. What has to hold is not a figure — it is that a +// floor exists, that it is the one both doors read, and that it lets the floor itself +// through. func TestAPasswordTooShortIsRefusedByBOTHDoors(t *testing.T) { path := copyDelivered(t) - err := runConfig([]string{"password", path}, strings.NewReader("court\n"), &strings.Builder{}) + tooShort := strings.Repeat("a", web.MinPasswordLength-1) + err := runConfig([]string{"password", path}, strings.NewReader(tooShort+"\n"), &strings.Builder{}) if err == nil { - t.Fatal("un mot de passe de cinq caractères a été accepté") + t.Fatalf("%q, un caractère sous le plancher, a été accepté", tooShort) } if hash := readJSONConfig(t, path).Admin.PasswordHash; hash != "" { t.Fatal("un mot de passe refusé a tout de même été écrit") } + + // And the floor EXACTLY goes through: a bench that only knew what is refused would stay + // green on a command that refuses everything. + exact := strings.Repeat("a", web.MinPasswordLength) + if err := runConfig([]string{"password", path}, strings.NewReader(exact+"\n"), + &strings.Builder{}); err != nil { + t.Fatalf("%q, le plancher exactement : %v", exact, err) + } + if !web.VerifySecret(readJSONConfig(t, path).Admin.PasswordHash, exact) { + t.Fatalf("le mot de passe %q, au plancher exact, n'a pas été écrit", exact) + } +} + +// TestTheTerminalCountsCHARACTERSAndNotBYTES is the terminal half of the counting unit. +// +// The screen's half is TestTheRecoveryFormCountsCODEPOINTSAndNotBYTES, in internal/web, +// where the door that used to count bytes lives. Neither door can drive the other from its +// own package, so what makes « la même valeur aux deux endroits » true is those two benches +// plus TestTheTwoDoorsNAMETheFloorInsteadOfSpellingIt, which holds that both compare +// against the one constant. +func TestTheTerminalCountsCHARACTERSAndNotBYTES(t *testing.T) { + // « é » is ONE character and TWO bytes in UTF-8, so a password one character short of + // the floor is already longer than the floor in bytes. A door counting bytes lets it in; + // a door counting characters does not; and the same password then opens one and not the + // other. + tooShort := strings.Repeat("é", web.MinPasswordLength-1) + if len(tooShort) < web.MinPasswordLength { + t.Fatalf("prémisse fausse : %q fait %d octets, il n'atteint pas le plancher et ne "+ + "prouve donc rien de l'unité comptée", tooShort, len(tooShort)) + } + + path := copyDelivered(t) + if err := runConfig([]string{"password", path}, strings.NewReader(tooShort+"\n"), + &strings.Builder{}); err == nil { + t.Fatalf("%q, sous le plancher en caractères, a été accepté : les octets ont été comptés", + tooShort) + } + + exact := strings.Repeat("é", web.MinPasswordLength) + if err := runConfig([]string{"password", path}, strings.NewReader(exact+"\n"), + &strings.Builder{}); err != nil { + t.Fatalf("%q, le plancher exactement en caractères accentués : %v", exact, err) + } + if !web.VerifySecret(readJSONConfig(t, path).Admin.PasswordHash, exact) { + t.Fatalf("le mot de passe accentué %q n'a pas été écrit", exact) + } +} + +// TestAByteOrderMarkIsNotPartOfThePassword. +// +// A pipe into a native process, from a PowerShell console in chcp 65001, puts EF BB BF in +// front of the standard input — which is how this command is fed when a responsible person +// scripts an installation. Hashing that mark walls the station off: the password typed back +// afterwards, at the screen or at this same console, does not carry it and never verifies, +// and neither end shows a reason. +func TestAByteOrderMarkIsNotPartOfThePassword(t *testing.T) { + path := copyDelivered(t) + password := strings.Repeat("a", web.MinPasswordLength) + if err := runConfig([]string{"password", path}, + strings.NewReader("\ufeff"+password+"\r\n"), &strings.Builder{}); err != nil { + t.Fatalf("config password : %v", err) + } + + hash := readJSONConfig(t, path).Admin.PasswordHash + if !web.VerifySecret(hash, password) { + t.Fatalf("l'empreinte écrite ne vérifie pas %q : la marque d'ordre des octets a été "+ + "hachée avec le mot de passe", password) + } + if web.VerifySecret(hash, "\ufeff"+password) { + t.Fatal("la marque d'ordre des octets fait partie du mot de passe écrit") + } } // TestTheRecoveryCodeIsPrintedOnceAndStoredHashed (§14.4, important-10). diff --git a/cmd/openscale/failure.go b/cmd/openscale/failure.go index 268a61c..7a9d5c6 100644 --- a/cmd/openscale/failure.go +++ b/cmd/openscale/failure.go @@ -24,10 +24,12 @@ const ( exitFatal = 3 // exitRestart is a stop somebody ASKED FOR, from the administration screen. // - // It is non-zero ON PURPOSE, and that is the whole mechanism: a non-zero code is - // what makes the SCM apply the recovery actions of §15.2 and systemd its - // Restart=always. A clean 0 would be recorded as a stop nobody undoes, and the - // station would wait for a human who thinks it is coming back. + // It is non-zero ON PURPOSE, and that is HALF the mechanism: a clean 0 would be + // recorded as a stop nobody undoes, and the station would wait for a human who + // thinks it is coming back. The other half is on the supervisor's side — systemd + // reads this code and applies its Restart=always, whereas the Windows SCM ignores it + // unless InstallService also set the failure-actions flag, Windows reserving the + // recovery actions of §15.2 for a service that died without saying so. exitRestart = 4 ) diff --git a/cmd/openscale/main.go b/cmd/openscale/main.go index 7fd4dec..ba9d853 100644 --- a/cmd/openscale/main.go +++ b/cmd/openscale/main.go @@ -53,6 +53,13 @@ La configuration (§11.5) : config export [fichier] [--hardware] la configuration à cloner vers les autres [--output f.json] postes — sans le bloc matériel par défaut config fingerprint [fichier] l'empreinte de 8 caractères à comparer + config station [fichier] --number pose l'identité du poste et l'état de sa + --name balance, sans passer par l'écran : c'est ce + [--no-scale] que fait l'installeur sur un poste neuf + config password [fichier] pose le mot de passe d'administration, lu sur + l'entrée standard et jamais en argument + config recovery-code [fichier] tire le code de secours de la fiche + config migrate [fichier] remet le fichier à la forme que ce binaire lit Diagnostic du poste (lot L8) : doctor [--zip] [--output f.zip] les contrôles de §15.4 : ce qui a été diff --git a/cmd/openscale/maintenance_test.go b/cmd/openscale/maintenance_test.go index e522ed4..6dcf9dd 100644 --- a/cmd/openscale/maintenance_test.go +++ b/cmd/openscale/maintenance_test.go @@ -11,9 +11,12 @@ import ( // TestARestartAskedStopsTheStationWithANonZeroCode. // -// THE CODE IS THE MECHANISM. A zero would be recorded by the SCM as a clean stop, and -// the recovery actions of §15.2 would never fire: the station would sit there, stopped, -// while a volunteer waits for the screen that told them it was coming back. +// THE CODE IS HALF THE MECHANISM, and the half this file can prove. A zero would be +// recorded as a clean stop by both supervisors and nothing would fire: the station would +// sit there, stopped, while a volunteer waits for the screen that told them it was coming +// back. The other half belongs to the supervisor — systemd acts on this code by itself, +// the Windows SCM only once InstallService has set the failure-actions flag, which is what +// service_windows.go poses beside the delays of §15.2. func TestARestartAskedStopsTheStationWithANonZeroCode(t *testing.T) { bench := newServeBench(t) asked := make(chan func() error, 1) @@ -62,6 +65,26 @@ func TestARestartAskedGoesThroughTheOrderedShutdown(t *testing.T) { } } +// TestAStopAskedByTheServiceManagerReturnsZero pins the OTHER side of the failure-actions +// flag, which nothing else in this repository would notice breaking. +// +// With the flag set, the SCM queues a restart for EVERY stop reporting a non-zero code — +// that is what makes the button work, and it has no idea who asked. The way out of a +// `sc stop` is the ctx.Done() branch of serve, which leaves `fatal` nil, so the service +// handler answers 0 and nothing is queued. That is what lets update.ps1 stop the station +// and copy a binary over it. The day that road returned an error instead, « service stop » +// followed by Copy-Item -Force would become a race against a restart five seconds later, +// on a station where nothing looks wrong. +func TestAStopAskedByTheServiceManagerReturnsZero(t *testing.T) { + bench := newServeBench(t) + bench.start() + + if err := bench.stop(); err != nil { + t.Fatalf("un arrêt demandé rend %v : le SCM y lirait un code non nul, mettrait une "+ + "reprise en file, et la mise à jour copierait le binaire sous un poste qui redémarre", err) + } +} + // TestABusyStationRefusesTheRestart: the guard answers for this act, and its French // sentence is what the screen shows. func TestABusyStationRefusesTheRestart(t *testing.T) { diff --git a/deploy/bootstrap_test.go b/deploy/bootstrap_test.go index 3aa0c14..d4f7f2f 100644 --- a/deploy/bootstrap_test.go +++ b/deploy/bootstrap_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "testing" @@ -114,28 +115,111 @@ func TestTheInstallerDeclaresEveryParameterTheBootstrapPasses(t *testing.T) { } } -// TestTheAccountPasswordNeverReachesALogOrACommandLine. +// secretParametersOfTheBootstrap names every parameter that carries a SECRET, by reading +// the script rather than by listing them here. +// +// The criterion is « a parameter whose name ends in Password », and it is a criterion +// rather than a list because the two benches below were written for -AccountPassword alone +// and stayed green the day -AdminPassword arrived: a second secret, named differently, went +// past a guard that spelled the first one's name. A rule that reads the param block covers +// the third one nobody has written yet. +func secretParametersOfTheBootstrap(t *testing.T) []string { + t.Helper() + lines := strings.Split(codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))), "\n") + // The reader of powershell_test.go, and not a regular expression of this file: it already + // knows what a param block is, attributes and default values included, and one place that + // knows is one place to correct. + declared, _ := parametersByScope(lines, owningScopes(lines)) + var secrets []string + for _, spelling := range declared[""] { + if strings.HasSuffix(spelling, "Password") { + secrets = append(secrets, spelling) + } + } + if len(secrets) == 0 { + t.Fatalf("aucun paramètre de %s ne finit par « Password » : la règle ne couvre plus "+ + "rien, alors que l'installation en pose deux", bootstrapPath) + } + sort.Strings(secrets) + return secrets +} + +// TestNoSecretParameterReachesALogOrACommandLine. // // install.log stays on the station; the installation sheet goes to the binder. And an // argument on a command line is readable in the process list by ANY user of the machine — // which is why the bootstrap refuses to elevate itself when a password was given. -func TestTheAccountPasswordNeverReachesALogOrACommandLine(t *testing.T) { +// +// It holds for BOTH secrets the installation now handles: the one of the Windows session, +// and the one of the administration, which install.ps1 pipes into `openscale config +// password` on the standard input for exactly this reason. +func TestNoSecretParameterReachesALogOrACommandLine(t *testing.T) { + secrets := secretParametersOfTheBootstrap(t) + // The VARIABLE and not the word: « $script:MinimumAdminPasswordLength » carries the + // FLOOR of the administration password, it is printed on purpose, and a rule matching + // the bare word forbade the question from saying how long the answer has to be. Case + // blind, because PowerShell is — that is the trap powershell_test.go is full of. + holders := make([]*regexp.Regexp, 0, len(secrets)) + for _, secret := range secrets { + holders = append(holders, regexp.MustCompile(`(?i)\$`+regexp.QuoteMeta(secret)+`\b`)) + } for _, name := range []string{bootstrapPath, "install.ps1"} { script := codeOnly(readFile(t, filepath.Join("windows", name))) for _, line := range strings.Split(script, "\n") { - if !strings.Contains(line, "AccountPassword") { - continue - } - for _, forbidden := range []string{"Write-Host", "Write-Step", "Start-Process"} { - if strings.Contains(line, forbidden) { - t.Errorf("%s fait passer le mot de passe du compte par %s :\n %s", - name, forbidden, strings.TrimSpace(line)) + for index, secret := range secrets { + if !holders[index].MatchString(line) { + continue + } + for _, forbidden := range []string{ + "Write-Host", "Write-Step", "Write-Progression", "Start-Process", + } { + if strings.Contains(line, forbidden) { + t.Errorf("%s fait passer -%s par %s :\n %s", + name, secret, forbidden, strings.TrimSpace(line)) + } } } } } } +// TestTheInstallerFeedsTheAdministrationPasswordOnTheStandardInput is the other half of the +// same rule, and the one no search for a forbidden word can express. +// +// « openscale config password » reads its secret off the standard input BECAUSE an argument +// would be readable in the process list. An installer that passed it as an option would +// break that promise from the one place the two scripts hand over to the binary — and every +// bench above would stay green, because no forbidden word would appear on the line. +func TestTheInstallerFeedsTheAdministrationPasswordOnTheStandardInput(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + call := strings.Index(installer, "config password") + if call < 0 { + t.Fatal("install.ps1 n'appelle plus « openscale config password » : le poste sort de " + + "l'installation sans mot de passe d'administration") + } + line := strings.LastIndex(installer[:call], "\n") + // The pipe is on the line BEFORE the call: the guarded form of §15.2 demands that the + // native call open its own line, so the value being piped opens the previous one. + previous := installer[strings.LastIndex(installer[:line], "\n")+1 : line] + if !strings.Contains(previous, "|") { + t.Errorf("le mot de passe d'administration n'est pas poussé par un tube dans "+ + "« config password » :\n %s", strings.TrimSpace(previous)) + } + for _, secret := range secretParametersOfTheBootstrap(t) { + argument := regexp.MustCompile(`config password[^\n]*\$` + regexp.QuoteMeta(secret)) + if argument.MatchString(installer) { + t.Errorf("install.ps1 passe -%s en ARGUMENT à « config password » : la liste des "+ + "processus le donnerait à tout le monde", secret) + } + } + // And the encoding of that pipe is settled before it is used. Without it, $OutputEncoding + // is us-ascii under Windows PowerShell 5.1 and an accented password is hashed as « ? ». + if !strings.Contains(installer, "Set-NativeOutputEncoding") { + t.Error("install.ps1 ne règle pas l'encodage des tubes natifs : un mot de passe " + + "accentué partirait en « ? » vers le binaire") + } +} + // TestTheBootstrapAsksTheFloorInsteadOfSpellingIt. // // Four characters is the arbitrage of common.ps1, and Resolve-AccountPassword is its @@ -169,6 +253,10 @@ func TestTheBootstrapAsksTheFloorInsteadOfSpellingIt(t *testing.T) { // interactive path elevates itself and asks AFTERWARDS, in the elevated window; the // scripted path demands an already-elevated console. This is not a limitation to lift // later: it is the choice of never writing a secret into an argv. +// +// EVERY secret, and the list comes from the param block rather than from this file: the +// version of this bench that spelled « AccountPassword » stayed green when a second +// password parameter arrived, which is precisely the day it had a job to do. func TestTheBootstrapRefusesToElevateWithASecretOnTheCommandLine(t *testing.T) { script := codeOnly(readFile(t, filepath.Join("windows", bootstrapPath))) elevation := strings.Index(script, "Start-Process") @@ -176,9 +264,25 @@ func TestTheBootstrapRefusesToElevateWithASecretOnTheCommandLine(t *testing.T) { t.Fatalf("%s ne se relève jamais en administrateur", bootstrapPath) } // The guard is BEFORE the relaunch, or it guards nothing. - if !strings.Contains(script[:elevation], "AccountPassword") { - t.Errorf("%s se relève en administrateur sans avoir vérifié qu'aucun mot de passe "+ - "ne lui a été passé : le secret partirait sur la ligne de commande", bootstrapPath) + for _, secret := range secretParametersOfTheBootstrap(t) { + if !strings.Contains(script[:elevation], secret) { + t.Errorf("%s se relève en administrateur sans avoir vérifié qu'aucun -%s ne lui a "+ + "été passé : le secret partirait sur la ligne de commande", bootstrapPath, secret) + } + } + // And the argument list of that relaunch carries none of them, whatever the guard above + // says: an option appended to $arguments by reflex — the way -StationNumber legitimately + // was — is exactly how a guard gets outlived by the file it guards. + built := strings.Index(script, "$arguments = @(") + if built < 0 || built > elevation { + t.Fatalf("%s ne construit plus la ligne de commande de sa relance avant de l'exécuter", + bootstrapPath) + } + for _, secret := range secretParametersOfTheBootstrap(t) { + if strings.Contains(script[built:elevation], "-"+secret) { + t.Errorf("%s ajoute -%s aux arguments de la relance élevée : le secret serait "+ + "lisible dans la liste des processus", bootstrapPath, secret) + } } } diff --git a/deploy/installer_test.go b/deploy/installer_test.go index fab1a25..9b134f9 100644 --- a/deploy/installer_test.go +++ b/deploy/installer_test.go @@ -3,10 +3,12 @@ package deploy import ( "path/filepath" "regexp" + "strconv" "strings" "testing" "openscale/internal/platform" + "openscale/internal/web" ) // The installers read as PROCEDURES: the subcommands they call and the binary must carry, @@ -337,11 +339,18 @@ func TestAReinstallLeavesTheSheetInTheBinderTrue(t *testing.T) { "donc toute fiche déjà classée devient fausse\n %s", number+1, strings.TrimSpace(line)) } } - // Le plancher du mot de passe choisi est DÉCLARÉ, et il est délibérément plus bas que - // celui du mot de passe d'administration : le premier ouvre une session sans droits sur - // un poste en libre-service, le second donne le droit de changer le poste. Le banc - // PowerShell lit la constante et vérifie qu'elle tient ; ce qui est gardé ici, c'est - // qu'elle existe — sans elle, ce banc ne mesurerait rien. + // Le plancher du mot de passe CHOISI est DÉCLARÉ. Ce qu'il garde n'est pas le poste : + // c'est une session Windows SANS AUCUN DROIT, sur une machine en libre-service dont + // l'accès physique vaut déjà l'accès administrateur — le rendre difficile ne protège + // rien et rend le poste inaccessible le samedi. Le banc PowerShell lit la constante et + // vérifie qu'elle tient ; ce qui est gardé ici, c'est qu'elle existe — sans elle, ce + // banc ne mesurerait rien. + // + // Ce commentaire disait « délibérément plus bas que celui du mot de passe + // d'administration ». Les deux valent quatre depuis que web.MinPasswordLength est + // descendu, et l'écart avait disparu sans que rien ne devienne rouge : le banc défendait + // une comparaison morte, alors que ce qui justifie ce chiffre-là est ce que ce compte + // protège, et rien d'autre. common := readFile(t, filepath.Join("windows", "common.ps1")) if !regexp.MustCompile(`\$script:MinimumPasswordLength = \d+`).MatchString(common) { t.Fatal("common.ps1 ne déclare plus $script:MinimumPasswordLength : -AccountPassword " + @@ -349,6 +358,344 @@ func TestAReinstallLeavesTheSheetInTheBinderTrue(t *testing.T) { } } +// TestTheInstallerAppliesTheAdministrationFloorTheBinaryHolds ties one number written in +// PowerShell to the one Go compiles. +// +// install.ps1 refuses a too-short administration password BEFORE asking for the +// confirmation, which means it has to know the floor — and PowerShell cannot read a Go +// constant. The number is therefore recopied into common.ps1, ONCE, and this bench is what +// keeps the copy honest: the day the owner moves web.MinPasswordLength, the failure lands +// here rather than on a station, where the installer's question would have promised one +// length and the binary refused another, three lines later, mid-installation. +func TestTheInstallerAppliesTheAdministrationFloorTheBinaryHolds(t *testing.T) { + common := readFile(t, filepath.Join("windows", "common.ps1")) + declared := regexp.MustCompile(`\$script:MinimumAdminPasswordLength = (\d+)`). + FindStringSubmatch(common) + if declared == nil { + t.Fatal("common.ps1 ne déclare plus $script:MinimumAdminPasswordLength : la question " + + "de l'installeur n'aurait plus de plancher à annoncer") + } + if declared[1] != strconv.Itoa(web.MinPasswordLength) { + t.Errorf("common.ps1 tient le plancher d'administration pour %s, web.MinPasswordLength "+ + "vaut %d : l'installeur accepterait ce que le binaire refuse, ou l'inverse", + declared[1], web.MinPasswordLength) + } + + // Et il est LU là où la question se pose, plutôt que réécrit une seconde fois. + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + if !strings.Contains(installer, "$script:MinimumAdminPasswordLength") { + t.Error("install.ps1 ne lit pas $script:MinimumAdminPasswordLength : il refuse donc " + + "selon un chiffre qui lui est propre") + } + if regexp.MustCompile(`MinimumAdminPasswordLength\s*=\s*\d`).MatchString(installer) { + t.Error("install.ps1 redéclare le plancher d'administration : common.ps1 le porte déjà") + } +} + +// TestTheInstallerAsksForWhatOnlyItCanKnow is CHANTIER D, read as a procedure. +// +// A station used to come out of install.ps1 with three faults — no number, no name, a scale +// naming a protocol without a port — and the way to repair them went through a screen that +// asks for the recovery code of a sheet just filed away in the shop's binder. The three +// questions are asked here, and what they answer is written through the binary, which is +// the only thing able to hash a password and to judge a station number. +func TestTheInstallerAsksForWhatOnlyItCanKnow(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + for what, needle := range map[string]string{ + "la pose de l'identité du poste": "config station", + "le numéro": "--number", + "le nom": "--name", + "la balance d'un poste neuf": "--no-scale", + "la pose du mot de passe": "config password", + "la saisie masquée et confirmée": "Read-ConfirmedSecret", + "la remise du numéro à la fiche": "-StationNumber $sheetNumber", + "la remise de l'état de la balance": "-ScaleDisabled $scaleWasDisabled", + "la remise du mot de passe à la fiche": "-AdminPasswordPosed $adminPasswordPosed", + } { + if !strings.Contains(installer, needle) { + t.Errorf("install.ps1 ne fait pas %s (« %s » absent)", what, needle) + } + } + + // LA BALANCE N'EST ÉTEINTE QUE SUR UN POSTE NEUF. Relancer l'installeur est ce que + // TROUBLESHOOTING.md recommande sur un poste qui marche : un --no-scale inconditionnel + // y couperait la balance d'un poste en service, et le poste passerait en saisie manuelle + // un samedi matin sans que personne comprenne pourquoi. + lines := strings.Split(installer, "\n") + for number, line := range lines { + if !strings.Contains(line, "--no-scale") { + continue + } + if !strings.Contains(line, "$configIsNew") { + t.Errorf("install.ps1 ligne %d : --no-scale n'est pas conditionné à un poste neuf — "+ + "une réinstallation éteindrait la balance d'un poste en service\n %s", + number+1, strings.TrimSpace(line)) + } + } +} + +// TestAStationNumberOfZeroReachesTheBinaryInsteadOfBeingSwallowed. +// +// « 0 » IS AN ANSWER, and a wrong one: the binary is the one that says so, in French, at +// control 1 of §11.3 — it refuses, it writes nothing, and install.ps1 asks again. PowerShell +// holds the integer 0 for FALSE, so deciding to pass --number on the VALUE dropped the option +// altogether: the bounds never saw the answer, the station kept station.number 0 — factory +// configuration — and the log announced « identité du poste posée » over the top of it. +// +// cmd/openscale/config.go carries the same trap on the Go side and names it: « --number 0 +// and no --number at all are the same integer ». It reads which options were TYPED. This is +// the PowerShell half of that sentence. +func TestAStationNumberOfZeroReachesTheBinaryInsteadOfBeingSwallowed(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + lines := strings.Split(installer, "\n") + + // L'entier ne sert JAMAIS de condition : c'est la forme exacte du défaut, et la seule + // que la relecture d'un diff laisse passer, parce qu'elle se lit comme du français. + asACondition := regexp.MustCompile(`(?:-not|-and|-or|if\s*\(|while\s*\()\s*\$StationNumber\b`) + for number, line := range lines { + if asACondition.MatchString(line) { + t.Errorf("install.ps1 ligne %d : $StationNumber décide d'une branche, et PowerShell "+ + "tient 0 pour faux — le numéro 0 saisi à l'invite serait avalé en silence, et le "+ + "poste repartirait en configuration d'usine\n %s", + number+1, strings.TrimSpace(line)) + } + } + + // Ce qui part au binaire est la RÉPONSE, reconnue à n'être pas vide — « 0 » en est une. + number, name := theAnswersTheInstallerCarries(t, installer) + added := -1 + for index, line := range lines { + if strings.Contains(line, "'--number'") { + added = index + } + } + if added < 0 { + t.Fatal("install.ps1 ne passe plus « --number » : ce banc ne mesure plus rien") + } + decided := false + for lookback := added; lookback >= 0 && lookback >= added-3; lookback-- { + if strings.Contains(lines[lookback], number+" -ne ''") { + decided = true + break + } + } + if !decided { + t.Errorf("install.ps1 ligne %d : --number n'est pas décidé sur une réponse DONNÉE "+ + "(« %s -ne '' » absent) — la véracité de l'entier appartient au binaire, qui la "+ + "refuse en français\n %s", added+1, number, strings.TrimSpace(lines[added])) + } + + // Et c'est bien cette variable-là que l'invite remplit : sans ce lien, la règle + // ci-dessus tiendrait sur une variable qui ne porte pas ce qui a été tapé. + prompt := -1 + for index, line := range lines { + if strings.Contains(line, "Read-Host ' Numéro'") { + prompt = index + } + } + if prompt < 0 { + t.Fatal("install.ps1 ne demande plus le numéro du poste : ce banc ne mesure plus rien") + } + filled := false + for lookahead := prompt; lookahead < len(lines) && lookahead <= prompt+4; lookahead++ { + if strings.Contains(lines[lookahead], number+" = ") { + filled = true + break + } + } + if !filled { + t.Errorf("install.ps1 ligne %d : l'invite ne remplit pas %s, la variable que le binaire "+ + "reçoit", prompt+1, number) + } + if name == "" { + t.Error("install.ps1 ne passe plus « --name » : ce banc ne mesure plus rien") + } +} + +// TestTheInstallerLogsWhatItReallyPosed. +// +// The warning « identité du poste NON posée » was written for ONE situation — a SCRIPTED +// installation of a NEW station, where nobody was there to answer — and that is precisely +// the situation it could not reach: on a new station the argument list always carries +// `--no-scale`, so a list of zero arguments never existed. The log then wrote « identité du +// poste posée » on a station that had neither number nor name. +// +// What the journal says is therefore composed of what was TRANSMITTED. The size of the +// argument list is not that: it also counts a declaration about the scale. +func TestTheInstallerLogsWhatItReallyPosed(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + lines := strings.Split(installer, "\n") + + emptiness := regexp.MustCompile(`\$identity(?:\.Count)?\s*-(?:eq\s+0|lt\s+1)\b|-not\s+\$identity\b`) + for number, line := range lines { + if emptiness.MatchString(line) { + t.Errorf("install.ps1 ligne %d : une branche se décide sur une liste d'arguments VIDE, "+ + "qui n'existe pas sur un poste neuf — « --no-scale » y est toujours\n %s", + number+1, strings.TrimSpace(line)) + } + } + + posed := -1 + for index, line := range lines { + if strings.Contains(line, "Write-Step") && strings.Contains(line, "identité du poste posée") { + posed = index + } + } + if posed < 0 { + posed = -1 + for index, line := range lines { + if strings.Contains(line, "identité du poste posée") { + posed = index + } + } + } + if posed < 0 { + t.Fatal("install.ps1 n'annonce plus l'identité posée : ce banc ne mesure plus rien") + } + + // Les DEUX réponses sont sous les yeux de la phrase qui les annonce. Un message composé + // plus loin que ça se compose d'autre chose — et c'est ce qu'il faisait. + number, name := theAnswersTheInstallerCarries(t, installer) + region := strings.Join(lines[max(0, posed-12):posed+1], "\n") + for what, needle := range map[string]string{ + "la réponse du numéro": number, + "la réponse du nom": name, + } { + if needle == "" { + continue + } + if !strings.Contains(region, needle) { + t.Errorf("install.ps1 ligne %d : ce que le journal annonce ne se lit pas sur %s (%s) — "+ + "il annonce donc une identité posée que personne n'a donnée", posed+1, what, needle) + } + } +} + +// theAnswersTheInstallerCarries names the two variables install.ps1 hands to the binary. +// +// They are READ OUT of the script rather than spelled here: what the two benches above hold +// is where the decision is taken, not what somebody called the variable. +func theAnswersTheInstallerCarries(t *testing.T, installer string) (number, name string) { + t.Helper() + for option, into := range map[string]*string{"--number": &number, "--name": &name} { + if found := regexp.MustCompile(`'` + option + `',\s*"?(\$\w+)"?`). + FindStringSubmatch(installer); found != nil { + *into = found[1] + } + } + return number, name +} + +// TestTheFourthDoorCountsCodePointsLikeTheOtherThree. +// +// Three doors set an administration password — the recovery form, `openscale config +// password`, and the installer's own question — and this lot made the UNIT COUNTED its +// subject: web.MinPasswordLength is « counted in CODE POINTS and not in bytes », with a +// bench on each Go side. PowerShell's `.Length` counts UTF-16 units, which is a third unit +// again: « 𝄞 » is one code point, two of those units. +// +// The gap only shows outside the basic multilingual plane, so the cost is low and the point +// is the coherence: a station must not accept at its installation what it refuses at its +// screen, on a floor whose whole justification is what a volunteer types with a queue behind +// them. +func TestTheFourthDoorCountsCodePointsLikeTheOtherThree(t *testing.T) { + floorInUTF16 := regexp.MustCompile(`\.Length\s*-lt\s*\$(?:script:)?Minimum`) + for _, name := range []string{"install.ps1", "common.ps1"} { + script := codeOnly(readFile(t, filepath.Join("windows", name))) + for number, line := range strings.Split(script, "\n") { + if floorInUTF16.MatchString(line) { + t.Errorf("%s ligne %d : le plancher est comparé à un .Length, qui compte des "+ + "unités UTF-16 là où le binaire compte des points de code\n %s", + name, number+1, strings.TrimSpace(line)) + } + } + if !strings.Contains(script, "Measure-CodePoint") { + t.Errorf("%s ne compte pas avec Measure-CodePoint : les deux endroits qui appliquent "+ + "le plancher en PowerShell compteraient chacun le leur", name) + } + } + + // Et la fonction est MESURÉE plutôt que relue : ce qu'elle compte ne se voit pas dans son + // texte, il se voit sur un caractère hors du plan multilingue de base. + common, err := filepath.Abs(filepath.Join("windows", "common.ps1")) + if err != nil { + t.Fatalf("chemin de common.ps1 : %v", err) + } + for _, shell := range powershellPaths(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + harness := filepath.Join(t.TempDir(), "codepoints.ps1") + // U+1D11E, la clé de sol : deux unités UTF-16, UN point de code. Elle est + // construite plutôt qu'écrite, pour que ce banc mesure le comptage et non + // l'encodage du fichier qui le porte. + writeScript(t, harness, `$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. `+quoteForPowerShell([]string{common})+` +$typed = [char]::ConvertFromUtf32(0x1D11E) + 'ab' +Write-Output "UTF16=$($typed.Length)" +Write-Output "POINTS=$(Measure-CodePoint -Text $typed)" +`) + output, err := runPowerShell(t, shell, harness) + if err != nil { + t.Fatalf("le banc du comptage n'a pas tourné sous %s :\n%s", shell, output) + } + measured := measurementsOf(output) + if measured["UTF16"] != "4" { + t.Fatalf("prémisse fausse : « 𝄞ab » fait %q unités UTF-16 sous %s, ce banc ne "+ + "mesure donc pas l'écart qu'il cherche\n%s", + measured["UTF16"], filepath.Base(shell), output) + } + if measured["POINTS"] != "3" { + t.Errorf("Measure-CodePoint compte %q sur « 𝄞ab » : le binaire en compte 3, et "+ + "l'installeur accepterait ce que le poste refuse\n%s", measured["POINTS"], output) + } + }) + } +} + +// TestAScriptedInstallationNeverWaitsForAnybody. +// +// bootstrap.ps1 -Yes, un déploiement à distance, une tâche planifiée : aucun de ces trois +// chemins n'a d'humain devant lui, et une invite y attend jusqu'à ce que quelqu'un +// s'aperçoive que le poste n'est toujours pas installé. C'est une régression grave et +// silencieuse — l'installation ne rate pas, elle ne finit pas. +func TestAScriptedInstallationNeverWaitsForAnybody(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + if !strings.Contains(installer, "Test-Interactive") { + t.Error("install.ps1 ne regarde pas s'il a quelqu'un devant lui : une installation " + + "scriptée resterait bloquée sur une invite") + } + if !strings.Contains(installer, "$askable") { + t.Fatal("install.ps1 ne décide plus s'il peut poser une question : ce banc ne sait " + + "plus quoi vérifier") + } + // Toute invite passe par ce verrou. Read-Host et Read-ConfirmedSecret sont les deux + // seules façons d'attendre quelqu'un dans ce script. + lines := strings.Split(installer, "\n") + owner := owningScopes(lines) + for number, line := range lines { + if !strings.Contains(line, "Read-Host") && !strings.Contains(line, "Read-ConfirmedSecret") { + continue + } + // Une invite écrite dans une fonction n'est pas exécutée par le corps du script : ce + // qui est jugé ici, c'est le déroulé de l'installation. + if owner[number] != "" { + continue + } + guarded := false + for lookback := number; lookback >= 0 && lookback >= number-12; lookback-- { + if strings.Contains(lines[lookback], "$askable") { + guarded = true + break + } + } + if !guarded { + t.Errorf("install.ps1 ligne %d : une invite qu'aucun $askable ne garde — une "+ + "installation scriptée s'y arrêterait pour toujours\n %s", + number+1, strings.TrimSpace(line)) + } + } +} + // TestTheUpdaterVerifiesHealthzAndRestoresOnFailure reads update.ps1 for the four things // §15.5 requires of it. func TestTheUpdaterVerifiesHealthzAndRestoresOnFailure(t *testing.T) { diff --git a/deploy/linux/bootstrap.sh b/deploy/linux/bootstrap.sh index d5c61ba..8438eae 100644 --- a/deploy/linux/bootstrap.sh +++ b/deploy/linux/bootstrap.sh @@ -30,23 +30,54 @@ # de mise à jour, et TROUBLESHOOTING.md enverrait un bénévole chercher un fichier # disparu. # -# CE SCRIPT NE POSE AUCUNE QUESTION, et ce n'est pas une simplification : les trois -# questions de la version Windows n'ont pas d'équivalent ici. Le compte openscale n'a ni mot -# de passe ni shell, il n'y a pas de mode pilote — l'application Access ne tourne pas sous -# Linux — et le kiosque est une unité systemd qu'install.sh active toujours. Rien à -# demander, donc rien à lire : sous « curl … | sh », l'entrée standard EST le script, et un -# « read » y avalerait la suite du fichier au lieu d'attendre un humain. +# CE SCRIPT NE POSE AUCUNE QUESTION LUI-MÊME, et la raison est technique : sous +# « curl … | sh », l'entrée standard EST le script, et un « read » y avalerait la suite du +# fichier au lieu d'attendre un humain. +# +# Les six questions de la version Windows se partagent alors en deux, et le partage n'est +# plus celui qui était écrit ici : +# +# CE QUI N'A TOUJOURS RIEN À DEMANDER — le mot de passe du compte, qui n'existe pas (le +# compte openscale n'a ni mot de passe ni interpréteur de commandes), le mode pilote, qui +# n'a pas d'objet (l'application Access ne tourne pas sous Linux), et l'ouverture de +# session automatique, que le kiosque n'attend pas (c'est une unité systemd qu'install.sh +# active toujours). +# +# CE QUI EN A, ET QUE CE SCRIPT NE FAIT QUE RELAYER — le numéro de ce poste, son nom et +# son mot de passe d'administration. install.sh les demande LUI-MÊME quand son entrée +# standard est un terminal, ce qui est le cas d'une installation lancée depuis une clé +# USB et jamais celui d'un « curl … | sh ». Ce script ne lui coupe donc pas la parole : il +# lui passe ses options et le laisse décider. +# +# COMMENT ON DONNE CES TROIS VALEURS À UN BOOTSTRAP : +# +# curl -fsSL | sudo sh -s -- --station-number 2 --station-name 'Poste 2 - fruits' +# +# Et pour le mot de passe d'administration, par l'ENVIRONNEMENT plutôt que par une +# option : /proc//cmdline est lisible par tous les comptes de la machine, +# /proc//environ par le seul propriétaire du processus. +# +# curl -fsSL | sudo OPENSCALE_ADMIN_PASSWORD='...' sh -s -- --yes --station-number 2 +# +# ★ ET C'EST PAR L'ENVIRONNEMENT QUE LE SECRET DESCEND VERS install.sh, même quand il est +# arrivé ici par --admin-password : le relayer en argument le publierait une seconde fois, +# dans la ligne de commande d'un processus lancé en root. C'est la règle de bootstrap.ps1, +# qui appelle install.ps1 DANS LE MÊME PROCESSUS pour la même raison. # # POUR UN POSTE SANS INTERNET, rien de tout cela ne sert : l'archive se copie sur une clé # USB et install.sh se lance seul, comme avant. Voir INSTALLATION.md. # -# Options : +# Options de ce script : # --version vX installe ce tag au lieu de la dernière release (aligner un poste sur # les autres, ou revenir en arrière) # --force met à jour même si le poste porte déjà cette version # --force-install relance install.sh sur un poste installé, au lieu d'update.sh — le # geste de réparation de TROUBLESHOOTING.md # +# Options relayées telles quelles à install.sh, et à lui seul (une mise à jour ne +# reconfigure rien) : --station-number, --station-name, --admin-password, --yes, +# --install-dir, --data-dir. +# # /bin/sh et non bash : Debian minimale a dash, et un script d'installation n'est pas # l'endroit où découvrir qu'un shell manque. @@ -66,6 +97,23 @@ FORCE=0 FORCE_INSTALL=0 WANTED_VERSION='' +# CE QUI DESCEND VERS install.sh, une variable par option plutôt qu'une liste accumulée à +# la volée. sh POSIX n'a qu'un seul tableau — les paramètres positionnels —, et le remplir +# pendant qu'on le parcourt fait relire à la boucle ce qu'elle vient d'y ajouter. Les +# reconstruire au moment de l'appel, comme install.sh reconstruit les siens, coûte six noms +# recopiés et se relit sans réfléchir. +# +# ADMIN_PASSWORD accepte aussi l'environnement, et la variable est RETIRÉE de +# l'environnement tout de suite : sans cela, curl, unzip et sha256sum en hériteraient tous +# dans leur propre /proc//environ. +ADMIN_PASSWORD=${OPENSCALE_ADMIN_PASSWORD:-} +unset OPENSCALE_ADMIN_PASSWORD +STATION_NUMBER='' +STATION_NAME='' +INSTALL_DIR='' +DATA_DIR='' +ASSUME_YES=0 + log() { printf ' %s\n' "$1"; } fail() { printf 'bootstrap.sh : %s\n' "$1" >&2; exit 1; } @@ -77,6 +125,15 @@ usage() { printf ' --version vX installe ce tag au lieu de la dernière release\n' printf ' --force met à jour même si le poste porte déjà cette version\n' printf ' --force-install relance install.sh au lieu de update.sh, pour réparer un poste\n' + printf '\n Relayées à install.sh, sur une installation seulement :\n' + printf ' --station-number le numéro de ce poste dans la coopérative\n' + printf ' --station-name le nom que lisent les bénévoles\n' + printf ' --admin-password le mot de passe d'"'"'administration. Préférez la variable\n' + printf ' OPENSCALE_ADMIN_PASSWORD : une ligne de commande est\n' + printf ' lisible par tout utilisateur de la machine\n' + printf ' --yes ne pose aucune question et n'"'"'attend personne\n' + printf ' --install-dir le répertoire du binaire\n' + printf ' --data-dir le répertoire des données du poste\n' } # fetch rend le corps de la réponse sur la sortie standard ; download l'écrit dans un @@ -111,6 +168,37 @@ while [ $# -gt 0 ]; do WANTED_VERSION="$1" ;; --version=*) WANTED_VERSION="${1#--version=}" ;; + --station-number) + shift + [ $# -gt 0 ] || fail 'option --station-number : le numéro manque' + STATION_NUMBER="$1" + ;; + --station-number=*) STATION_NUMBER="${1#--station-number=}" ;; + --station-name) + shift + [ $# -gt 0 ] || fail 'option --station-name : le nom manque' + STATION_NAME="$1" + ;; + --station-name=*) STATION_NAME="${1#--station-name=}" ;; + --admin-password) + shift + [ $# -gt 0 ] || fail 'option --admin-password : le mot de passe manque' + ADMIN_PASSWORD="$1" + ;; + --admin-password=*) ADMIN_PASSWORD="${1#--admin-password=}" ;; + --install-dir) + shift + [ $# -gt 0 ] || fail 'option --install-dir : le répertoire manque' + INSTALL_DIR="$1" + ;; + --install-dir=*) INSTALL_DIR="${1#--install-dir=}" ;; + --data-dir) + shift + [ $# -gt 0 ] || fail 'option --data-dir : le répertoire manque' + DATA_DIR="$1" + ;; + --data-dir=*) DATA_DIR="${1#--data-dir=}" ;; + --yes) ASSUME_YES=1 ;; -h|--help) usage exit 0 @@ -247,9 +335,87 @@ log "décompressé dans $EXTRACTED" printf '\n' if [ -x "$BINARY" ] && [ -f "$UNIT" ] && [ "$FORCE_INSTALL" -eq 0 ]; then log "poste déjà installé en ${INSTALLED_VERSION:-version inconnue} : mise à jour vers $TAG" + # Les options d'identité ne descendent PAS ici, et ce n'est pas un oubli : une mise à jour + # ne reconfigure rien. Un poste qui reçoit une version nouvelle garde son numéro, son nom + # et son mot de passe — les redemander à chaque mise à jour serait la meilleure façon + # d'en changer un par accident, un mardi, sur un poste qui marchait. sh "$EXTRACTED/update.sh" else - sh "$EXTRACTED/install.sh" + # ★ CE SCRIPT VIT SUR main, LES ARCHIVES SONT FIGÉES À LEUR TAG. --version installe une + # release antérieure, dont l'install.sh ne connaît pas forcément les options + # d'aujourd'hui, et une option inconnue y est un refus net — APRÈS le téléchargement, + # l'empreinte et la décompression, c'est-à-dire au pire moment pour perdre un bénévole. + # On la retire, et on dit ce que sa perte coûte : « elle est ignorée » laisserait + # quelqu'un devant un poste qui ne se comporte pas comme la notice. + set -- + if [ -n "$STATION_NUMBER" ]; then + set -- "$@" --station-number "$STATION_NUMBER" + fi + if [ -n "$STATION_NAME" ]; then + set -- "$@" --station-name "$STATION_NAME" + fi + if [ -n "$INSTALL_DIR" ]; then + set -- "$@" --install-dir "$INSTALL_DIR" + fi + if [ -n "$DATA_DIR" ]; then + set -- "$@" --data-dir "$DATA_DIR" + fi + if [ "$ASSUME_YES" -eq 1 ]; then + set -- "$@" --yes + fi + # La liste tourne sur elle-même : chaque option est retirée en tête et remise en queue si + # elle survit. Le compteur est ce qui borne le tour — sans lui, la boucle relirait sans + # fin ce qu'elle vient de remettre. + INSTALLER="$EXTRACTED/install.sh" + ROUNDS=$# + while [ "$ROUNDS" -gt 0 ]; do + OPTION="$1" + shift + ROUNDS=$((ROUNDS - 1)) + VALUE='' + HAS_VALUE=0 + case "$OPTION" in + --yes) ;; + *) + VALUE="$1" + shift + ROUNDS=$((ROUNDS - 1)) + HAS_VALUE=1 + ;; + esac + # L'étiquette du « case » de install.sh, et non le nom seul : « --station-number » se + # lit aussi dans sa prose, qui survit à une version qui n'aurait plus l'option. + if grep -q -F -- "$OPTION)" "$INSTALLER"; then + if [ "$HAS_VALUE" -eq 1 ]; then + set -- "$@" "$OPTION" "$VALUE" + else + set -- "$@" "$OPTION" + fi + continue + fi + log "la version $TAG ne connaît pas l'option $OPTION : elle est ignorée." + case "$OPTION" in + --station-number) log "le numéro du poste se posera sur l'écran d'administration." ;; + --station-name) log "le nom du poste se posera sur l'écran d'administration." ;; + --install-dir|--data-dir) log "les emplacements par défaut seront employés." ;; + --yes) log "l'installeur posera ses questions s'il a quelqu'un devant lui." ;; + esac + done + + # ★ LE SECRET NE PASSE PAS PAR LA LIGNE DE COMMANDE. Il descend par l'ENVIRONNEMENT du + # seul processus qui en a besoin : /proc//cmdline est lisible par tous les comptes de + # la machine, /proc//environ par le seul propriétaire du processus. C'est l'équivalent + # de l'appel « dans le même processus » de bootstrap.ps1, qui refuse pour cette raison + # exacte de s'élever quand on lui a donné un mot de passe. + if [ -n "$ADMIN_PASSWORD" ] && ! grep -q -F 'OPENSCALE_ADMIN_PASSWORD' "$INSTALLER"; then + ADMIN_PASSWORD='' + log "la version $TAG ne lit pas OPENSCALE_ADMIN_PASSWORD : le mot de passe d'administration se posera à l'écran, avec le code de secours de la fiche." + fi + if [ -n "$ADMIN_PASSWORD" ]; then + OPENSCALE_ADMIN_PASSWORD="$ADMIN_PASSWORD" sh "$EXTRACTED/install.sh" "$@" + else + sh "$EXTRACTED/install.sh" "$@" + fi fi # --- 7. Les scripts survivent à l'installation ---------------------------------------- diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh index a302f2b..0506eb3 100644 --- a/deploy/linux/install.sh +++ b/deploy/linux/install.sh @@ -2,41 +2,450 @@ # Installe un poste de pesée OpenScale sur une Debian 12 minimale — §15.3. # # sudo ./install.sh +# sudo ./install.sh --station-number 2 --station-name 'Poste 2 - fruits' +# sudo OPENSCALE_ADMIN_PASSWORD='...' ./install.sh --yes --station-number 2 \ +# --station-name 'Poste 2 - fruits' +# +# Ce que le script fait, dans cet ordre : # -# Ce que le script fait : # 1. installe cage, chromium et seatd — le kiosque Wayland mono-application ; # 2. crée le compte openscale, dans les groupes dialout (SÉRIE) et lp (IMPRIMANTE) ; -# 3. pose le binaire, les répertoires et les deux unités systemd ; -# 4. pose les règles udev qui donnent au port série un nom STABLE ; -# 5. active et démarre le service, puis vérifie /healthz ; -# 6. écrit la fiche d'installation. +# 3. pose le binaire, les répertoires, la documentation et la configuration livrée ; +# 4. POSE CE QUE SEULE L'INSTALLATION PEUT DEMANDER — le numéro de ce poste, son nom et +# son mot de passe d'administration — et déclare la balance ABSENTE sur un poste NEUF. +# Sans cette étape, le poste sortait de l'installeur en configuration d'usine, avec +# trois fautes, et sans autre porte vers ses réglages que le code de secours de la +# fiche qu'on venait justement de ranger au classeur ; +# 5. tire le CODE DE SECOURS quand le fichier n'en porte pas déjà un, et l'imprime sur la +# fiche : c'est lui, et lui seul, qui rouvre un poste qui n'a AUCUN mot de passe ; +# 6. pose les règles udev et polkit, puis les deux unités systemd ; +# 7. active et démarre le service, puis vérifie /healthz ; +# 8. écrit la fiche d'installation. # # Il est IDEMPOTENT : le relancer sur un poste déjà installé remet tout en place sans rien -# casser. +# casser. C'est le geste que TROUBLESHOOTING.md recommande sur un poste qui marche mal, et +# c'est lui qui décide de deux règles d'ici : la balance n'est déclarée absente que sur un +# poste NEUF — l'éteindre sur un poste en service le ferait passer en saisie manuelle un +# samedi matin —, et le code de secours n'est JAMAIS renouvelé, parce que la fiche déjà +# rangée au classeur doit rester vraie. +# +# LES QUESTIONS, ET QUAND ELLES SE POSENT +# +# Une valeur qui manque est DEMANDÉE quand il y a quelqu'un devant ce script ; sinon +# l'installation continue sans bloquer et le journal dit ce qui n'a pas été posé. +# +# ★ « QUELQU'UN DEVANT CE SCRIPT » SE TESTE PAR « [ -t 0 ] », ET C'EST CE TEST-LÀ QUI +# COMPTE. Sous « curl … | sh », l'entrée standard EST le script : un « read » n'y attend +# personne, il avale la suite du fichier et l'installation part en morceaux. L'entrée +# attachée à un terminal est donc la question à poser, et --yes est le choix explicite de +# n'en poser aucune. C'est l'équivalent exact de Test-Interactive côté Windows, dont les +# deux moitiés utiles — UserInteractive et IsInputRedirected — se ramènent ici à ce seul +# test : un shell n'a pas de station de fenêtres, et il n'a pas non plus de commutateur +# -NonInteractive que la troisième moitié va chercher sur la ligne de commande. +# +# Le mot de passe se tape SANS ÉCHO et se confirme (ask_secret, plus bas). +# +# CE QUI N'A DÉLIBÉRÉMENT PAS D'ÉQUIVALENT DE CE CÔTÉ-CI +# +# Trois options de install.ps1 ne sont pas ici, et leur absence est un arbitrage plutôt +# qu'un retard. Les nommer coûte trois lignes ; les taire coûte la question à chaque +# relecture, et la règle du dépôt est qu'une modification validée sur l'un des deux +# installeurs se reporte sur l'autre — donc que ce qui ne se reporte PAS s'écrive. +# +# -AccountPassword : le compte openscale de ce poste n'a ni mot de passe ni interpréteur +# de commandes (useradd --shell /usr/sbin/nologin, aucun --password), il n'ouvre aucune +# session et rien ne se tape en son nom. Il n'y a pas de secret à choisir. +# -SkipAutoLogon : Windows a besoin d'une ouverture de session automatique parce que son +# kiosque est une tâche déclenchée par une session. Ici l'écran client est une unité +# systemd (openscale-kiosk.service, WantedBy=multi-user.target, PAMName=login) : elle +# démarre à l'allumage de la machine, sans que personne ouvre de session. Il n'y a donc +# rien à activer, et rien à sauter. Un poste qui ne serait PAS en libre-service — +# l'unique usage de -SkipAutoLogon — se règle après coup en une commande : +# « systemctl disable --now openscale-kiosk.service ». +# -Pilot : ce mode existe pour la période pilote de L9, qui laisse l'application Access +# relançable en moins de deux minutes ; il installe pour cela le service en démarrage +# manuel. Access est une application Windows et ne tourne pas ici : il n'y a aucun +# ancien poste à laisser reprendre la main sur cette machine. # # /bin/sh et non bash : Debian minimale a dash, et un script d'installation n'est pas -# l'endroit où découvrir qu'un shell manque. +# l'endroit où découvrir qu'un shell manque. Pas de « read -s », pas de tableaux, pas de +# doubles crochets — dash refuse les trois. set -eu PRODUCT='OpenScale' SERVICE='openscale' ACCOUNT='openscale' -BINARY='/usr/local/bin/openscale' + +# LES EMPLACEMENTS LIVRÉS, sous leur propre nom, parce que deux choses en ont besoin : les +# valeurs par défaut ci-dessous, et la réécriture des unités quand --install-dir ou +# --data-dir a déplacé quelque chose (place_unit). Ce sont ceux que internal/platform/ +# paths.go compile et que les deux unités nomment en clair. +STOCK_BINARY='/usr/local/bin/openscale' +STOCK_DATA_DIR='/var/lib/openscale' + +BINARY="$STOCK_BINARY" +DATA_DIR="$STOCK_DATA_DIR" +# /etc/openscale n'est PAS déplaçable, et ce n'est pas un oubli : §11.1 sépare exprès ce +# qu'un opérateur édite et sauvegarde (/etc) de ce que le poste écrit (/var/lib). Une +# option --config-dir déferait cette séparation sur le poste qui l'emploierait, et c'est +# précisément ce que le document a tranché. Sous Windows la question ne se pose pas : la +# configuration et les données y vivent sous une même racine, d'où -DataRoot là-bas. CONFIG_DIR='/etc/openscale' -DATA_DIR='/var/lib/openscale' +CONFIG="$CONFIG_DIR/config.json" LOG_DIR='/var/log/openscale' DOC_DIR='/usr/share/doc/openscale' UNIT_DIR='/etc/systemd/system' HERE=$(cd "$(dirname "$0")" && pwd) +# Le plancher du mot de passe d'ADMINISTRATION, en points de code. +# +# CE CHIFFRE N'EST PAS D'ICI : l'autorité est web.MinPasswordLength, dans +# internal/web/argon2id.go, que l'écran de secours et « openscale config password » +# appliquent tous les deux. Il est recopié parce qu'un script shell ne sait pas lire une +# constante Go — common.ps1 le recopie pour la même raison et le dit dans les mêmes termes. +# Il sert à refuser AVANT la confirmation plutôt qu'après : le binaire refuse la même chose, +# mais une fois le mot de passe tapé deux fois. +MIN_ADMIN_PASSWORD_LENGTH=4 + log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; } +say() { printf '%s\n' "$1"; } +note() { printf '%s\n' "$1" >&2; } fail() { printf 'install.sh : %s\n' "$1" >&2; exit 1; } +usage() { + cat < le numéro de ce poste dans la coopérative. C'est de lui que + dérive le nom du fichier de catalogue surveillé, flv_.csv + --station-name le nom que lisent les bénévoles, « Poste 2 - fruits » + --admin-password le mot de passe d'administration du poste, celui qui donne le + droit d'en changer les réglages. ★ UN ARGUMENT DE LIGNE DE + COMMANDE EST LISIBLE PAR TOUT LE MONDE : /proc//cmdline + l'est pour tous les comptes de la machine, et « ps » l'affiche. + Préférez la variable d'environnement OPENSCALE_ADMIN_PASSWORD, + qui ne passe que par /proc//environ — lisible du seul + propriétaire du processus. Mieux encore : ne donnez rien, et + répondez à la question, qui n'écrit nulle part + +LE RESTE : + + --yes ne pose AUCUNE question et n'attend personne. Sans lui, une + entrée standard qui n'est pas un terminal est reconnue toute + seule et les questions sont sautées de la même façon + --install-dir le répertoire du binaire (défaut $(dirname "$STOCK_BINARY")) + --data-dir le répertoire des données du poste (défaut $STOCK_DATA_DIR) + --help affiche ceci + +Ce qui n'a pas été demandé est écrit sur la fiche d'installation comme restant à faire. +USAGE +} + +# --- Les options ---------------------------------------------------------------------- +# Une boucle « while … case » et non getopts : getopts ne connaît que les options d'UNE +# lettre, et les noms longs sont ce qui rend les deux installeurs relisibles l'un contre +# l'autre. Chaque nom est celui de son paramètre Windows, en minuscules-tirets : +# -StationNumber → --station-number, -AdminPassword → --admin-password, -Yes → --yes, +# -InstallDir → --install-dir. La correspondance est mécanique EXPRÈS : c'est ce qui permet +# de vérifier à l'œil qu'une modification validée d'un côté a bien été reportée de l'autre. +# +# Une seule ne l'est pas, et c'est délibéré : -DataRoot devient --data-dir. Sous Windows, +# DataRoot est la racine qui contient à la fois config.json et data\ ; ici il n'existe +# aucun répertoire qui contienne les deux (§11.1, /etc et /var/lib), donc « racine » ne +# nommerait rien. +ADMIN_PASSWORD=${OPENSCALE_ADMIN_PASSWORD:-} +ADMIN_PASSWORD_FROM_ARGV=0 +# ★ RETIRÉE DE L'ENVIRONNEMENT TOUT DE SUITE. Sans cela, apt-get, useradd, systemctl et le +# binaire lui-même hériteraient tous du secret dans leur propre /proc//environ. Une +# variable de shell, elle, ne quitte pas ce processus. +unset OPENSCALE_ADMIN_PASSWORD +STATION_NUMBER='' +STATION_NAME='' +ASSUME_YES=0 + +while [ $# -gt 0 ]; do + case "$1" in + --admin-password) + shift + [ $# -gt 0 ] || fail "option --admin-password : le mot de passe manque" + ADMIN_PASSWORD="$1" + ADMIN_PASSWORD_FROM_ARGV=1 + ;; + --admin-password=*) + ADMIN_PASSWORD="${1#--admin-password=}" + ADMIN_PASSWORD_FROM_ARGV=1 + ;; + --station-number) + shift + [ $# -gt 0 ] || fail "option --station-number : le numéro manque" + STATION_NUMBER="$1" + ;; + --station-number=*) STATION_NUMBER="${1#--station-number=}" ;; + --station-name) + shift + [ $# -gt 0 ] || fail "option --station-name : le nom manque" + STATION_NAME="$1" + ;; + --station-name=*) STATION_NAME="${1#--station-name=}" ;; + --install-dir) + shift + [ $# -gt 0 ] || fail "option --install-dir : le répertoire manque" + BINARY="$1/openscale" + ;; + --install-dir=*) BINARY="${1#--install-dir=}/openscale" ;; + --data-dir) + shift + [ $# -gt 0 ] || fail "option --data-dir : le répertoire manque" + DATA_DIR="$1" + ;; + --data-dir=*) DATA_DIR="${1#--data-dir=}" ;; + --yes) ASSUME_YES=1 ;; + -h|--help) + usage + exit 0 + ;; + *) fail "option inconnue : $1 (« install.sh --help » les liste)" ;; + esac + shift +done + +# QUI PEUT ENCORE RÉPONDRE, décidé ici parce que cela ne dépend que des options et du +# terminal. Les deux cas restent distincts parce qu'ils n'ont pas la même cause : --yes est +# un CHOIX, une entrée standard qui n'est pas un terminal est un FAIT — un tube, un fichier +# de réponses, ou le corps de ce script sous « curl … | sh ». +ASKABLE=0 +if [ "$ASSUME_YES" -eq 0 ] && [ -t 0 ]; then ASKABLE=1; fi + +# Les unités livrées sont-elles encore vraies telles quelles ? place_unit s'en sert. +MOVED=0 +if [ "$BINARY" != "$STOCK_BINARY" ] || [ "$DATA_DIR" != "$STOCK_DATA_DIR" ]; then MOVED=1; fi + +# --- Les fonctions que les questions et la fiche emploient ------------------------------ + +# count_code_points rend le nombre de POINTS DE CODE d'un texte, celui que le binaire +# compte. +# +# ★ « ${#chaine} » COMPTERAIT DES OCTETS. Mesuré sous dash : « éàçü » y vaut 8, et un mot de +# passe de quatre lettres accentuées passerait donc un plancher de quatre sans le valoir — +# alors que web.MinPasswordLength dit « counted in CODE POINTS and not in bytes », que +# l'écran d'administration et « openscale config password » comptent l'un et l'autre des +# points de code, et que common.ps1 a écrit Measure-CodePoint pour la même raison. Une +# cinquième porte qui compterait des octets accepterait à l'installation ce que le poste +# refuse ensuite, sans que rien nulle part ne dise pourquoi. +# +# En UTF-8, les octets de continuation sont 0x80–0xBF et JAMAIS le premier octet d'un +# caractère : les retirer laisse exactement un octet par point de code. LC_ALL=C est ce qui +# garantit à tr de raisonner en octets plutôt qu'en caractères de la locale du poste. +count_code_points() { + printf '%s' "$1" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d ' ' +} + +# config_field rend une valeur d'un bloc de premier niveau de config.json, ou rien. +# +# Le fichier a DEUX formes et les deux doivent se lire : celle qui est LIVRÉE, écrite à la +# main, où le bloc « station » tient sur une seule ligne ; et celle que le binaire réécrit — +# json.MarshalIndent avec deux espaces —, où chaque clé a la sienne. Ce qu'elles ont en +# commun est l'indentation de DEUX espaces des clés de premier niveau, et c'est là-dessus +# que la sortie du bloc se décide : « name » existe aussi dans les catégories du catalogue, +# et un grep nu y répondrait « Fruits ». +# +# Les échappements rendus sont ceux que l'encodeur de Go produit : \" et \\, plus les trois +# \u qu'il pose systématiquement sur &, < et >. Tout autre \u devient « ? » — ce serait un +# fichier que personne d'ici n'a écrit, et un point d'interrogation sur la fiche se voit, +# alors qu'une séquence recopiée telle quelle passerait pour le nom du poste. +config_field() { + if [ ! -f "$1" ]; then + return 0 + fi + awk -v block="\"$2\"" -v key="\"$3\"" ' + BEGIN { inside = 0 } + { + line = $0 + if (inside == 0) { + at = index(line, block ":") + if (at == 0) { next } + inside = 1 + line = substr(line, at + length(block) + 1) + } else if (substr(line, 1, 3) ~ /^ [^ ]/) { + exit + } + at = index(line, key ":") + if (at == 0) { next } + rest = substr(line, at + length(key) + 1) + sub(/^[ \t]*/, "", rest) + if (substr(rest, 1, 1) != "\"") { + if (match(rest, /-?[0-9]+/)) { print substr(rest, RSTART, RLENGTH) } + exit + } + value = "" + i = 2 + n = length(rest) + while (i <= n) { + c = substr(rest, i, 1) + if (c == "\\") { + e = substr(rest, i + 1, 1) + if (e == "u") { + hex = substr(rest, i + 2, 4) + if (hex == "0026") { value = value "&" } + else if (hex == "003c") { value = value "<" } + else if (hex == "003e") { value = value ">" } + else { value = value "?" } + i = i + 6 + continue + } + value = value e + i = i + 2 + continue + } + if (c == "\"") { break } + value = value c + i = i + 1 + } + print value + exit + } + ' "$1" +} + +# ask_secret pose une question dont la réponse ne s'affiche pas, la fait retaper, et la rend +# sur la SORTIE STANDARD. +# +# Les invites partent sur la sortie d'ERREUR, et ce n'est pas un détail de présentation : +# l'appelant capture cette fonction par substitution de commande, et une invite mêlée à la +# réponse ferait partie du mot de passe haché. +# +# ★ « read -s » EST UN BASHISME. dash répond « Illegal option -s » et l'installation +# s'arrête sur sa propre question. Couper l'écho du terminal est la forme portable, et le +# trap est ce qui le REND : sans lui, un Ctrl-C au milieu de la saisie laisse un opérateur +# qui tape à l'aveugle jusqu'à ce que quelqu'un lui souffle « stty sane ». +# +# LA CONFIRMATION EST LA MOITIÉ QUI COMPTE, et c'est le raisonnement de Read-ConfirmedSecret +# mot pour mot : un mot de passe tapé de travers en saisie masquée est un mot de passe que +# personne ne pourra plus jamais deviner — ni celui qui l'a tapé, ni la fiche, qui ne le +# porte pas. +# +# Les variables portent un préfixe au lieu d'être déclarées « local », qui n'est pas du sh +# POSIX : dash l'accepte, un autre shell POSIX peut le refuser, et un installeur n'est pas +# l'endroit où découvrir lequel. +ask_secret() { + ask_prompt="$1" + ask_minimum="$2" + ask_stty=$(stty -g) + trap 'stty "$ask_stty" 2>/dev/null || true' EXIT INT TERM + stty -echo + while true; do + printf '%s : ' "$ask_prompt" >&2 + if ! read -r ask_first; then + printf '\n' >&2 + fail "l'entrée standard s'est fermée pendant la saisie : rien n'a été posé" + fi + printf '\n' >&2 + if [ -z "$ask_first" ]; then + note " il en faut un : cette question n'a pas de réponse par défaut." + continue + fi + case "$ask_first" in + [[:space:]]*|*[[:space:]]) + note " il commence ou finit par une espace : personne ne le retapera juste." + continue + ;; + esac + if [ "$(count_code_points "$ask_first")" -lt "$ask_minimum" ]; then + note " trop court : $ask_minimum caractères au minimum." + continue + fi + printf ' Confirmation : ' >&2 + if ! read -r ask_again; then + printf '\n' >&2 + fail "l'entrée standard s'est fermée pendant la confirmation : rien n'a été posé" + fi + printf '\n' >&2 + if [ "$ask_first" != "$ask_again" ]; then + note ' les deux saisies ne sont pas les mêmes.' + continue + fi + break + done + # L'écho est rendu ICI et pas seulement par le trap : celui-ci n'existe que pour le chemin + # interrompu, et cette fonction doit laisser le terminal comme elle l'a trouvé même si un + # appelant la lance un jour ailleurs que dans une substitution de commande. + stty "$ask_stty" 2>/dev/null || true + trap - EXIT INT TERM + printf '%s' "$ask_first" +} + +# place_unit pose une unité systemd, en réécrivant les emplacements qu'un poste déplacé +# rendrait faux. +# +# Les deux unités livrées nomment /usr/local/bin/openscale et /var/lib/openscale EN CLAIR, +# et elles le doivent : c'est ce qui les rend lisibles telles quelles à 8 h du matin, et un +# test de deploy/ les compare aux constantes de internal/platform/paths.go. Sur un poste où +# --install-dir ou --data-dir a déplacé quelque chose, ces deux chaînes deviennent FAUSSES — +# le service lancerait un binaire absent, et ProtectSystem=strict lui refuserait d'écrire là +# où il écrit. C'est ce que install.ps1 fait du marqueur %OPENSCALE_BINARY% dans le XML de +# la tâche planifiée, pour la même raison exactement. +# +# ★ « --data » ARRIVE AVEC, et il le faut : les emplacements par défaut du binaire sont +# COMPILÉS. Une unité qui pointerait ailleurs sans le dire laisserait le service écrire dans +# /var/lib/openscale quand même, et le poste tournerait sur deux répertoires à la fois. +# L'unité du kiosque n'en a pas besoin : elle ne lit que la configuration, qui ne bouge pas. +place_unit() { + if [ "$MOVED" -eq 0 ]; then + install -m 0644 "$1" "$2" + return 0 + fi + sed -e "s#$STOCK_BINARY#$BINARY#g" \ + -e "s#$STOCK_DATA_DIR#$DATA_DIR#g" \ + -e "s#\\(ExecStart=.*openscale serve\\)#\\1 --data $DATA_DIR#" \ + "$1" > "$2.nouveau" + chmod 0644 "$2.nouveau" + mv "$2.nouveau" "$2" +} + +# --- Les contrôles préalables ----------------------------------------------------------- +# Tout ce qui peut refuser refuse ICI, avant la première écriture : une installation qui +# échoue doit échouer avant d'avoir commencé, et non dix étapes plus loin sur un poste dont +# le compte, les répertoires et le binaire sont déjà posés. [ "$(id -u)" -eq 0 ] || fail "à lancer en root : sudo ./install.sh" [ -f "$HERE/openscale" ] || fail "le binaire openscale est introuvable à côté de install.sh" +case "$BINARY" in + /*) ;; + *) fail "--install-dir demande un chemin absolu" ;; +esac +case "$DATA_DIR" in + /*) ;; + *) fail "--data-dir demande un chemin absolu" ;; +esac +# systemd coupe une ligne d'unité sur les espaces, et place_unit substitue avec sed : les +# deux comptent sur des chemins qui n'en portent pas. Le refuser ici vaut mieux qu'un +# ExecStart coupé en deux que personne ne relit. +case "$BINARY$DATA_DIR" in + *[[:space:]]*|*'#'*) fail "les chemins d'installation ne doivent porter ni espace ni « # »" ;; +esac + +if [ -n "$ADMIN_PASSWORD" ] && + [ "$(count_code_points "$ADMIN_PASSWORD")" -lt "$MIN_ADMIN_PASSWORD_LENGTH" ]; then + fail "le mot de passe d'administration doit faire au moins $MIN_ADMIN_PASSWORD_LENGTH caractères : c'est le plancher qu'applique le poste lui-même, et rien n'a été installé" +fi +if [ "$ADMIN_PASSWORD_FROM_ARGV" -eq 1 ]; then + # Dit UNE fois, à l'endroit où c'est encore réparable. La ligne de commande de ce + # processus reste lisible dans /proc tant qu'il tourne, et dans l'historique du shell + # après : changer le mot de passe est le seul remède, et le savoir maintenant coûte moins + # cher que de l'apprendre. + log "AVERTISSEMENT : --admin-password place le secret sur la ligne de commande, que tout utilisateur de la machine lit dans /proc et que l'historique du shell garde. OPENSCALE_ADMIN_PASSWORD évite le premier ; ne rien donner du tout évite les deux." +fi + log "installation d'$PRODUCT sur $(hostname)" +if [ "$MOVED" -eq 1 ]; then + log "emplacements déplacés : binaire $BINARY, données $DATA_DIR — les unités systemd sont réécrites en conséquence, mais uninstall.sh ne connaît que les emplacements par défaut" +fi # --- 1. Les paquets ------------------------------------------------------------------- # --no-install-recommends : un poste de pesée n'a pas besoin des 200 Mo de recommandations @@ -53,6 +462,9 @@ fi # dialout = le PORT SÉRIE, lp = l'IMPRIMANTE, video et input = le kiosque Wayland. Un # compte sans dialout tombe en saisie manuelle avec « accès refusé » sur un port qui # existe, et c'est une heure de recherche. +# +# Ni mot de passe ni interpréteur de commandes : c'est ce qui fait que -AccountPassword n'a +# rien à demander ici (en-tête de ce fichier). if id "$ACCOUNT" >/dev/null 2>&1; then log "compte $ACCOUNT : déjà présent, groupes vérifiés" usermod -aG dialout,lp,video,input,render "$ACCOUNT" || true @@ -63,6 +475,7 @@ else fi # --- 3. Binaire, répertoires, documentation ----------------------------------------- +install -d -m 0755 "$(dirname "$BINARY")" install -m 0755 "$HERE/openscale" "$BINARY" install -d -o "$ACCOUNT" -g "$ACCOUNT" -m 0750 "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR" install -d -o "$ACCOUNT" -g "$ACCOUNT" -m 0750 \ @@ -76,20 +489,211 @@ install -d -m 0755 "$DOC_DIR" for doc in INSTALLATION.md TROUBLESHOOTING.md SHA256SUMS flv_demo.csv; do if [ -f "$HERE/$doc" ]; then install -m 0644 "$HERE/$doc" "$DOC_DIR/$doc"; fi done -log "binaire installé : $("$BINARY" --version)" +VERSION=$("$BINARY" --version) +log "binaire installé : $VERSION" # La configuration livrée : les valeurs du site, SANS le bloc matériel (§11.5). Elle est -# donc incomplète exprès — le numéro de poste, le port série et la file d'impression se -# règlent sur l'écran — et le poste démarre en attendant sur le profil neutre en servant -# la liste de ses fautes (§11.3). -if [ -f "$HERE/config-lacagette.json" ] && [ ! -f "$CONFIG_DIR/config.json" ]; then - install -m 0640 -o "$ACCOUNT" -g "$ACCOUNT" "$HERE/config-lacagette.json" "$CONFIG_DIR/config.json" - log "configuration livrée copiée dans $CONFIG_DIR/config.json" -elif [ -f "$CONFIG_DIR/config.json" ]; then - log "configuration existante conservée : $CONFIG_DIR/config.json" +# donc incomplète exprès — le port série et la file d'impression se règlent sur l'écran — +# et le poste démarre en attendant sur le profil neutre en servant la liste de ses fautes +# (§11.3). Ce que l'étape 4 lui ajoute est ce que l'écran ne peut PAS deviner : qui est ce +# poste. +CONFIG_IS_NEW=0 +if [ -f "$HERE/config-lacagette.json" ] && [ ! -f "$CONFIG" ]; then + install -m 0640 -o "$ACCOUNT" -g "$ACCOUNT" "$HERE/config-lacagette.json" "$CONFIG" + CONFIG_IS_NEW=1 + log "configuration livrée copiée dans $CONFIG" +elif [ -f "$CONFIG" ]; then + log "configuration existante conservée : $CONFIG" +fi + +# --- 4. Qui est ce poste, et sa balance (§11.2, §15.5) -------------------------------- +# CE QUE SEULE L'INSTALLATION PEUT DEMANDER. La configuration livrée est l'export de §11.5 : +# numéro 0, aucun nom, et une balance qui nomme son protocole sans nommer de port. +# +# LA BALANCE N'EST DÉCLARÉE ABSENTE QUE SUR UN POSTE NEUF, et le critère est « ce fichier +# vient d'être copié », rien d'autre. « scale.present = false » est la déclaration explicite +# de §11.2 : c'est vrai d'un poste qui sort de l'archive, où rien n'a encore été branché ni +# détecté ; c'est faux d'un poste en service, dont relancer l'installeur ne doit surtout pas +# éteindre la balance. +# +# LES BORNES DU NUMÉRO NE SONT PAS ICI. C'est le contrôle 1 de §11.3, le binaire les +# applique, il refuse en français et il n'écrit rien : on redemande. Les réécrire dans ce +# script en ferait un second endroit à corriger, et le premier à mentir. +NUMBER_IS_MISSING=1 +if [ -n "$(config_field "$CONFIG" station number)" ] && + [ "$(config_field "$CONFIG" station number)" != '0' ]; then + NUMBER_IS_MISSING=0 +fi +NAME_IS_MISSING=1 +if [ -n "$(config_field "$CONFIG" station name)" ]; then NAME_IS_MISSING=0; fi + +NUMBER_ANSWER="$STATION_NUMBER" +NAME_ANSWER="$STATION_NAME" +WILL_ASK_IDENTITY=0 +if [ "$ASKABLE" -eq 1 ] && [ -f "$CONFIG" ]; then + if [ "$NUMBER_IS_MISSING" -eq 1 ] && [ -z "$NUMBER_ANSWER" ]; then WILL_ASK_IDENTITY=1; fi + if [ "$NAME_IS_MISSING" -eq 1 ] && [ -z "$NAME_ANSWER" ]; then WILL_ASK_IDENTITY=1; fi +fi +if [ "$WILL_ASK_IDENTITY" -eq 1 ]; then + say '' + say " CE POSTE — répondez, puis l'installation reprend seule." +fi + +while [ -f "$CONFIG" ]; do + if [ "$ASKABLE" -eq 1 ] && [ "$NUMBER_IS_MISSING" -eq 1 ] && [ -z "$NUMBER_ANSWER" ]; then + say '' + say ' Numéro de ce poste dans la coopérative' + say " C'est de lui que dérive le nom du fichier de catalogue surveillé, flv_.csv." + while true; do + printf ' Numéro : ' + if ! read -r NUMBER_ANSWER; then fail "l'entrée standard s'est fermée : le numéro n'a pas été posé"; fi + case "$NUMBER_ANSWER" in + ''|*[!0-9]*) say " un nombre, et rien d'autre." ;; + *) break ;; + esac + done + fi + if [ "$ASKABLE" -eq 1 ] && [ "$NAME_IS_MISSING" -eq 1 ] && [ -z "$NAME_ANSWER" ]; then + say '' + say ' Nom de ce poste' + say " Ce que les bénévoles lisent sur l'écran d'administration : « Poste 2 - fruits »." + while true; do + printf ' Nom : ' + if ! read -r NAME_ANSWER; then fail "l'entrée standard s'est fermée : le nom n'a pas été posé"; fi + if [ -n "$NAME_ANSWER" ]; then break; fi + say " il en faut un : c'est ce qui distingue ce poste de ses voisins." + done + fi + + # Les options se construisent dans les PARAMÈTRES POSITIONNELS, et pas dans une chaîne : + # c'est la seule façon, en sh POSIX, de transporter « Poste 2 - fruits » sans que le + # découpage en mots n'en fasse trois arguments. Ce « set -- » écrase les paramètres du + # script, et il en a le droit : la boucle d'analyse des options les a tous consommés. + set -- config station "$CONFIG" + if [ -n "$NUMBER_ANSWER" ]; then set -- "$@" --number "$NUMBER_ANSWER"; fi + if [ -n "$NAME_ANSWER" ]; then set -- "$@" --name "$NAME_ANSWER"; fi + if [ "$CONFIG_IS_NEW" -eq 1 ]; then set -- "$@" --no-scale; fi + + # « config station » et le fichier font TROIS paramètres ; au-delà, il y a quelque chose à + # poser. Sans aucune option la commande refuserait — « config station ne change rien sans + # --number, --name ou --no-scale » —, et ce refus-là n'en est pas un : c'est le cas d'une + # réinstallation dont tout est déjà posé. + STATION_STATUS=0 + if [ $# -gt 3 ]; then + "$BINARY" "$@" || STATION_STATUS=$? + fi + if [ "$STATION_STATUS" -ne 0 ]; then + # Sans personne pour retaper, un refus est un échec d'installation comme un autre : le + # poste ne doit pas s'annoncer installé avec un numéro que ses propres contrôles + # rejettent. + if [ "$ASKABLE" -eq 0 ]; then + fail "« openscale config station » a refusé ce qui lui a été transmis (code $STATION_STATUS) — rien n'a été écrit" + fi + log "la réponse a été refusée — rien n'a été écrit, on recommence" + NUMBER_ANSWER='' + NAME_ANSWER='' + continue + fi + + # ★ CE QUE LE JOURNAL DIT SE COMPOSE DE CE QUI A ÉTÉ TRANSMIS, et jamais du nombre + # d'arguments : sur un poste NEUF il porte toujours « --no-scale », si bien qu'une liste + # vide n'existe pas et que l'avertissement écrit pour le seul cas qui en a besoin — une + # installation scriptée où personne n'a répondu — ne s'afficherait jamais. Le journal + # annoncerait alors une identité posée sur un poste qui n'a ni numéro ni nom. + POSED='' + UNANSWERED='' + if [ -n "$NUMBER_ANSWER" ]; then + POSED="numéro $NUMBER_ANSWER" + elif [ "$NUMBER_IS_MISSING" -eq 1 ]; then + UNANSWERED='le numéro' + fi + if [ -n "$NAME_ANSWER" ]; then + if [ -n "$POSED" ]; then POSED="$POSED, "; fi + POSED="${POSED}nom « $NAME_ANSWER »" + elif [ "$NAME_IS_MISSING" -eq 1 ]; then + if [ -n "$UNANSWERED" ]; then UNANSWERED="$UNANSWERED et "; fi + UNANSWERED="${UNANSWERED}le nom" + fi + + if [ -n "$POSED" ]; then + SAID="identité du poste posée dans $CONFIG : $POSED" + elif [ -n "$UNANSWERED" ]; then + SAID="identité du poste NON posée : rien n'a été demandé ni transmis" + else + SAID="identité du poste inchangée : numéro et nom déjà posés dans $CONFIG" + fi + if [ -n "$UNANSWERED" ]; then + SAID="$SAID — il reste à régler sur l'écran d'administration : $UNANSWERED" + fi + log "$SAID" + break +done +SCALE_DISABLED="$CONFIG_IS_NEW" + +# --- 5. Le code de secours d'administration (§14.4, important-10) --------------------- +# « Un code de secours de 8 caractères est généré à l'installation, imprimé sur la fiche +# d'installation et consigné dans le classeur du magasin. » C'est ici et pas sur l'écran : +# le code est ce qui rouvre un poste QUI N'A AUCUN MOT DE PASSE, et c'est l'état d'un poste +# dont l'étape suivante n'a pas pu poser le sien — une installation scriptée, une console +# sans clavier. Un script shell ne sait pas produire une empreinte argon2id ; le binaire le +# fait, et il est le seul à afficher le code en clair, une fois. +# +# Une réinstallation ne le fait PAS tourner : la fiche déjà rangée au classeur doit rester +# vraie, et personne ne peut relire un code qui n'existe plus qu'en empreinte. +# +# L'appel n'est pas toléré s'il échoue, et ce n'est pas un oubli : sous « set -e », une +# substitution de commande qui sort non nulle arrête le script. Un poste sans code de +# secours ET sans mot de passe n'a plus aucune porte. +RECOVERY_CODE='' +if [ -f "$CONFIG" ] && [ -z "$(config_field "$CONFIG" admin recovery_code_hash)" ]; then + RECOVERY_PRINTED=$("$BINARY" config recovery-code "$CONFIG") + RECOVERY_CODE=$(printf '%s\n' "$RECOVERY_PRINTED" | + sed -n 's/^Code de secours de ce poste : \(.*\)$/\1/p' | head -n 1) + if [ -n "$RECOVERY_CODE" ]; then + log "code de secours d'administration tiré (il n'est écrit que sur la fiche)" + else + log "code de secours d'administration NON relu dans la sortie du binaire : la fiche portera une ligne à remplir à la main" + fi +elif [ -f "$CONFIG" ]; then + log "code de secours existant conservé : la fiche déjà classée reste valable" +fi + +# --- 6. Le mot de passe d'administration (§14.4) -------------------------------------- +# LE POSER ICI EST TOUT L'OBJET DE CETTE ÉTAPE. Jusqu'ici le poste sortait de l'installeur +# sans aucun mot de passe : le premier réglage ouvrait le panneau « Ce poste n'a pas encore +# de mot de passe », qui réclame le code de secours — donc la fiche, donc le classeur, donc +# quelqu'un qui sait où il est. Le code de secours ne disparaît pas pour autant : il reste +# le recours d'un poste dont le mot de passe est perdu, et la seule porte d'une installation +# scriptée où personne n'était là pour répondre. +# +# ★ SUR L'ENTRÉE STANDARD, JAMAIS SUR LA LIGNE DE COMMANDE. « openscale config » ne déclare +# aucune option qui prendrait un secret, et le dit dans son propre usage : un argument se +# lit dans /proc//cmdline, que tout utilisateur de la machine peut ouvrir. +# +# Rien à régler sur l'encodage, contrairement à Set-NativeOutputEncoding sous Windows : un +# tube Unix transporte les octets tels quels, et ils sont déjà en UTF-8 des deux côtés. +ADMIN_POSED=0 +if [ -n "$(config_field "$CONFIG" admin password_hash)" ]; then ADMIN_POSED=1; fi +if [ "$ASKABLE" -eq 1 ] && [ -z "$ADMIN_PASSWORD" ] && [ "$ADMIN_POSED" -eq 0 ] && + [ -f "$CONFIG" ]; then + say '' + say " Mot de passe d'administration de ce poste" + say " Il protège le droit de CHANGER le poste : les prix, l'étiquette, le catalogue." + say " $MIN_ADMIN_PASSWORD_LENGTH caractères au minimum, tapé deux fois, et il ne s'affiche pas." + say " Il n'est imprimé NULLE PART, pas même sur la fiche : prenez-en un que l'équipe" + say " connaît. Oublié, il se repose avec le code de secours de la fiche." + ADMIN_PASSWORD=$(ask_secret ' Mot de passe' "$MIN_ADMIN_PASSWORD_LENGTH") +fi +if [ -n "$ADMIN_PASSWORD" ] && [ -f "$CONFIG" ]; then + printf '%s\n' "$ADMIN_PASSWORD" | "$BINARY" config password "$CONFIG" + ADMIN_PASSWORD='' + ADMIN_POSED=1 + log "mot de passe d'administration posé (il n'est écrit ni dans ce journal ni sur la fiche)" +elif [ "$ADMIN_POSED" -eq 0 ]; then + log "mot de passe d'administration NON posé : le premier réglage le demandera, avec le code de secours de la fiche" fi -# --- 4. Les règles udev ------------------------------------------------------------- +# --- 7. Les règles udev --------------------------------------------------------------- if [ -f "$HERE/99-openscale.rules" ]; then install -m 0644 "$HERE/99-openscale.rules" /etc/udev/rules.d/99-openscale.rules udevadm control --reload @@ -104,7 +708,7 @@ if [ -f "$HERE/99-openscale.rules" ]; then fi fi -# --- 4 bis. Le droit de redémarrer l'ordinateur -------------------------------------- +# --- 7 bis. Le droit de redémarrer l'ordinateur -------------------------------------- # Le service tourne en « openscale » et non en root : sans cette règle, le bouton # « Redémarrer l'ordinateur » de l'écran d'administration est refusé par polkit. Le poste # marche quand même, et c'est pourquoi son absence n'arrête pas l'installation — mais @@ -121,9 +725,9 @@ if [ -f "$HERE/49-openscale-reboot.rules" ]; then fi fi -# --- 5. Les unités systemd ---------------------------------------------------------- -install -m 0644 "$HERE/openscale.service" "$UNIT_DIR/openscale.service" -install -m 0644 "$HERE/openscale-kiosk.service" "$UNIT_DIR/openscale-kiosk.service" +# --- 8. Les unités systemd ---------------------------------------------------------- +place_unit "$HERE/openscale.service" "$UNIT_DIR/openscale.service" +place_unit "$HERE/openscale-kiosk.service" "$UNIT_DIR/openscale-kiosk.service" systemctl daemon-reload # systemd-analyze verify dit tout de suite ce qu'une unité fautive coûterait à trouver au # prochain démarrage. Son échec n'arrête PAS l'installation : il peut refuser une @@ -138,11 +742,11 @@ systemctl enable openscale-kiosk.service systemctl restart openscale.service log 'service activé et démarré' -# --- 6. Vérification ---------------------------------------------------------------- +# --- 9. Vérification ---------------------------------------------------------------- # /healthz, et JAMAIS /readyz : une imprimante sans papier répond 503 sur /readyz, et une # installation qui se croirait ratée pour un rouleau vide n'aurait rien compris à §15.3. LISTEN=$(sed -n 's/.*"listen"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - "$CONFIG_DIR/config.json" 2>/dev/null | head -n 1) + "$CONFIG" 2>/dev/null | head -n 1) [ -n "$LISTEN" ] || LISTEN='127.0.0.1:8085' case "$LISTEN" in 0.0.0.0:*) LISTEN="127.0.0.1:${LISTEN##*:}" ;; @@ -153,7 +757,7 @@ ADDRESS="http://$LISTEN" # DEUX adresses, et la seconde n'est pas une précaution vague : un poste dont la # configuration est fautive démarre sur le PROFIL NEUTRE (§11.3) et sert donc sur # l'adresse de ce profil — c'est exactement l'état d'un poste fraîchement installé, dont il -# reste à régler le numéro, la balance et l'imprimante. +# reste à régler la balance et l'imprimante. FACTORY_ADDRESS='http://127.0.0.1:8085' healthy=0 attempt=0 @@ -178,41 +782,116 @@ if [ "$healthy" -eq 1 ]; then log "le poste répond sur $ADDRESS/healthz" else log "le poste ne répond pas sur $ADDRESS — diagnostic :" - "$BINARY" doctor --config "$CONFIG_DIR/config.json" --data "$DATA_DIR" || true + "$BINARY" doctor --config "$CONFIG" --data "$DATA_DIR" || true +fi + +# --- 10. La fiche d'installation ------------------------------------------------------ +# CE QUE LE FICHIER DIT, et non ce que ce script croit y avoir écrit. La fiche part au +# classeur et elle y reste des années : elle doit porter l'identité que le poste applique, y +# compris quand une réinstallation n'a rien redemandé parce que tout était déjà posé. +FINGERPRINT=$("$BINARY" config fingerprint "$CONFIG" 2>/dev/null || + printf '%s' "(à relever sur l'écran d'administration)") +SHEET_NUMBER=$(config_field "$CONFIG" station number) +SHEET_NAME=$(config_field "$CONFIG" station name) +if [ -z "$SHEET_NUMBER" ] || [ "$SHEET_NUMBER" = '0' ]; then SHEET_NUMBER='(pas encore posé)'; fi +if [ -z "$SHEET_NAME" ]; then SHEET_NAME='(pas encore posé)'; fi + +# La fiche N'IMPRIME PAS le mot de passe d'administration, qui est pourtant posé ici. Ce +# n'est pas un oubli : cette feuille part au classeur du magasin, et ce mot de passe donne +# le droit de CHANGER le poste. Ce qu'elle porte à sa place est ce dont on a besoin quand il +# est perdu : qui l'a posé, et le code de secours. +if [ "$ADMIN_POSED" -eq 1 ]; then + SHEET_ADMIN=" POSÉ À L'INSTALLATION, par la personne qui a lancé l'installeur. + Il n'est écrit NULLE PART : ni sur cette fiche, ni dans la configuration, qui + n'en garde qu'une empreinte argon2id. Redemandez-le à cette personne. Perdu + pour tout le monde, il se repose en ligne de commande (plus bas)." +else + SHEET_ADMIN=" PAS ENCORE POSÉ : l'installation n'a pas eu de quoi poser la question + (installation scriptée, ou entrée standard qui n'est pas un terminal). Le + premier geste qui change le poste le demandera, et c'est le CODE DE SECOURS + ci-dessous qui ouvre cette porte-là." +fi + +# Le code n'existe en clair QU'ICI : le poste n'en garde que l'empreinte argon2id. +if [ -n "$RECOVERY_CODE" ]; then + SHEET_RECOVERY=" $RECOVERY_CODE + Tiré à l'installation. Il n'est affiché nulle part ailleurs et le poste ne + sait pas le relire : cette feuille est la seule copie." +else + SHEET_RECOVERY=" ........................................................ + À RECOPIER ICI À LA MAIN : ce poste portait déjà un code, et seule son + empreinte est conservée. Reprenez-le sur la fiche précédente, ou tirez-en un + nouveau avec « openscale config recovery-code »." +fi + +# L'écart d'empreinte d'un poste dont la balance n'est pas encore réglée. §15.5 fait +# comparer les quatre postes À L'ŒIL : un écart que la fiche n'annonce pas est un écart +# qu'on prend pour une panne, et qu'on « répare » en touchant à la configuration. +SHEET_SCALE='' +if [ "$SCALE_DISABLED" -eq 1 ]; then + SHEET_SCALE=" + ATTENTION, LA BALANCE DE CE POSTE EST DÉCLARÉE ABSENTE. L'installation la + désactive sur un poste neuf, où elle n'est encore ni branchée ni détectée. + Tant qu'elle n'est pas remise en service — page « Matériel », « Détecter + automatiquement », puis « Utiliser cette balance » sur le port qui répond —, + CE POSTE N'AFFICHE PAS LA MÊME EMPREINTE QUE SES VOISINS, et ce n'est pas + une panne. Elle rejoint celle du parc dès que la balance est déclarée." fi -# --- 7. La fiche d'installation ------------------------------------------------------ -FINGERPRINT=$("$BINARY" config fingerprint "$CONFIG_DIR/config.json" 2>/dev/null || echo "(à relever sur l'écran d'administration)") SHEET="$DATA_DIR/install-sheet.txt" cat > "$SHEET" < », que « Get-Help .\\install.ps1 » et « .\\install.ps1 -? » lisent tout seuls, " + + "paramètre par paramètre. Un -Help ferait une SECONDE aide à tenir à jour à côté de " + + "celle-là, et c'est toujours la seconde qui pourrit. sh n'a pas cet en-tête : son " + + "aide s'écrit, donc elle s'appelle.", + proof: ".SYNOPSIS", + }, +} + +// aReasonNotALabel is the length under which a « why » is a label and not a reason. +// +// « pas d'objet » is eleven characters and explains nothing to the next reader; every reason +// written above names what the other system does INSTEAD, which is what makes it checkable. +// The floor is this bench's own rule and it lives only here. +const aReasonNotALabel = 60 + +// TestBothInstallersOfferTheSameInstallation is the guard of the product owner's decision of +// 10/08/2026: « une modification validée sur l'un doit être reportée sur l'autre et +// inversement ». +// +// What it protects is a coopérative running four stations on two systems: an option that +// exists on one side only means the same installation, done on a Debian rather than on a +// Windows, comes out different — and nobody finds out at the installation. They find out the +// Saturday somebody follows INSTALLATION.md on the other machine, types what the notice says, +// and gets « option inconnue ». +// +// The failure names the option AND the script that carries it, because the repair is never +// « corriger le banc »: it is either porting the option, or writing the écart down in +// installerParityExceptions with its reason. +func TestBothInstallersOfferTheSameInstallation(t *testing.T) { + windows := powerShellParameters(t, windowsInstallerPath) + linux := shellOptions(t, linuxInstallerPath) + + // The table first: a pair that names an option no script carries is a parity declared + // over a hole, and it would excuse the very absence it is supposed to forbid. + pairedWindows := map[string]parityPair{} + pairedLinux := map[string]parityPair{} + for _, pair := range installerParity { + key := strings.ToLower(pair.windows) + if _, twice := pairedWindows[key]; twice { + t.Errorf("la table de parité nomme -%s deux fois", pair.windows) + } + if _, twice := pairedLinux[pair.linux]; twice { + t.Errorf("la table de parité nomme %s deux fois", pair.linux) + } + pairedWindows[key] = pair + pairedLinux[pair.linux] = pair + + if pair.linux != dashedSpelling(pair.windows) && strings.TrimSpace(pair.why) == "" { + t.Errorf("la table fait répondre %s à -%s, alors que la correspondance mécanique "+ + "donne %s : un écart de graphie sans raison écrite est ce qui rend les deux "+ + "installeurs impossibles à relire l'un contre l'autre", + pair.linux, pair.windows, dashedSpelling(pair.windows)) + } + if _, declared := windows[key]; !declared { + t.Errorf("la table de parité fait répondre -%s (%s) à %s (%s), mais %s ne déclare "+ + "plus -%s : l'option a été retirée d'un seul côté, et %s la porte toujours", + pair.windows, windowsInstallerPath, pair.linux, linuxInstallerPath, + windowsInstallerPath, pair.windows, linuxInstallerPath) + } + if !linux[pair.linux] { + t.Errorf("la table de parité fait répondre %s (%s) à -%s (%s), mais %s n'analyse "+ + "plus %s : l'option a été retirée d'un seul côté, et %s la porte toujours", + pair.linux, linuxInstallerPath, pair.windows, windowsInstallerPath, + linuxInstallerPath, pair.linux, windowsInstallerPath) + } + } + + excusedWindows, excusedLinux := map[string]bool{}, map[string]bool{} + for _, exception := range installerParityExceptions { + checkTheReasonIsWritten(t, exception) + switch exception.carriedBy { + case windowsInstallerPath: + key := strings.ToLower(exception.option) + excusedWindows[key] = true + if _, declared := windows[key]; !declared { + t.Errorf("installerParityExceptions excuse -%s, que %s ne déclare plus : une "+ + "exception qui ne correspond à rien est un tapis, pas un arbitrage", + exception.option, exception.carriedBy) + } + if linux[dashedSpelling(exception.option)] { + t.Errorf("installerParityExceptions dit que %s n'a pas d'équivalent à -%s, "+ + "alors qu'il analyse %s : l'exception est périmée, la parité est faite", + exception.missingFrom, exception.option, dashedSpelling(exception.option)) + } + case linuxInstallerPath: + excusedLinux[exception.option] = true + if !linux[exception.option] { + t.Errorf("installerParityExceptions excuse %s, que %s n'analyse plus : une "+ + "exception qui ne correspond à rien est un tapis, pas un arbitrage", + exception.option, exception.carriedBy) + } + default: + t.Errorf("l'exception %s dit être portée par %s, qui n'est ni %s ni %s", + exception.option, exception.carriedBy, windowsInstallerPath, linuxInstallerPath) + } + if _, paired := pairedWindows[strings.ToLower(exception.option)]; paired { + t.Errorf("%s est à la fois dans la table de parité et dans les exceptions : les "+ + "deux se contredisent", exception.option) + } + if pairedLinux[exception.option].linux != "" { + t.Errorf("%s est à la fois dans la table de parité et dans les exceptions : les "+ + "deux se contredisent", exception.option) + } + } + + // Windows towards Linux. + for _, key := range sortedKeys(windows) { + if _, paired := pairedWindows[key]; paired || excusedWindows[key] { + continue + } + t.Errorf("%s déclare -%s, dont %s n'a aucun équivalent. Une modification validée sur "+ + "l'un des deux installeurs se reporte sur l'autre (décision du 10/08/2026) : "+ + "ajoutez %s à %s et la paire à installerParity, ou inscrivez l'écart dans "+ + "installerParityExceptions avec sa raison", + windowsInstallerPath, windows[key], linuxInstallerPath, + dashedSpelling(windows[key]), linuxInstallerPath) + } + + // Linux towards Windows, and this direction is the one that gets forgotten: the Windows + // station is the one that exists, so an option written on the Debian first has nobody + // asking for it back. + for _, option := range sortedNames(linux) { + if _, paired := pairedLinux[option]; paired || excusedLinux[option] { + continue + } + t.Errorf("%s analyse %s, dont %s n'a aucun équivalent. Une modification validée sur "+ + "l'un des deux installeurs se reporte sur l'autre (décision du 10/08/2026) : "+ + "ajoutez -%s à %s et la paire à installerParity, ou inscrivez l'écart dans "+ + "installerParityExceptions avec sa raison", + linuxInstallerPath, option, windowsInstallerPath, + pascalSpelling(option), windowsInstallerPath) + } +} + +// bootstrapRelayExceptions names the installer options a bootstrap deliberately does not +// hand over, and why. Same rule as above: no reason, no exception. +var bootstrapRelayExceptions = []parityException{ + { + option: "--admin-password", + carriedBy: linuxInstallerPath, + missingFrom: linuxBootstrapPath, + why: "Le secret descend vers install.sh par l'ENVIRONNEMENT et jamais par un argument, " + + "même quand il est arrivé au bootstrap par --admin-password : /proc//cmdline " + + "est lisible par tous les comptes de la machine, /proc//environ par le seul " + + "propriétaire du processus. Le relayer en argument le publierait une seconde fois, " + + "dans la ligne de commande d'un processus lancé en root. C'est la règle de " + + "bootstrap.ps1, qui appelle install.ps1 dans le MÊME processus pour la même raison.", + proof: `OPENSCALE_ADMIN_PASSWORD="$ADMIN_PASSWORD"`, + }, + { + option: "--help", + carriedBy: linuxInstallerPath, + missingFrom: linuxBootstrapPath, + why: "Le bootstrap répond LUI-MÊME à --help, et sort avant d'avoir téléchargé quoi que " + + "ce soit : sous « curl … | sh » il n'y a encore aucun install.sh sur le disque à " + + "qui faire imprimer une aide. Son usage nomme d'ailleurs les options qu'il relaie, " + + "donc la question posée est bien celle à laquelle il répond.", + proof: "-h|--help)", + }, +} + +// TestEachBootstrapRelaysWhatItsInstallerAccepts is the same decision of 10/08/2026, seen +// from the one-liner. +// +// What it protects is the gesture INSTALLATION.md actually recommends: a station is not +// installed by hand from an archive, it is installed by « curl … | sudo sh » or by its +// Windows twin. An option the installer accepts but the bootstrap ignores is an option that +// exists only for whoever unzips the archive themselves — so the parity above would be green +// and the station installed remotely would STILL come out different. +// +// Both halves are checked, because they fail differently: a bootstrap that does not ACCEPT +// the option refuses the command outright, whereas a bootstrap that accepts it and does not +// HAND IT OVER installs a station that quietly ignores what the operator asked for. The +// opposite direction — every argument the bootstrap passes must be declared by the installer +// — is TestTheInstallerDeclaresEveryParameterTheBootstrapPasses, in bootstrap_test.go. +func TestEachBootstrapRelaysWhatItsInstallerAccepts(t *testing.T) { + excused := map[string]bool{} + for _, exception := range bootstrapRelayExceptions { + checkTheReasonIsWritten(t, exception) + excused[exception.missingFrom+" "+exception.option] = true + } + + t.Run("windows", func(t *testing.T) { + installer := powerShellParameters(t, windowsInstallerPath) + bootstrap := powerShellParameters(t, windowsBootstrapPath) + // bootstrap.ps1 hands the answers over by splatting ONE hashtable, which is what keeps + // the secret out of every command line. Its keys are therefore what « relayer » means + // on this side. + relayed := hashTableKeys(t, windowsBootstrapPath, "installerArguments") + for _, key := range sortedKeys(installer) { + if _, accepted := bootstrap[key]; !accepted { + t.Errorf("%s déclare -%s, que %s n'accepte pas : le one-liner ne sait pas "+ + "installer ce que l'installeur sait faire (décision du 10/08/2026)", + windowsInstallerPath, installer[key], windowsBootstrapPath) + continue + } + if relayed[key] { + continue + } + if excused[windowsBootstrapPath+" "+installer[key]] { + t.Logf("-%s n'est pas relayé, exprès : voir bootstrapRelayExceptions", installer[key]) + continue + } + t.Errorf("%s accepte -%s mais ne le met pas dans $installerArguments : l'option "+ + "est acceptée puis perdue, et le poste s'installe en ignorant ce qu'on lui a "+ + "demandé", windowsBootstrapPath, installer[key]) + } + }) + + t.Run("linux", func(t *testing.T) { + installer := shellOptions(t, linuxInstallerPath) + bootstrap := shellOptions(t, linuxBootstrapPath) + // « set -- "$@" --option "$VALEUR" » is how bootstrap.sh rebuilds install.sh's argument + // list, sh POSIX having no array but its positional parameters. + relay := readFile(t, linuxBootstrapPath) + for _, option := range sortedNames(installer) { + if !bootstrap[option] { + t.Errorf("%s analyse %s, que %s n'accepte pas : le one-liner ne sait pas "+ + "installer ce que l'installeur sait faire (décision du 10/08/2026)", + linuxInstallerPath, option, linuxBootstrapPath) + continue + } + if handedOver(relay, option) { + continue + } + if excused[linuxBootstrapPath+" "+option] { + t.Logf("%s n'est pas relayé en argument, exprès : voir bootstrapRelayExceptions", option) + continue + } + t.Errorf("%s accepte %s mais ne le repasse jamais à install.sh (« set -- \"$@\" "+ + "%s ») : l'option est acceptée puis perdue, et le poste s'installe en ignorant "+ + "ce qu'on lui a demandé", linuxBootstrapPath, option, option) + } + }) +} + +// TestNeitherInstallerPutsASecretOnTheBinaryCommandLine holds, on BOTH systems at once, the +// promise « openscale config password » is built on. +// +// An argument is readable in the process list by any user of the machine — /proc//cmdline +// on Debian, the process list on Windows — so the binary reads its secret off the standard +// input, and the two installers push it in through a pipe. The rule is one rule and it now +// has one bench: install.ps1's half was held on its own by +// TestTheInstallerFeedsTheAdministrationPasswordOnTheStandardInput (bootstrap_test.go), and +// install.sh's half was held by nothing at all — which is the exact shape the decision of +// 10/08/2026 forbids, a guarantee ported to one side and not to the other. +// +// The last check is what keeps the bench honest: an installer that simply stopped setting the +// administration password would satisfy every prohibition above by doing nothing. +func TestNeitherInstallerPutsASecretOnTheBinaryCommandLine(t *testing.T) { + for _, installer := range []struct { + script string + binary *regexp.Regexp // how this script invokes the station binary + secret *regexp.Regexp // how this script names a secret: the criterion, not the list + }{ + { + script: windowsInstallerPath, + binary: regexp.MustCompile(`\$paths\.Binary`), + // A variable whose name ends in Password, case blind because PowerShell is. + // $script:MinimumAdminPasswordLength is not one: the floor is printed on purpose. + secret: regexp.MustCompile(`(?i)\$\w*Password\b`), + }, + { + script: linuxInstallerPath, + binary: regexp.MustCompile(`"\$BINARY"`), + // Same criterion in shell. MIN_ADMIN_PASSWORD_LENGTH and ADMIN_PASSWORD_FROM_ARGV + // are excluded by the word boundary, and they are not secrets. + secret: regexp.MustCompile(`\$\{?\w*PASSWORD\}?\b`), + }, + } { + lines := strings.Split(codeOnly(readFile(t, installer.script)), "\n") + handedOverOnStandardInput := false + for number, line := range lines { + call := installer.binary.FindStringIndex(line) + if call == nil { + continue + } + if found := installer.secret.FindString(line[call[1]:]); found != "" { + t.Errorf("%s ligne %d donne %s en ARGUMENT au binaire : la liste des processus "+ + "le publierait à tous les comptes de la machine\n %s", + installer.script, number+1, found, strings.TrimSpace(line)) + } + if !strings.Contains(line, "config password") { + continue + } + before := line[:call[0]] + if strings.Contains(before, "|") && installer.secret.MatchString(before) { + handedOverOnStandardInput = true + } + // PowerShell writes its pipeline over two lines, the value opening the first: the + // guarded form of §15.2 wants the native call alone on its own line. + if number > 0 { + previous := lines[number-1] + if strings.Contains(previous, "|") && installer.secret.MatchString(previous) { + handedOverOnStandardInput = true + } + } + } + if !handedOverOnStandardInput { + t.Errorf("%s ne pousse aucun secret par un tube dans « openscale config password » : "+ + "soit le poste sort de l'installation sans mot de passe d'administration, soit "+ + "le secret passe par un chemin que ce banc ne voit pas", installer.script) + } + } +} + +// --- Les lecteurs --------------------------------------------------------------------- + +// dashedSpelling renders a PowerShell parameter the way a sh script spells it. +// +// This is the mechanical correspondence install.sh names in its own header, and having it +// COMPUTED rather than listed is what makes the table above readable: a line that needs no +// « why » is a line whose two halves say the same thing. +func dashedSpelling(parameter string) string { + var out strings.Builder + out.WriteString("--") + for index, letter := range parameter { + if index > 0 && unicode.IsUpper(letter) { + out.WriteByte('-') + } + out.WriteRune(unicode.ToLower(letter)) + } + return out.String() +} + +// pascalSpelling is the same correspondence, read the other way, so a failure on the Linux +// side can name the parameter that is missing rather than describe it. +func pascalSpelling(option string) string { + var out strings.Builder + for _, word := range strings.Split(strings.TrimPrefix(option, "--"), "-") { + if word == "" { + continue + } + out.WriteString(strings.ToUpper(word[:1])) + out.WriteString(word[1:]) + } + return out.String() +} + +// checkTheReasonIsWritten is what stops the exception lists from becoming a carpet. +// +// Two things are demanded of an écart. A reason, in French, long enough to name what the +// other system does INSTEAD — « pas d'objet » excuses everything and teaches nothing. And +// that same reason written IN THE SCRIPT THAT LACKS THE OPTION, because that is where the +// next person comparing the two will be looking, and a reason readable only from a Go test +// file is a reason nobody reads. +func checkTheReasonIsWritten(t *testing.T, exception parityException) { + t.Helper() + if len(strings.TrimSpace(exception.why)) < aReasonNotALabel { + t.Errorf("l'exception de parité %s n'a pas de raison écrite : une exception muette est "+ + "le tapis sous lequel on balaie un écart, pas un arbitrage", exception.option) + return + } + if exception.proof == "" { + t.Errorf("l'exception de parité %s ne dit pas où son arbitrage est écrit dans %s", + exception.option, exception.missingFrom) + return + } + if !strings.Contains(readFile(t, exception.missingFrom), exception.proof) { + t.Errorf("la raison de l'exception %s n'est écrite que dans ce banc : %s ne contient "+ + "pas « %s », et c'est pourtant là que regardera celui qui relit les deux scripts "+ + "l'un contre l'autre", exception.option, exception.missingFrom, exception.proof) + } +} + +// powerShellParameters reads the parameters a .ps1 declares at its top level. +// +// It leans on the reader of powershell_test.go instead of a regular expression of its own: +// that one already knows what a param block is, attributes and default values included, and +// one place that knows is one place to correct. +func powerShellParameters(t *testing.T, path string) map[string]string { + t.Helper() + lines := strings.Split(codeOnly(readFile(t, path)), "\n") + declared, _ := parametersByScope(lines, owningScopes(lines)) + if len(declared[""]) == 0 { + t.Fatalf("aucun paramètre lu dans le bloc param() de %s : l'extraction a raté, et un "+ + "banc qui ne lit rien est vert pour rien", path) + } + return declared[""] +} + +// shellOptions reads the long options a sh script accepts, out of the `case` that analyses +// its arguments. +// +// FRAGILE BY NATURE, and made as loud as possible about it: sh declares nothing, its options +// exist only as the labels of a `case`. So the reader anchors on the argument loop itself — +// « while [ $# -gt 0 ] », the one thing that cannot be renamed without ceasing to be an +// argument loop — then takes the `case` it opens, WHATEVER its subject: reading « $1 » would +// have made a bench that goes silent the day somebody writes « OPTION="$1" » first. Two such +// loops in one script and it says so rather than picking the wrong one. +// +// From there it follows the block to its own `esac`, counting the nested ones, and demands to +// have met the catch-all « *) » on the way. That last one is the proof it read the block to +// its END: an extraction that stopped short would quietly return half the options, and a +// bench that misses an option is worse than no bench — it reassures wrongly. +// +// The labels of one arm are ALIASES of a single option — « -h|--help » —, and « --foo=* » is +// that same option once more; both are folded onto the long spelling, which is the one the +// parity table names. +func shellOptions(t *testing.T, path string) map[string]bool { + t.Helper() + lines := strings.Split(codeOnly(readFile(t, path)), "\n") + + argumentLoop := regexp.MustCompile(`^while\s+.*\$#.*;\s*do$`) + caseOpener := regexp.MustCompile(`^case\s+.*\sin$`) + + loop := -1 + for number, line := range lines { + if !argumentLoop.MatchString(strings.TrimSpace(line)) { + continue + } + if loop >= 0 { + t.Fatalf("%s porte deux boucles « while … $# … ; do » (lignes %d et %d) : ce "+ + "lecteur ne sait plus laquelle analyse les options", path, loop+1, number+1) + } + loop = number + } + if loop < 0 { + t.Fatalf("%s n'a plus de boucle « while … $# … ; do » : ce lecteur ne sait plus où "+ + "sont ses options, et il vaut mieux le dire que rendre une liste vide", path) + } + + opener := -1 + for number := loop + 1; number < len(lines); number++ { + if caseOpener.MatchString(strings.TrimSpace(lines[number])) { + opener = number + break + } + if strings.TrimSpace(lines[number]) == "done" { + break + } + } + if opener < 0 { + t.Fatalf("la boucle d'arguments de %s (ligne %d) n'ouvre aucun « case … in » : ce "+ + "lecteur ne sait plus où sont ses options", path, loop+1) + } + + nested := caseOpener + label := regexp.MustCompile(`^([-*][^()\s]*)\)`) + + options := map[string]bool{} + catchAll, closed, depth := false, false, 0 + for _, line := range lines[opener:] { + trimmed := strings.TrimSpace(line) + switch { + case nested.MatchString(trimmed): + depth++ + continue + case trimmed == "esac": + depth-- + if depth == 0 { + closed = true + } + } + if closed { + break + } + if depth != 1 { + continue + } + match := label.FindStringSubmatch(trimmed) + if match == nil { + continue + } + canonical := "" + for _, alias := range strings.Split(match[1], "|") { + alias = strings.TrimSuffix(alias, "=*") + switch { + case alias == "*": + catchAll = true + case strings.HasPrefix(alias, "--") || canonical == "": + canonical = alias + } + } + if canonical != "" { + options[canonical] = true + } + } + if !closed { + t.Fatalf("le « case » des options de %s ne se referme pas : l'extraction n'a pas lu ce "+ + "qu'elle croit avoir lu", path) + } + if !catchAll { + t.Fatalf("le « *) » final du « case » des options de %s n'a pas été vu : l'extraction "+ + "s'est arrêtée avant la fin du bloc et rend une liste incomplète", path) + } + return options +} + +// hashTableKeys reads the keys of a PowerShell hash table literal. +// +// bootstrap.ps1 builds ONE table and splats it, which is what keeps the secret out of every +// command line; its keys are therefore the list of what is handed over. The opposite +// direction is read the same way in bootstrap_test.go — that one asks whether install.ps1 +// declares each key, this one asks whether each parameter is a key. +func hashTableKeys(t *testing.T, path, variable string) map[string]bool { + t.Helper() + script := codeOnly(readFile(t, path)) + table := regexp.MustCompile(`(?s)\$` + regexp.QuoteMeta(variable) + `\s*=\s*@\{(.*?)\n\}`). + FindStringSubmatch(script) + if table == nil { + t.Fatalf("%s ne construit plus ses arguments dans une table $%s : ce banc ne sait plus "+ + "ce qui est passé à l'installeur", path, variable) + } + keys := map[string]bool{} + for _, key := range regexp.MustCompile(`(?m)^\s*(\w+)\s*=`).FindAllStringSubmatch(table[1], -1) { + keys[strings.ToLower(key[1])] = true + } + if len(keys) == 0 { + t.Fatalf("la table $%s de %s est vide : rien n'est relayé", variable, path) + } + return keys +} + +// handedOver answers whether bootstrap.sh puts an option back into install.sh's argument list. +func handedOver(script, option string) bool { + for _, line := range strings.Split(codeOnly(script), "\n") { + if strings.Contains(line, "set --") && strings.Contains(line, option) { + return true + } + } + return false +} + +// sortedKeys and sortedNames make a failure list stable: a bench whose message changes order +// from one run to the next reads like a second defect. +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedNames(values map[string]bool) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/deploy/powershell_test.go b/deploy/powershell_test.go index 6a1444a..86d303f 100644 --- a/deploy/powershell_test.go +++ b/deploy/powershell_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -395,6 +396,201 @@ func decodeCP1252(raw []byte) (string, bool) { return text.String(), differs } +// TestTheSecretPipeCarriesTheBytesTheBinaryWillRead is the one thing about install.ps1 that +// no reading of its text can settle: what actually comes out of a pipe into a native +// process. +// +// install.ps1 pushes the administration password into `openscale config password` on the +// STANDARD INPUT, because an argument would be readable in the process list. Two measured +// faults live on that pipe, and neither shows on the screen: +// +// - $OutputEncoding is us-ascii under Windows PowerShell 5.1, so every accent becomes +// « ? » — 3F. A password hashed as « ? » walls the station off: what the volunteer +// types back afterwards never verifies, at the screen or at that same console. +// - a console in chcp 65001 lets $OutputEncoding follow [Console]::OutputEncoding, which +// carries a preamble — EF BB BF lands in front of the password. readSecretLine strips a +// leading mark on the Go side; this bench is about the PowerShell side doing its half. +// +// So the script is SOURCED and its own function called, rather than the gesture reproduced: +// what is measured is the shipped Set-NativeOutputEncoding, on the PowerShell of the +// station. The child process is a PowerShell reading its raw standard input, which is a +// native process exactly like openscale.exe from the parent's point of view — and unlike +// openscale.exe, it is here without a build. +func TestTheSecretPipeCarriesTheBytesTheBinaryWillRead(t *testing.T) { + // Accentué ET hors ASCII, comme « clé » ou « poirée » qu'une coopérative française + // écrira sans y penser. + const secret = "clé-à-péser" + + common, err := filepath.Abs(filepath.Join("windows", "common.ps1")) + if err != nil { + t.Fatalf("chemin de common.ps1 : %v", err) + } + + for _, shell := range powershellPaths(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + directory := t.TempDir() + child := filepath.Join(directory, "read-stdin.ps1") + writeScript(t, child, `$ErrorActionPreference = 'Stop' +$received = New-Object System.IO.MemoryStream +[Console]::OpenStandardInput().CopyTo($received) +[BitConverter]::ToString($received.ToArray()).Replace('-', '') +`) + + harness := filepath.Join(directory, "pipe.ps1") + writeScript(t, harness, `$ErrorActionPreference = 'Stop' +. `+quoteForPowerShell([]string{common})+` +$secret = '`+secret+`' +$child = `+quoteForPowerShell([]string{child})+` +$shell = `+quoteForPowerShell([]string{shell})+` +function Invoke-Pipe { + ($secret | & $shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $child) -join '' +} + +$global:OutputEncoding = New-Object System.Text.ASCIIEncoding +Write-Output "ASCII=$(Invoke-Pipe)" + +$global:OutputEncoding = New-Object System.Text.UTF8Encoding($true) +Write-Output "MARQUE=$(Invoke-Pipe)" + +Set-NativeOutputEncoding +Write-Output "POSE=$(Invoke-Pipe)" + +Write-Output "ATTENDU=$([BitConverter]::ToString([Text.Encoding]::UTF8.GetBytes($secret)).Replace('-', ''))" +`) + + output, err := runPowerShell(t, shell, harness) + if err != nil { + t.Fatalf("le banc du tube n'a pas tourné sous %s :\n%s", shell, output) + } + measured := measurementsOf(output) + for _, key := range []string{"ASCII", "MARQUE", "POSE", "ATTENDU"} { + if measured[key] == "" { + t.Fatalf("le banc n'a pas mesuré « %s » :\n%s", key, output) + } + } + t.Logf("us-ascii : %s\nchcp 65001: %s\nposé : %s\nattendu : %s", + measured["ASCII"], measured["MARQUE"], measured["POSE"], measured["ATTENDU"]) + + // The line separator PowerShell adds after the object it pipes is not part of the + // password: `readSecretLine` reads up to it and trims it. + posed := strings.TrimSuffix(strings.TrimSuffix(measured["POSE"], "0A"), "0D") + if posed != measured["ATTENDU"] { + t.Errorf("le tube a livré %s, attendu %s : le mot de passe haché ne serait pas "+ + "celui qui a été tapé", posed, measured["ATTENDU"]) + } + if strings.HasPrefix(posed, "EFBBBF") { + t.Errorf("le tube a livré une marque d'ordre des octets en tête (%s)", posed) + } + if strings.Contains(posed, "3F") { + t.Errorf("le tube a livré un « ? » (3F) : un accent a été perdu — %s", posed) + } + + // La preuve que le geste sert : sans lui, ces deux consoles-là livrent autre chose. + // Elles sont journalisées plutôt qu'exigées, parce que le défaut dépend de la + // version de PowerShell — et un banc qui EXIGERAIT le défaut deviendrait rouge le + // jour où Microsoft le corrige, sur un script qui, lui, marche. + for what, key := range map[string]string{ + "us-ascii ($OutputEncoding par défaut de 5.1)": "ASCII", + "UTF-8 avec préambule (chcp 65001)": "MARQUE", + } { + if measured[key] == measured["POSE"] { + t.Logf("%s livre déjà les mêmes octets sous %s : cette console n'a pas ce "+ + "défaut-là, le réglage n'y coûte rien", what, filepath.Base(shell)) + } + } + }) + } +} + +// TestNobodyIsAskedWhenNobodyIsThere is the regression of a bench that hung for two +// minutes, and it is the shape the defect would take on a station. +// +// install.ps1 asks three questions, and `Read-Host -AsSecureString` does NOT read a +// redirected standard input: it reads the console, which has nothing to give. Measured on +// this machine on 10/08/2026 — a harness fed from a file printed its first line and never +// came back. [Environment]::UserInteractive was TRUE the whole time, because the session +// of a technician who pipes something into a script is still an interactive session. +// +// So the guard is exercised rather than read: the harness below runs Test-Interactive in +// the two conditions an automated installation actually arrives in, and both have to +// answer « personne ». A guard that answered « quelqu'un » there would not fail this +// bench — it would hang it, which is exactly what it does to an installation. +func TestNobodyIsAskedWhenNobodyIsThere(t *testing.T) { + common, err := filepath.Abs(filepath.Join("windows", "common.ps1")) + if err != nil { + t.Fatalf("chemin de common.ps1 : %v", err) + } + + for _, shell := range powershellPaths(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + directory := t.TempDir() + harness := filepath.Join(directory, "interactive.ps1") + writeScript(t, harness, `$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. `+quoteForPowerShell([]string{common})+` +Write-Output "SESSION=$([Environment]::UserInteractive)" +Write-Output "ENTREE_REDIRIGEE=$([Console]::IsInputRedirected)" +Write-Output "REPONSE=$(Test-Interactive)" +`) + + // L'ENTRÉE REDIRIGÉE, SANS -NonInteractive : c'est le cas mesuré, et le seul + // contrôle qui peut le voir est [Console]::IsInputRedirected. Le banc vérifie + // d'abord qu'il REPRODUIT la condition — sans quoi il ne mesurerait rien. + answers := filepath.Join(directory, "reponses.txt") + if err := os.WriteFile(answers, []byte("2\nPoste 2\n"), 0o644); err != nil { + t.Fatalf("écriture des réponses : %v", err) + } + piped := exec.Command(shell, "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", harness) + input, err := os.Open(answers) + if err != nil { + t.Fatalf("ouverture de %s : %v", answers, err) + } + defer input.Close() + piped.Stdin = input + raw, err := piped.CombinedOutput() + if err != nil { + t.Fatalf("le banc de Test-Interactive n'a pas tourné :\n%s", raw) + } + measured := measurementsOf(string(raw)) + if measured["ENTREE_REDIRIGEE"] != "True" { + t.Fatalf("ce banc ne redirige pas l'entrée standard du script : il ne mesure "+ + "donc pas la condition qui a bloqué install.ps1\n%s", raw) + } + t.Logf("entrée redirigée : session interactive = %s, entrée redirigée = %s", + measured["SESSION"], measured["ENTREE_REDIRIGEE"]) + if measured["REPONSE"] != "False" { + t.Errorf("Test-Interactive répond %q alors que l'entrée standard est un "+ + "fichier : « Read-Host -AsSecureString » n'y lit rien et attend pour "+ + "toujours\n%s", measured["REPONSE"], raw) + } + + // Et « powershell -NonInteractive », que runPowerShell passe déjà : Read-Host y + // lève au lieu d'attendre, ce qui arrête une installation automatique sur une + // question plutôt que de la bloquer — moins grave, tout aussi inutile. + output, err := runPowerShell(t, shell, harness) + if err != nil { + t.Fatalf("le banc de Test-Interactive n'a pas tourné :\n%s", output) + } + if answer := measurementsOf(output)["REPONSE"]; answer != "False" { + t.Errorf("Test-Interactive répond %q sous -NonInteractive\n%s", answer, output) + } + }) + } +} + +// measurementsOf reads the « CLÉ=valeur » lines a bench harness printed. +func measurementsOf(output string) map[string]string { + measured := map[string]string{} + for _, line := range strings.Split(output, "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if found { + measured[key] = value + } + } + return measured +} + // TestEveryPowerShellScriptParses is the syntactic check: a script with a typo in it is a // station half-installed, and the typo is found by whoever runs it as administrator on a // Saturday morning. diff --git a/deploy/windows/bootstrap.ps1 b/deploy/windows/bootstrap.ps1 index 41f06c5..0bd0526 100644 --- a/deploy/windows/bootstrap.ps1 +++ b/deploy/windows/bootstrap.ps1 @@ -64,6 +64,23 @@ utilisateur de la machine lit dans la liste des processus. En mode scripté, la console doit donc être déjà élevée. +.PARAMETER AdminPassword + Le mot de passe d'ADMINISTRATION du poste, celui qui donne le droit d'en changer les + réglages. Il est simplement relayé à install.ps1, qui le demande lui-même en saisie + masquée quand il ne l'a pas. + + ★ IL INTERDIT L'AUTO-ÉLÉVATION POUR LA MÊME RAISON QUE -AccountPassword, et la règle est + celle-là plutôt que la liste : tout paramètre de ce script dont le nom finit par + « Password » porte un secret, et aucun secret ne part sur une ligne de commande. + +.PARAMETER StationNumber + Le numéro de ce poste dans la coopérative. Relayé à install.ps1, qui le demande quand il + ne l'a pas. Ce n'est pas un secret : il est imprimé sur la fiche d'installation, et il + voyage donc sans réserve par l'auto-élévation. + +.PARAMETER StationName + Le nom que lisent les bénévoles. Mêmes règles que -StationNumber. + .PARAMETER Pilot Service en démarrage manuel : l'application Access reste relançable en deux minutes. @@ -92,6 +109,9 @@ [CmdletBinding()] param( [string]$AccountPassword, + [string]$AdminPassword, + [int]$StationNumber, + [string]$StationName, [switch]$Pilot, [switch]$SkipAutoLogon, [switch]$Yes, @@ -215,12 +235,14 @@ Write-Host '==================================================================== if (-not (Test-Elevated)) { # ★ LE REFUS, ET SA RAISON. Relancer une fenêtre élevée fait passer les paramètres par # une ligne de commande, visible dans la liste des processus par n'importe quel - # utilisateur de la machine. Un mot de passe n'y va pas. - if ($AccountPassword) { - throw 'avec -AccountPassword, la console doit DEJA etre ouverte en administrateur : ' + - 'l''auto-elevation ferait passer le mot de passe par une ligne de commande, ou il ' + - 'est lisible par tout le monde. Menu Demarrer, tapez powershell, clic droit, ' + - 'Executer en tant qu''administrateur.' + # utilisateur de la machine. Un mot de passe n'y va pas — aucun des deux, et la règle est + # « tout paramètre dont le nom finit par Password » plutôt qu'une liste de deux noms + # qu'un troisième secret n'aurait aucune raison de rejoindre. + if ($AccountPassword -or $AdminPassword) { + throw 'avec un mot de passe en parametre, la console doit DEJA etre ouverte en ' + + 'administrateur : l''auto-elevation ferait passer ce secret par une ligne de ' + + 'commande, ou il est lisible par tout le monde. Menu Demarrer, tapez powershell, ' + + 'clic droit, Executer en tant qu''administrateur.' } # ★ CE NOM N'EST PAS « $relaunched », ET C'EST LA PANNE DU 07/08/2026. Les noms de @@ -243,6 +265,10 @@ if (-not (Test-Elevated)) { if ($Pilot) { $arguments += '-Pilot' } if ($SkipAutoLogon) { $arguments += '-SkipAutoLogon' } if ($Yes) { $arguments += '-Yes' } + # Le numero et le nom du poste ne sont pas des secrets : ils sont imprimes sur la fiche + # d'installation et lus a l'ecran d'administration. Ils passent donc par la relance. + if ($StationNumber) { $arguments += @('-StationNumber', "$StationNumber") } + if ($StationName) { $arguments += @('-StationName', $StationName) } if ($Version) { $arguments += @('-Version', $Version) } if ($InstallDir) { $arguments += @('-InstallDir', $InstallDir) } if ($DataRoot) { $arguments += @('-DataRoot', $DataRoot) } @@ -367,7 +393,16 @@ elseif ($requestedInstallDir) { Get-OpenScalePaths -InstallDir $requestedInstall elseif ($requestedDataRoot) { Get-OpenScalePaths -DataRoot $requestedDataRoot } else { Get-OpenScalePaths } -# --- 6. Les trois questions --------------------------------------------------------- +# --- 6. Les questions qui appartiennent à ce script ----------------------------------- +# TROIS ICI, TROIS DANS install.ps1, et le partage n'est pas arbitraire : celles-ci portent +# sur la MACHINE — le compte Windows, le mode de démarrage, l'ouverture de session — et se +# posent avant que rien ne soit installé. Celles de l'installeur portent sur le POSTE — son +# numéro, son nom, son mot de passe d'administration — et s'appliquent à un config.json qui +# n'existe pas encore à ce stade. Une réponse recueillie ici pour un fichier qui n'existe +# pas serait une réponse gardée en mémoire en espérant que la suite se passe bien. +# +# -Yes traverse : sans lui, l'installeur poserait les siennes et une installation scriptée +# attendrait indéfiniment quelqu'un. if (-not $Yes -and -not [Environment]::UserInteractive) { throw 'aucune console interactive : ce script ne peut pas poser ses questions. ' + 'Relancez-le avec -Yes, ou donnez ses reponses en parametres.' @@ -375,7 +410,7 @@ if (-not $Yes -and -not [Environment]::UserInteractive) { if (-not $Yes) { Write-Host '' - Write-Host ' Trois questions, puis l''installation se deroule seule.' + Write-Host ' Trois questions sur cette machine. L''installeur en posera trois sur le poste.' Write-Host '' } @@ -436,6 +471,10 @@ if (-not $Yes -and -not $SkipAutoLogon) { # un script appelé par l'opérateur d'appel, dans le processus où elle vient d'être saisie. $installerArguments = @{ AccountPassword = $AccountPassword + AdminPassword = $AdminPassword + StationNumber = $StationNumber + StationName = $StationName + Yes = $Yes Pilot = $Pilot SkipAutoLogon = $SkipAutoLogon InstallDir = $requestedInstallDir @@ -450,14 +489,21 @@ foreach ($key in @($installerArguments.Keys)) { # et un paramètre qu'il ne déclare pas ferait échouer l'appel sur « Impossible de trouver un # paramètre correspondant au nom … » APRÈS le téléchargement, l'extraction et les trois # questions — le pire moment pour perdre un bénévole. On le retire, et on le dit. +# +# Ce que l'option perdue coute est dit a chaque fois qu'il y a quelque chose a dire : « elle +# est ignoree » laisse un benevole devant un poste qui ne se comporte pas comme la notice. +$consequences = @{ + AccountPassword = 'le mot de passe du compte sera tire au sort et imprime sur la fiche.' + AdminPassword = 'le mot de passe d''administration se posera a l''ecran, avec le code de secours de la fiche.' + StationNumber = 'le numero du poste se posera a l''ecran d''administration.' + StationName = 'le nom du poste se posera a l''ecran d''administration.' +} $installerText = Get-Content -LiteralPath $installer -Raw foreach ($key in @($installerArguments.Keys)) { if ($installerText -notmatch "\`$$key\b") { $installerArguments.Remove($key) Write-Progression "la version $($release.tag_name) ne connait pas l'option -$key : elle est ignoree." - if ($key -eq 'AccountPassword') { - Write-Progression 'le mot de passe du compte sera tire au sort et imprime sur la fiche.' - } + if ($consequences.ContainsKey($key)) { Write-Progression $consequences[$key] } } } diff --git a/deploy/windows/common.ps1 b/deploy/windows/common.ps1 index 55f8247..94fd8a0 100644 --- a/deploy/windows/common.ps1 +++ b/deploy/windows/common.ps1 @@ -59,15 +59,30 @@ $script:AutoStartDelaySeconds = 20 # Le plancher d'un mot de passe posé à la main sur le compte Windows du poste. # -# QUATRE, et DÉLIBÉRÉMENT plus bas que les 8 caractères qu'« openscale config password » -# tient pour le mot de passe d'administration (§14.4). Les deux protègent des choses -# différentes : celui-là garde le droit de CHANGER le poste, celui-ci ouvre une session -# Windows SANS AUCUN DROIT, sur une machine en libre-service dans un magasin, dont l'accès -# physique vaut déjà l'accès administrateur (§15.2, et l'écart assumé de install.ps1 -# étape 3). Le rendre difficile ne protège rien et rend le poste inaccessible le samedi. +# QUATRE, parce que ce mot de passe n'ouvre qu'une SESSION WINDOWS SANS AUCUN DROIT, sur +# une machine en libre-service dans un magasin, dont l'accès physique vaut déjà l'accès +# administrateur (§15.2, et l'écart assumé de install.ps1 étape 3). Il ne garde rien +# d'autre : ce compte ne peut ni installer, ni lire la base, ni ouvrir l'administration. +# Le rendre difficile ne protège donc rien, et rend le poste inaccessible le samedi où +# quelqu'un a fermé la session et où la fiche est restée au classeur. +# # C'est le plancher d'un mot de passe CHOISI ; le tirage automatique en fait toujours 20. $script:MinimumPasswordLength = 4 +# Le plancher du mot de passe d'ADMINISTRATION, celui qui donne le droit de changer le +# poste (§14.4). +# +# CE CHIFFRE N'EST PAS D'ICI : l'autorité est web.MinPasswordLength, dans +# internal/web/argon2id.go, que l'écran de secours et « openscale config password » +# appliquent tous les deux. Il est recopié parce que PowerShell ne sait pas lire une +# constante Go, et le banc TestTheInstallerAppliesTheAdministrationFloorTheBinaryHolds +# tient les deux ensemble : le jour où le propriétaire du produit déplace le plancher, le +# banc devient rouge ici plutôt que la question de l'installeur ne devienne fausse. +# +# Il sert à refuser AVANT la confirmation plutôt qu'après : le binaire refuse la même +# chose, mais trois lignes plus loin, une fois le mot de passe tapé deux fois. +$script:MinimumAdminPasswordLength = 4 + # La suspension USB sélective : le GUID du sous-groupe « Paramètres USB » et celui du # réglage lui-même. Ils sont RECOPIÉS de docs/02-architecture.md §15.2 — on ne devine pas # un GUID. @@ -120,27 +135,48 @@ function Assert-Success { function Set-NativeOutputEncoding { <# .SYNOPSIS - Fait lire à PowerShell la sortie des exécutables natifs en UTF-8. + Met les DEUX SENS du tube avec un exécutable natif en UTF-8 sans marque. .DESCRIPTION - PowerShell 5.1 décode ce qu'écrit un exécutable natif avec la page de codes de la - CONSOLE — 850 sur un Windows français — alors qu'openscale.exe écrit de l'UTF-8. Sans - cet appel, « compilé » revient « compil├® » : les octets C3 A9 lus en CP850. - - Ce serait sans importance si ce texte restait à l'écran. Mais $version part dans le - journal d'installation ET sur la FICHE qu'on imprime et qu'on range dans le classeur du - magasin, à la ligne « Version installée ». - - UTF8Encoding($false) et non [Text.Encoding]::UTF8 : la seconde porte une marque d'ordre - des octets en préambule, que PowerShell écrirait en tête de sa propre sortie. - - L'affectation échoue quand le script tourne sans console attachée. Ce n'est pas une - raison d'interrompre une installation : sans console, personne ne lit le charabia. + Deux réglages, deux sens, et deux pannes différentes. Ils sont ensemble parce qu'ils + répondent à la même question — « en quels octets openscale.exe et PowerShell se + parlent-ils ? » — et qu'en séparer un le ferait oublier. + + CE QU'ON LIT — [Console]::OutputEncoding. PowerShell 5.1 décode ce qu'écrit un + exécutable natif avec la page de codes de la CONSOLE — 850 sur un Windows français — + alors qu'openscale.exe écrit de l'UTF-8. Sans ce réglage, « compilé » revient + « compil├® » : les octets C3 A9 lus en CP850. Ce serait sans importance si ce texte + restait à l'écran, mais $version part dans le journal d'installation ET sur la FICHE + qu'on imprime et qu'on range dans le classeur, à la ligne « Version installée ». + + CE QU'ON ÉCRIT — $OutputEncoding, qui gouverne ce que PowerShell pousse dans l'ENTRÉE + STANDARD d'un processus natif. Sous Windows PowerShell 5.1 il vaut us-ascii : tout + accent envoyé par un tube devient « ? ». C'est par ce tube que le mot de passe + d'administration part vers « openscale config password », et un mot de passe accentué + haché avec des « ? » mure le poste — ce que le bénévole retape ensuite à l'écran ne + vérifiera jamais, et rien des deux côtés ne dira pourquoi. + + UTF8Encoding($false) DANS LES DEUX CAS, et le $false n'est pas décoratif : + [Text.Encoding]::UTF8 porte une marque d'ordre des octets en préambule. En lecture, + PowerShell l'écrirait en tête de sa propre sortie ; en écriture, elle arrive en tête de + l'entrée standard du binaire — EF BB BF collés devant le mot de passe. C'est ce que + fait une console en chcp 65001 quand $OutputEncoding suit [Console]::OutputEncoding. + Le binaire retire une marque de tête de son côté ; ce réglage-ci fait que la question + ne se pose pas. + + $global: et non une affectation nue : un point-source exécute chez son appelant, mais + une affectation faite DANS une fonction ouvre une variable locale à cette fonction, qui + meurt avec elle. + + Les affectations échouent quand le script tourne sans console attachée. Ce n'est pas + une raison d'interrompre une installation : sans console, personne ne lit le charabia. #> [CmdletBinding()] param() try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch { } + try { $global:OutputEncoding = New-Object System.Text.UTF8Encoding($false) } + catch { } } function Write-Step { @@ -196,6 +232,149 @@ function New-RandomPassword { -join ($bytes | ForEach-Object { $alphabet[$_ % $alphabet.Length] }) } +function ConvertTo-PlainText { + <# + .SYNOPSIS + Le contenu d'une saisie masquée, en clair. + .DESCRIPTION + Ce que « -AsSecureString » achète n'est pas un secret gardé : le mot de passe du + compte finit de toute façon dans Winlogon\DefaultPassword et sur la fiche, et celui de + l'administration part dans l'entrée standard du binaire. C'est un mot de passe qui ne + s'affiche pas devant les clients pendant qu'on le tape. + + Le passage par Marshal est ce qui rend cette lecture possible sous WINDOWS POWERSHELL + 5.1 : « ConvertFrom-SecureString -AsPlainText » n'existe qu'à partir de PowerShell 7, + et un script d'installation qui ne tourne pas sur le PowerShell livré avec Windows ne + sert à rien. La mémoire non gérée est remise à zéro dans un finally. + + bootstrap.ps1 en porte un JUMEAU, et ce n'est pas un oubli : c'est le seul fichier du + projet qui vit hors de l'archive, il s'installe aussi contre une release antérieure + (-Version), et une fonction qu'il attendrait de common.ps1 manquerait à ce common.ps1 + là. Son en-tête tient le même raisonnement pour Test-Elevated. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][securestring]$Secure) + + $pointer = [IntPtr]::Zero + try { + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) + } + finally { + if ($pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } + } +} + +function Measure-CodePoint { + <# + .SYNOPSIS + Le nombre de points de code d'un texte, celui que le binaire compte. + .DESCRIPTION + TROIS UNITÉS SE DISPUTENT LE MOT « CARACTÈRE », et le plancher d'un mot de passe doit + être le même des deux côtés du tube. « .Length » compte des unités UTF-16 : « 𝄞 » en + fait DEUX. Le binaire compte des points de code — web.MinPasswordLength le dit dans sa + propre phrase — et l'écran en compte autant. Une quatrième porte qui compterait la + sienne accepterait à l'installation ce que le poste refuse ensuite, sans que rien nulle + part ne dise pourquoi. + + L'UTF-32 est ce qui rend le compte lisible : il code CHAQUE point de code sur quatre + octets, exactement, et il est le seul encodage de .NET dont ce soit vrai. Compter les + octets et diviser par quatre, c'est donc compter les points de code sans écrire soi-même + la reconnaissance des paires de substitution. + + L'écart ne se voit qu'au-delà du plan multilingue de base — émoji, écritures anciennes, + symboles musicaux —, donc il ne se verra pas sur un poste. Ce n'est pas une raison pour + en faire une quatrième règle : c'est ce qui fait qu'on ne le retrouverait jamais. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) + + [int]([Text.Encoding]::UTF32.GetByteCount($Text) / 4) +} + +function Test-Interactive { + <# + .SYNOPSIS + Dit s'il y a quelqu'un devant ce script, à qui poser une question. + .DESCRIPTION + UNE INVITE QUI BLOQUE UNE INSTALLATION AUTOMATIQUE EST UNE PANNE SILENCIEUSE : rien + n'échoue, rien ne se termine, et personne ne s'en aperçoit avant de venir voir pourquoi + le poste n'est toujours pas installé. Trois façons de n'avoir personne, et il faut les + trois — la première seule laissait passer la pire. + + [Environment]::UserInteractive est faux dans une station de fenêtres sans bureau : un + service, une tâche planifiée « qu'un utilisateur soit connecté ou non ». + + [Console]::IsInputRedirected est LA MOITIÉ QUI MANQUAIT, et elle est MESURÉE. Dans la + session d'un technicien qui redirige l'entrée standard — « install.ps1 < reponses.txt », + un appel depuis un autre script, un tube —, UserInteractive reste VRAI. Et + « Read-Host -AsSecureString » ne lit PAS ce tube : il lit la console, qui n'a rien à + donner. Vérifié sur cette machine le 10/08/2026 : le script s'est arrêté sur la + première invite et n'en est jamais ressorti. + + « powershell -NonInteractive » n'expose aucune propriété : il se lit sur la ligne de + commande du processus. Sans ce troisième contrôle, Read-Host y lève « Read and Prompt + functionality is not available », que $ErrorActionPreference = 'Stop' transforme en + installation interrompue — moins grave qu'un blocage, mais c'est quand même une + installation automatique qui échoue sur une question. + #> + [CmdletBinding()] + param() + + if (-not [Environment]::UserInteractive) { return $false } + if ([Console]::IsInputRedirected) { return $false } + foreach ($argument in [Environment]::GetCommandLineArgs()) { + if ($argument -match '^-+non?i(nteractive)?$') { return $false } + } + $true +} + +function Read-ConfirmedSecret { + <# + .SYNOPSIS + Un secret tapé deux fois, jamais affiché, rendu en clair. + .DESCRIPTION + LA CONFIRMATION EST LA MOITIÉ QUI COMPTE. Un mot de passe tapé de travers en saisie + masquée est un mot de passe que personne ne pourra plus jamais deviner : ni le + bénévole qui l'a tapé, ni la fiche, qui ne le porte pas. Le retaper est la seule façon + de savoir ce qu'on a tapé. + + La boucle ne rend la main que sur une réponse acceptable, et elle n'accepte pas le + vide : cette fonction sert les questions auxquelles l'installation a besoin d'une + réponse, et laisser passer une réponse vide est exactement ce qui laissait un poste à + moitié configuré. + + Le plancher arrive en paramètre plutôt qu'écrit ici : les deux mots de passe que ce + dépôt pose n'ont pas le même, pour des raisons écrites chacune à côté de sa constante, + en tête de ce fichier. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Prompt, + [Parameter(Mandatory)][int]$MinimumLength) + + while ($true) { + $typed = ConvertTo-PlainText (Read-Host $Prompt -AsSecureString) + if ($typed -eq '') { + Write-Host ' il en faut un : cette question n''a pas de réponse par défaut.' + continue + } + if ($typed -ne $typed.Trim()) { + Write-Host ' il commence ou finit par une espace : personne ne le retapera juste depuis la fiche.' + continue + } + if ((Measure-CodePoint -Text $typed) -lt $MinimumLength) { + Write-Host " trop court : $MinimumLength caractères au minimum." + continue + } + if ($typed -cne (ConvertTo-PlainText (Read-Host ' Confirmation' -AsSecureString))) { + Write-Host ' les deux saisies ne sont pas les mêmes.' + continue + } + return $typed + } +} + function Test-LocalCredential { <# .SYNOPSIS @@ -267,7 +446,7 @@ function Resolve-AccountPassword { throw '-AccountPassword commence ou finit par une espace : c''est un mot de passe que ' + 'personne ne retapera juste depuis la fiche' } - if ($Requested.Length -lt $script:MinimumPasswordLength) { + if ((Measure-CodePoint -Text $Requested) -lt $script:MinimumPasswordLength) { throw "-AccountPassword doit faire au moins $($script:MinimumPasswordLength) caractères" } return [pscustomobject]@{ Password = $Requested; Change = $true; Warning = '' @@ -534,13 +713,19 @@ function Get-RegistryValue { function Get-SnapshotValue { <# .SYNOPSIS - Lit une valeur d'instantané, ou $null quand la section ou la valeur manque. + Lit une valeur d'un document JSON relu, ou $null quand la section ou la valeur manque. .DESCRIPTION « Set-StrictMode -Version Latest » fait ÉCHOUER l'accès à une propriété absente d'un PSCustomObject. Un restore.json écrit par une version antérieure de l'installeur n'a pas les sections que la version d'aujourd'hui y met : sans cette fonction, désinstaller un poste installé il y a six mois s'arrête sur « The property cannot be found », et c'est la désinstallation — le geste qui doit toujours marcher — qui casse. + + install.ps1 s'en sert aussi pour lire config.json, et c'est le même piège : un fichier + de configuration écrit par une version antérieure du binaire, ou simplement abîmé, n'a + pas forcément la section qu'on vient y chercher. Le nom parle d'instantané parce que + c'est de là qu'elle vient ; ce qu'elle fait est vrai de tout document relu par + ConvertFrom-Json. #> [CmdletBinding()] param($Section, [Parameter(Mandatory)][string]$Name) @@ -938,8 +1123,15 @@ function Write-InstallSheet { .DESCRIPTION « C'est le livrable qui manque le plus souvent et qui coûte le plus cher quand il manque » (§15.2, étape 7). Elle porte le compte Windows et son mot de passe, le - numéro de poste, l'empreinte de configuration, la date — et le code de secours - d'administration, que §14.4 fait générer À L'INSTALLATION et imprimer ici. + numéro et le nom du poste, l'empreinte de configuration, la date — et le code de + secours d'administration, que §14.4 fait générer À L'INSTALLATION et imprimer ici. + + ELLE N'IMPRIME PAS LE MOT DE PASSE D'ADMINISTRATION, qui est pourtant posé à + l'installation depuis que l'installeur le demande. Ce n'est pas un oubli : cette + feuille part au classeur du magasin, et le mot de passe d'administration donne le + droit de CHANGER le poste — les prix, le gabarit d'étiquette, le dépôt suivi. Ce que + la fiche porte à sa place est ce dont on a besoin quand il est perdu : qui l'a posé, et + le code de secours. -RecoveryCode vide laisse la ligne à remplir à la main, ce qui reste vrai d'un poste dont le fichier portait déjà un code : c'est celui de la fiche précédente, et @@ -951,11 +1143,14 @@ function Write-InstallSheet { [Parameter(Mandatory)][string]$Account, [Parameter(Mandatory)][string]$Password, [string]$Fingerprint = '(à relever sur l''écran d''administration)', - [string]$StationNumber = '(à choisir dans l''assistant de premier démarrage)', + [string]$StationNumber = '(pas encore posé)', + [string]$StationName = '(pas encore posé)', [string]$Version = '(inconnue)', [string]$Address = 'http://127.0.0.1:8085', [string]$RecoveryCode = '', - [bool]$PasswordChanged = $true) + [bool]$PasswordChanged = $true, + [bool]$AdminPasswordPosed = $false, + [bool]$ScaleDisabled = $false) # Deux fiches côte à côte dans le classeur, et une seule ouvre la session : c'est la # fiche qui doit dire laquelle, pas le journal d'installation, qui reste sur le poste. @@ -966,6 +1161,36 @@ function Write-InstallSheet { " INCHANGÉ par cette réinstallation : la fiche déjà classée reste valable." } + # Le mot de passe d'administration est POSÉ à l'installation, et il n'est écrit nulle + # part — pas même ici. La fiche dit donc à qui le redemander, ce qui est la seule chose + # dont on ait besoin d'une feuille de papier pour se souvenir. + $adminLine = if ($AdminPasswordPosed) { + " POSÉ À L'INSTALLATION, par la personne qui a lancé l'installeur.`n" + + " Il n'est écrit NULLE PART : ni sur cette fiche, ni dans le journal`n" + + " d'installation, ni dans la configuration, qui n'en garde qu'une empreinte.`n" + + " Redemandez-le à cette personne. Perdu pour tout le monde, il se repose avec`n" + + " le code de secours ci-dessous, ou en ligne de commande (plus bas)." + } + else { + " PAS ENCORE POSÉ : l'installation n'a pas eu de quoi poser la question`n" + + " (installation scriptée, ou console sans clavier). Le premier geste qui`n" + + " change le poste le demandera, et c'est le CODE DE SECOURS ci-dessous qui`n" + + " ouvre cette porte-là." + } + + # L'écart d'empreinte d'un poste dont la balance n'est pas encore réglée. §15.5 fait + # comparer les quatre postes À L'ŒIL : un écart que la fiche n'annonce pas est un écart + # qu'on prend pour une panne, et qu'on « répare » en touchant à la configuration. + $fingerprintNote = if ($ScaleDisabled) { + "`n ATTENTION, LA BALANCE DE CE POSTE EST DÉCLARÉE ABSENTE. L'installation la`n" + + " désactive sur un poste neuf, où elle n'est encore ni branchée ni détectée.`n" + + " Tant qu'elle n'est pas remise en service — page « Matériel », « Détecter`n" + + " automatiquement », puis « Utiliser cette balance » sur le port qui répond —,`n" + + " CE POSTE N'AFFICHE PAS LA MÊME EMPREINTE QUE SES VOISINS, et ce n'est pas`n" + + " une panne. Elle rejoint celle du parc dès que la balance est déclarée." + } + else { '' } + # Le code n'existe en clair QU'ICI : le poste n'en garde que l'empreinte argon2id. $recoveryLine = if ([string]::IsNullOrWhiteSpace($RecoveryCode)) { " ........................................................`n" + @@ -1002,31 +1227,39 @@ $passwordLine CONFIGURATION Numéro de poste .......... $StationNumber + Nom du poste ............. $StationName Empreinte du fichier ..... $Fingerprint Les quatre postes doivent afficher la MÊME empreinte de 8 caractères. Elle se lit en bas de l'écran d'administration, ou avec : "$(Join-Path $script:InstallDir $script:BinaryName)" config fingerprint - ATTENTION, c'est normal : tant que le numéro de poste, la balance et - l'imprimante ne sont pas réglés, la configuration est incomplète, le poste - tourne en CONFIGURATION D'USINE et l'écran affiche une AUTRE empreinte que - celle ci-dessus. Les deux se rejoignent dès que l'assistant est terminé. + ATTENTION, c'est normal : tant que la balance, l'imprimante et le catalogue + ne sont pas réglés, la configuration est incomplète, le poste tourne en + CONFIGURATION D'USINE et l'écran affiche une AUTRE empreinte que celle + ci-dessus. Les deux se rejoignent dès que les réglages sont terminés. C'est à ce moment-là qu'on compare les quatre postes. +$fingerprintNote + +MOT DE PASSE D'ADMINISTRATION +$adminLine + Il protège le droit de CHANGER le poste — les prix, le gabarit d'étiquette, + le dépôt suivi. Le poste ne demande rien pour être REGARDÉ : toutes les + pages se lisent, et la question arrive au moment où l'on change quelque + chose. $script:MinimumAdminPasswordLength caractères au minimum. CODE DE SECOURS D'ADMINISTRATION $recoveryLine - C'est lui qui POSE le mot de passe d'administration la première fois, depuis - l'écran et sans ligne de commande. Le poste ne demande rien pour être - regardé : il demande au moment où l'on change quelque chose. + IL SERT À UN POSTE QUI N'A AUCUN MOT DE PASSE, et à lui seul : c'est là que + l'écran le demande, et nulle part ailleurs. 1. Bouton « Réglages » sur l'écran client : l'engrenage, tout à droite de la barre du bas. L'administration s'ouvre sur le Tableau de bord. 2. Colonne de gauche, page « Matériel », puis « Détecter automatiquement ». Ce premier geste qui change le poste est celui qui pose la question. 3. Le panneau « Ce poste n'a pas encore de mot de passe » demande ce code, - puis le mot de passe à poser. 8 caractères au minimum. - Si le mot de passe est perdu PLUS TARD, ce code ne se saisit plus à l'écran — - il n'y est demandé qu'à un poste qui n'a aucun mot de passe. La reprise en - main passe alors par la ligne de commande, sur le poste, en administrateur : + puis le mot de passe à poser. $script:MinimumAdminPasswordLength caractères au minimum. + Si le poste a DÉJÀ un mot de passe — c'est le cas dès que l'installation a + posé le sien — ce code ne se saisit plus à l'écran. La reprise en main passe + alors par la ligne de commande, sur le poste, en administrateur : Stop-Service $script:ServiceName "$(Join-Path $script:InstallDir $script:BinaryName)" config password Start-Service $script:ServiceName diff --git a/deploy/windows/install.ps1 b/deploy/windows/install.ps1 index e49e298..8c31357 100644 --- a/deploy/windows/install.ps1 +++ b/deploy/windows/install.ps1 @@ -16,7 +16,12 @@ l'étape 2, qui le nomme : dans l'ordre inverse, icacls échoue sur un principal inexistant, l'échec passe inaperçu — c'est un exécutable natif — et l'ACL décrite comme obligatoire n'est jamais posée. - 2. Arborescence + ACL. + 2. Arborescence + ACL, puis le binaire, la configuration livrée — et LES TROIS + CHOSES QUE SEULE L'INSTALLATION PEUT DEMANDER : le numéro de ce poste, son nom + et le mot de passe d'administration. Sans elles, le poste sortait de l'installeur + avec trois fautes, en configuration d'usine, et sans autre porte vers ses réglages + que le code de secours de la fiche. La balance d'un poste NEUF est déclarée + absente au passage : elle n'est encore ni branchée ni détectée. 3. OUVERTURE DE SESSION AUTOMATIQUE — écrite ici, pas déléguée à un humain (bloquant-7). C'est ce qui fait revenir le poste sur l'écran client après une coupure de courant. @@ -52,18 +57,57 @@ Il finit dans l'historique du shell : à taper sur un poste, pas dans un script partagé. +.PARAMETER AdminPassword + Le mot de passe d'ADMINISTRATION du poste — celui qui donne le droit de changer les + prix, l'étiquette et le catalogue. Rien à voir avec -AccountPassword, qui n'ouvre + qu'une session Windows sans droits. + + Absent, il est DEMANDÉ en saisie masquée, tapé deux fois. Il n'est imprimé nulle part, + pas même sur la fiche d'installation : le poste n'en garde qu'une empreinte argon2id, et + ce qui reste sur le papier est le code de secours, qui rouvre la porte d'un poste qui + n'en a aucun. + + Il ne part JAMAIS sur une ligne de commande vers le binaire : il lui est poussé par + l'ENTRÉE STANDARD, parce qu'un argument est lisible dans la liste des processus par + n'importe quel utilisateur de la machine. + + MAIS L'ARGUMENT QUI ARRIVE ICI L'EST AUSSI, et c'est le sens de l'avertissement imprimé + au moment où ce paramètre est reçu : ce script ne peut pas réécrire son propre argv. + Pour une installation scriptée, préférez la variable d'environnement + OPENSCALE_ADMIN_PASSWORD, que ce script lit puis EFFACE aussitôt — l'équivalent exact de + ce que fait deploy/linux/install.sh, et la parité des deux installeurs est tenue par + deploy/parity_test.go. + +.PARAMETER StationNumber + Le numéro de ce poste dans la coopérative. C'est de lui que dérive le nom du fichier de + catalogue surveillé, flv_.csv. Absent, il est demandé — sauf sur un poste qui en a + déjà un, qu'une réinstallation ne touche pas. + +.PARAMETER StationName + Le nom que lisent les bénévoles, « Poste 2 — fruits ». Mêmes règles que -StationNumber. + +.PARAMETER Yes + Ne pose AUCUNE question et n'attend personne. C'est ce que passe une installation + scriptée ; sans ce commutateur, une console sans clavier est reconnue toute seule et les + questions sont sautées de la même façon. Ce qui n'a pas été demandé est écrit sur la + fiche d'installation comme restant à faire. + .EXAMPLE .\install.ps1 .EXAMPLE .\install.ps1 -Pilot .EXAMPLE - .\install.ps1 -AccountPassword 'poire-balance-samedi' + .\install.ps1 -AccountPassword 'poire-balance-samedi' -StationNumber 2 -StationName 'Poste 2 - fruits' #> [CmdletBinding()] param( [switch]$Pilot, [switch]$SkipAutoLogon, [string]$AccountPassword, + [string]$AdminPassword, + [int]$StationNumber, + [string]$StationName, + [switch]$Yes, [string]$InstallDir, [string]$DataRoot) @@ -95,6 +139,42 @@ if (-not (Test-Path $source)) { throw "openscale.exe est introuvable à côté de install.ps1 ($source). Décompressez l'archive complète." } +# La voie SANS argv, et elle est lue avant tout le reste : une variable d'environnement ne +# figure pas dans la liste des processus, là où un argument de ligne de commande y est +# lisible par n'importe quel compte de la machine. C'est la même voie que celle de +# deploy/linux/install.sh, et deploy/parity_test.go tient les deux ensemble. +# +# EFFACÉE TOUT DE SUITE, avant le premier processus fils : sans cela, chaque enfant de ce +# script — le binaire, icacls, schtasks, powercfg — en hériterait dans son propre +# environnement, et le secret vivrait aussi longtemps que le plus lent d'entre eux. +if (-not $AdminPassword -and $env:OPENSCALE_ADMIN_PASSWORD) { + $AdminPassword = $env:OPENSCALE_ADMIN_PASSWORD + $env:OPENSCALE_ADMIN_PASSWORD = $null +} +elseif ($AdminPassword) { + # Dit AU MOMENT où c'est encore réparable, et pas seulement dans l'en-tête que personne + # ne relit : rien n'est installé, le mot de passe peut encore être changé sans reprendre + # le poste. Un shell ne peut pas réécrire son propre argv — le dire est tout ce qui est + # en notre pouvoir. + Write-Host " AVERTISSEMENT : -AdminPassword place le secret sur la ligne de commande, que" + Write-Host " tout utilisateur de la machine lit dans la liste des processus et que" + Write-Host " l'historique de PowerShell garde. La variable d'environnement" + Write-Host " OPENSCALE_ADMIN_PASSWORD évite le premier ; ne rien donner du tout, et" + Write-Host " répondre à la question, évite les deux." +} + +# Un -AdminPassword trop court est refusé ICI, avant la première écriture. Le binaire le +# refuserait de toute façon — c'est lui l'autorité, et common.ps1 dit d'où vient ce +# chiffre —, mais dix étapes plus loin, sur un poste dont le compte, l'ACL et le binaire +# sont déjà posés. Une installation qui échoue doit échouer avant d'avoir commencé. +# +# Et il compte ce que le binaire compte : des points de code, jamais des unités UTF-16. +# Le raisonnement entier est sur Measure-CodePoint, dans common.ps1. +if ($AdminPassword -and (Measure-CodePoint -Text $AdminPassword) -lt $script:MinimumAdminPasswordLength) { + throw "-AdminPassword doit faire au moins $script:MinimumAdminPasswordLength caractères : " + + "c'est le plancher qu'applique le poste lui-même, et rien n'a été installé." +} + Write-Step "installation d'$script:ProductName sur $env:COMPUTERNAME" $paths.LogFile # --- 0. Sauvegarde de ce qui va être écrasé (important-15) -------------------------- @@ -197,37 +277,155 @@ Assert-Success 'openscale --version' Write-Step "binaire installé : $version" $paths.LogFile $delivered = Join-Path $PSScriptRoot 'config-lacagette.json' +$configIsNew = $false if ((Test-Path $delivered) -and -not (Test-Path $paths.Config)) { # La configuration livrée est celle de §11.5 : les valeurs du site, SANS le bloc - # matériel. Elle est donc incomplète exprès — le numéro de poste, le port série et la - # file d'impression se règlent sur l'écran, à l'étape suivante de §15.5 — et le poste - # démarre en attendant sur le profil neutre en servant la liste de ses fautes (§11.3). + # matériel. Elle est donc incomplète exprès — le port série et la file d'impression se + # règlent sur l'écran, à l'étape suivante de §15.5 — et le poste démarre en attendant sur + # le profil neutre en servant la liste de ses fautes (§11.3). Ce que l'étape 2 ter + # ci-dessous lui ajoute est ce que l'écran ne peut PAS deviner : qui est ce poste. Copy-Item -Path $delivered -Destination $paths.Config -Force + $configIsNew = $true Write-Step "configuration livrée copiée dans $($paths.Config)" $paths.LogFile } elseif (Test-Path $paths.Config) { Write-Step "configuration existante conservée : $($paths.Config)" $paths.LogFile } -# --- 2 ter. Code de secours d'administration (§14.4, important-10) ----------------- +# Le fichier tel qu'il est AVANT que les trois étapes suivantes n'y touchent. Elles y +# lisent toutes les trois la même chose — ce qui est déjà posé, et qu'une réinstallation +# ne doit pas redemander — et le relire trois fois, c'est trois façons de répondre +# différemment à la même question. +$existingConfig = $null +if (Test-Path $paths.Config) { + try { $existingConfig = Get-Content -Path $paths.Config -Raw -Encoding UTF8 | ConvertFrom-Json } + catch { $existingConfig = $null } +} +$existingStation = Get-SnapshotValue $existingConfig 'station' +$existingAdmin = Get-SnapshotValue $existingConfig 'admin' + +# QUI PEUT ENCORE RÉPONDRE. Une installation scriptée — bootstrap.ps1 -Yes, un déploiement +# à distance, une tâche planifiée — n'a personne devant elle, et une invite y attend +# jusqu'à ce que quelqu'un s'aperçoive que le poste n'est toujours pas installé. Les deux +# cas restent distincts parce qu'ils n'ont pas la même cause : -Yes est un choix, une +# console sans personne est un fait, et Test-Interactive porte les trois façons de le +# constater — dont celle qui a bloqué un banc pendant deux minutes le 10/08/2026. +$askable = (-not $Yes) -and (Test-Interactive) + +# --- 2 ter. Qui est ce poste, et sa balance (§11.2, §15.5) ------------------------- +# CE QUE SEULE L'INSTALLATION PEUT DEMANDER. La configuration livrée est l'export de +# §11.5 : numéro 0, aucun nom, et une balance qui nomme son protocole sans nommer de port. +# Ces trois fautes faisaient démarrer le poste en configuration d'usine, et leur réparation +# était renvoyée à un écran qu'on n'atteint qu'avec le code de secours — celui de la fiche +# qu'on vient justement de ranger au classeur. +# +# LA BALANCE N'EST DÉSACTIVÉE QUE SUR UN POSTE NEUF, et le critère est « ce fichier vient +# d'être copié », rien d'autre. « scale.present = false » est la déclaration explicite de +# §11.2 : ce poste n'a pas de balance. C'est vrai d'un poste qui sort de l'archive, où rien +# n'a encore été branché ni détecté ; c'est faux d'un poste en service, dont relancer +# l'installeur ne doit surtout pas éteindre la balance. +# +# LES BORNES DU NUMÉRO NE SONT PAS ICI. C'est le contrôle 1 de §11.3, le binaire les +# applique, il refuse en français et il n'écrit rien : on redemande. Les réécrire dans ce +# script en ferait un second endroit à corriger, et le premier à mentir. +# +# ★ CE QUI DÉCIDE EST QU'UNE RÉPONSE A ÉTÉ DONNÉE, JAMAIS QUE L'ENTIER SOIT VRAI. « 0 » est +# une réponse — mauvaise, et c'est au binaire de le dire. PowerShell tient 0 pour FAUX : +# décider sur la valeur avalait le zéro en silence, --number ne partait pas, les bornes ne +# voyaient jamais rien, et le poste repartait en configuration d'usine sous un journal qui +# annonçait « identité posée ». Les deux réponses vivent donc dans des variables de TEXTE, +# où « vide » veut dire « personne n'a répondu » et où « 0 » est une réponse comme une autre. +# Le binaire fait le même partage côté Go, et le dit dans cmd/openscale/config.go : « --number +# 0 et pas de --number du tout sont le même entier ». +$numberIsMissing = -not (Get-SnapshotValue $existingStation 'number') +$nameIsMissing = [string]::IsNullOrWhiteSpace((Get-SnapshotValue $existingStation 'name')) +$numberAnswer = '' +if ($PSBoundParameters.ContainsKey('StationNumber')) { + $numberAnswer = "$StationNumber" +} +$nameAnswer = "$StationName".Trim() +if ($askable -and (($numberIsMissing -and $numberAnswer -eq '') -or ($nameIsMissing -and $nameAnswer -eq ''))) { + Write-Host '' + Write-Host ' CE POSTE — répondez, puis l''installation reprend seule.' +} +while ($true) { + if ($askable -and $numberIsMissing -and $numberAnswer -eq '') { + Write-Host '' + Write-Host ' Numéro de ce poste dans la coopérative' + Write-Host ' C''est de lui que dérive le nom du fichier de catalogue surveillé, flv_.csv.' + while ($true) { + $typed = (Read-Host ' Numéro').Trim() + if ($typed -match '^\d+$') { $numberAnswer = $typed; break } + Write-Host ' un nombre, et rien d''autre.' + } + } + if ($askable -and $nameIsMissing -and $nameAnswer -eq '') { + Write-Host '' + Write-Host ' Nom de ce poste' + Write-Host ' Ce que les bénévoles lisent sur l''écran d''administration : « Poste 2 - fruits ».' + while ($true) { + $nameAnswer = (Read-Host ' Nom').Trim() + if ($nameAnswer -ne '') { break } + Write-Host ' il en faut un : c''est ce qui distingue ce poste de ses voisins.' + } + } + + $identity = @() + if ($numberAnswer -ne '') { $identity += @('--number', $numberAnswer) } + if ($nameAnswer -ne '') { $identity += @('--name', $nameAnswer) } + if ($configIsNew) { $identity += '--no-scale' } + + if ($identity.Count -gt 0) { + & $paths.Binary config station $paths.Config @identity + if ($LASTEXITCODE -ne 0) { + # Sans personne pour retaper, un refus est un échec d'installation comme un autre : le + # poste ne doit pas s'annoncer installé avec un numéro que ses contrôles rejettent. + if (-not $askable) { Assert-Success 'openscale config station' } + Write-Step 'la réponse a été refusée — rien n''a été écrit, on recommence' $paths.LogFile + $numberAnswer = '' + $nameAnswer = '' + continue + } + } + + # ★ CE QUE LE JOURNAL DIT SE COMPOSE DE CE QUI A ÉTÉ TRANSMIS. Le compte des arguments ne + # le dit pas : sur un poste NEUF il porte toujours « --no-scale », si bien qu'une liste + # vide n'existait pas et que l'avertissement écrit pour le seul cas qui en a besoin — une + # installation scriptée où personne n'a répondu — ne pouvait pas s'afficher. Le journal + # annonçait alors une identité posée sur un poste qui n'avait ni numéro ni nom, et le + # découvrir un samedi coûte plus cher que le lire à l'installation. + $posed = @() + $unanswered = @() + if ($numberAnswer -ne '') { $posed += "numéro $numberAnswer" } + elseif ($numberIsMissing) { $unanswered += 'le numéro' } + if ($nameAnswer -ne '') { $posed += "nom « $nameAnswer »" } + elseif ($nameIsMissing) { $unanswered += 'le nom' } + + $said = if ($posed.Count -gt 0) { "identité du poste posée dans $($paths.Config) : $($posed -join ', ')" } + elseif ($unanswered.Count -gt 0) { "identité du poste NON posée : rien n'a été demandé ni transmis" } + else { "identité du poste inchangée : numéro et nom déjà posés dans $($paths.Config)" } + if ($unanswered.Count -gt 0) { + $said += " — il reste à régler sur l'écran d'administration : $($unanswered -join ' et ')" + } + Write-Step $said $paths.LogFile + break +} +$scaleWasDisabled = $configIsNew + +# --- 2 quater. Code de secours d'administration (§14.4, important-10) -------------- # « Un code de secours de 8 caractères est généré à l'installation, imprimé sur la fiche # d'installation et consigné dans le classeur du magasin. » C'est ici, et pas sur l'écran : -# un poste sort de l'installeur SANS mot de passe d'administration — la configuration -# livrée est l'export de §11.5, qui ne porte aucun secret — donc sans ce code il n'existe -# aucune porte d'entrée, ni écran ni ligne de commande, vers les pages qui écrivent la -# configuration. PowerShell ne sait pas produire une empreinte argon2id : le binaire le -# fait, et il est le seul à afficher le code en clair, une fois. +# le code est ce qui rouvre un poste QUI N'A AUCUN MOT DE PASSE, et c'est l'état d'un poste +# dont l'étape suivante n'a pas pu poser le sien — une installation scriptée, une console +# sans clavier. C'est aussi la seule reprise en main quand le mot de passe posé se perd. +# PowerShell ne sait pas produire une empreinte argon2id : le binaire le fait, et il est le +# seul à afficher le code en clair, une fois. # # Le code n'est JAMAIS écrit dans install.log : ce journal reste sur le poste, la fiche # part au classeur. $recoveryCode = '' if (Test-Path $paths.Config) { - $existing = '' - try { - $existing = (Get-Content -Path $paths.Config -Raw -Encoding UTF8 | - ConvertFrom-Json).admin.recovery_code_hash - } - catch { $existing = '' } + $existing = Get-SnapshotValue $existingAdmin 'recovery_code_hash' if ([string]::IsNullOrWhiteSpace($existing)) { $printed = & $paths.Binary config recovery-code $paths.Config @@ -249,6 +447,49 @@ if (Test-Path $paths.Config) { } } +# --- 2 quinquies. Mot de passe d'administration (§14.4) --------------------------- +# LE POSER ICI EST TOUT L'OBJET DE CETTE ÉTAPE. Jusqu'ici le poste sortait de l'installeur +# sans aucun mot de passe : le premier réglage ouvrait le panneau « Ce poste n'a pas encore +# de mot de passe », qui réclame le code de secours — donc la fiche, donc le classeur, donc +# quelqu'un qui sait où il est. Le code de secours ne disparaît pas pour autant : il reste +# le recours d'un poste dont le mot de passe est perdu, et la seule porte d'une +# installation scriptée, où personne n'était là pour répondre. +# +# ★ SUR L'ENTRÉE STANDARD, JAMAIS SUR LA LIGNE DE COMMANDE. Un argument se lit dans la +# liste des processus par n'importe quel utilisateur de la machine, et c'est la raison pour +# laquelle bootstrap.ps1 refuse de s'élever tout seul quand on lui a donné un secret. +# +# L'ENCODAGE EST FAIT, ET IL A DEUX MOITIÉS — Set-NativeOutputEncoding, appelée en tête de +# ce script : sans elle, $OutputEncoding vaut us-ascii sous Windows PowerShell 5.1 et tout +# accent poussé dans ce tube devient « ? », tandis qu'une console en chcp 65001 y colle une +# marque d'ordre des octets. Un mot de passe haché avec l'un ou l'autre mure le poste, et +# rien des deux côtés ne dit pourquoi. +# +# Le mot de passe n'est écrit NULLE PART : ni dans install.log, ni sur la fiche. +$adminPasswordPosed = -not [string]::IsNullOrWhiteSpace((Get-SnapshotValue $existingAdmin 'password_hash')) +if ($askable -and -not $AdminPassword -and -not $adminPasswordPosed) { + Write-Host '' + Write-Host ' Mot de passe d''administration de ce poste' + Write-Host ' Il protège le droit de CHANGER le poste : les prix, l''étiquette, le catalogue.' + Write-Host ' Rien à voir avec le mot de passe de la session Windows.' + Write-Host " $script:MinimumAdminPasswordLength caractères au minimum, tapé deux fois, et il ne s'affiche pas." + Write-Host ' Il n''est imprimé NULLE PART, pas même sur la fiche : prenez-en un que l''équipe' + Write-Host ' connaît. Oublié, il se repose avec le code de secours de la fiche.' + $AdminPassword = Read-ConfirmedSecret -Prompt ' Mot de passe' ` + -MinimumLength $script:MinimumAdminPasswordLength +} +if ($AdminPassword) { + $AdminPassword | + & $paths.Binary config password $paths.Config + Assert-Success 'openscale config password' + $AdminPassword = '' + $adminPasswordPosed = $true + Write-Step "mot de passe d'administration posé (il n'est écrit ni dans ce journal ni sur la fiche)" $paths.LogFile +} +elseif (-not $adminPasswordPosed) { + Write-Step "mot de passe d'administration NON posé : le premier réglage le demandera, avec le code de secours de la fiche" $paths.LogFile +} + # --- 3. Ouverture de session automatique (bloquant-7) ------------------------------ # C'était l'écueil le plus coûteux du plan précédent : l'installeur affichait « lancez # maintenant netplwiz », étape manuelle faite une fois et JAMAIS vérifiée ensuite. Après @@ -376,10 +617,25 @@ if (Test-Path $paths.Config) { $read = & $paths.Binary config fingerprint $paths.Config if ($LASTEXITCODE -eq 0 -and $read) { $fingerprint = $read.Trim() } } -$stationNumber = '(à choisir dans l''assistant de premier démarrage)' +# CE QUE LE FICHIER DIT, et non ce que ce script croit y avoir écrit. La fiche part au +# classeur et elle y reste des années : elle doit porter l'identité que le poste applique, +# y compris quand une réinstallation n'a rien redemandé parce que tout était déjà posé. +$posedConfig = $null +if (Test-Path $paths.Config) { + try { $posedConfig = Get-Content -Path $paths.Config -Raw -Encoding UTF8 | ConvertFrom-Json } + catch { $posedConfig = $null } +} +$posedStation = Get-SnapshotValue $posedConfig 'station' +$sheetNumber = "$(Get-SnapshotValue $posedStation 'number')" +$sheetName = "$(Get-SnapshotValue $posedStation 'name')" +if (-not $sheetNumber -or $sheetNumber -eq '0') { $sheetNumber = '(pas encore posé)' } +if (-not $sheetName) { $sheetName = '(pas encore posé)' } + Write-InstallSheet -Path $paths.InstallSheet -Account $script:AccountName -Password $password ` - -Fingerprint $fingerprint -StationNumber $stationNumber -Version "$version" -Address $address ` - -RecoveryCode $recoveryCode -PasswordChanged $decision.Change | Out-Null + -Fingerprint $fingerprint -StationNumber $sheetNumber -StationName $sheetName ` + -Version "$version" -Address $address ` + -RecoveryCode $recoveryCode -PasswordChanged $decision.Change ` + -AdminPasswordPosed $adminPasswordPosed -ScaleDisabled $scaleWasDisabled | Out-Null Write-Step "fiche d'installation écrite dans $($paths.InstallSheet)" $paths.LogFile Write-Host '' @@ -414,10 +670,26 @@ else { Write-Host " que le poste se relèvera d'une coupure de courant." } Write-Host " 3. Bouton « Réglages » sur l'écran client — l'engrenage, tout à droite de la" -Write-Host " barre du bas —, page « Matériel », « Détecter automatiquement » : le poste" -Write-Host " demande alors le code de secours de la fiche et le mot de passe" -Write-Host " d'administration à poser. Réglez ensuite la balance, l'imprimante et le" -Write-Host ' catalogue. Voir INSTALLATION.md.' +Write-Host " barre du bas —, page « Matériel », « Détecter automatiquement ». Le port où" +Write-Host " la balance répond porte alors un bouton « Utiliser cette balance » : c'est" +Write-Host " LUI qui la remet en service, avec son protocole et son port. Réglez ensuite" +Write-Host " l'imprimante et le catalogue. Voir INSTALLATION.md." +if ($adminPasswordPosed) { + Write-Host " Le mot de passe d'administration est POSÉ : le poste le demandera au premier" + Write-Host ' réglage. Il n''est écrit nulle part, pas même sur la fiche.' +} +else { + Write-Host " Ce poste n'a AUCUN mot de passe d'administration : le premier réglage ouvrira" + Write-Host ' le panneau qui réclame le CODE DE SECOURS de la fiche, puis le mot de passe.' +} +if ($scaleWasDisabled) { + # §15.5 fait comparer les empreintes des quatre postes À L'ŒIL. Un écart annoncé est une + # étape restante ; un écart qu'on découvre est une panne qu'on croit réparer en touchant + # à la configuration. + Write-Host " La balance de ce poste NEUF est déclarée absente tant qu'elle n'est pas" + Write-Host " détectée : son empreinte de configuration diffère donc de celle de ses" + Write-Host ' voisins, et les rejoint dès que la balance est remise en service.' +} if ($decision.Warning) { Write-Host '' Write-Host ' ATTENTION' diff --git a/docs/02-architecture.md b/docs/02-architecture.md index a15482b..ff14730 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -2896,7 +2896,9 @@ func (c *Config) Validate(reg Registries) []Fault > **Le profil neutre remplace ce sur quoi le poste TOURNE, jamais la façon dont on l'ATTEINT pour le réparer.** Le bloc `network` du fichier — `listen` **et** `admin_on_lan` — survit au repli au même titre que le bloc `admin`, et pour la même raison : l'adresse est celle qui figure sur la fiche d'installation, et c'est celle que le kiosque compose depuis ce même fichier, qu'une configuration fautive laisse parfaitement lisible ; `admin_on_lan` est ce qui permet à un bénévole d'arriver avec un portable plutôt qu'avec un clavier. Emprunter l'adresse du profil neutre déplaçait le service pendant que le kiosque continuait d'ouvrir l'adresse déclarée : **écran client noir sur le poste même que cette section existe pour garder vivant**, et écran d'administration refermé sur la boucle locale au moment où quelqu'un vient réparer. Le bloc est repris **entier ou pas du tout** : une adresse ouverte au réseau derrière une garde d'administration fermée serait plus difficile à diagnostiquer qu'un repli cohérent dans les deux sens. > -> **Le repli sur `127.0.0.1:8085` ne subsiste que dans un cas** : celui où une faute nomme le bloc `network` **lui-même** — un poste tout juste installé dont le champ est vide. Un repli qui recopierait une adresse inliable transformerait `ERR-CFG-01`, un poste qui sert sa liste de fautes, en `ERR-SYS-02`, un poste qui n'est pas là du tout. **« En cas de doute, 8085 » cesse donc d'être le repère qu'un exploitant pouvait appliquer partout** : un poste fautif ailleurs que sur son bloc réseau sert sur l'adresse que son fichier déclare, et c'est celle-là qu'il faut ouvrir. C'est aussi pourquoi `Test-StationHealth` (`deploy/windows/common.ps1`) sonde les deux adresses, et dans cet ordre. +> **Une adresse d'écoute VIDE n'est plus une faute, elle est un fichier qui ne dit rien (10/08/2026).** `Config.UnmarshalJSON` la ramène à celle du profil neutre, exactement comme il corrige `update.repository` vide (ADR-040) et `ui.min_products_for_chip` à zéro (ADR-059) — même mécanisme, même raison écrite : *un fichier qui ne dit rien tourne sur le défaut, et le refuser ici ferait refuser à un poste sa propre configuration livrée*. Le cas n'était pas théorique, c'était **le cas nominal** : `Export` remet tout le bloc `network` à zéro (§11.5), donc le fichier livré porte littéralement `"listen": ""`, et un poste neuf partait avec une faute que **rien à l'écran ne permettait de réparer** — or `PUT /admin/api/config` valide le document entier, si bien qu'une seule faute sans champ verrouillait l'administration **entière, sur toutes les pages**. Constaté à l'installation d'un poste de production le 10/08/2026, réparé à la main dans le fichier. Une adresse **présente et fausse** traverse le rattrapage intacte et se fait toujours refuser par le contrôle 2 — qui a désormais un champ à l'écran pour dire où taper la bonne (§14.4). +> +> **Le repli sur `127.0.0.1:8085` ne subsiste que dans un cas** : celui où une faute nomme le bloc `network` **lui-même** — désormais une adresse mal formée, et non plus un champ vide. Un repli qui recopierait une adresse inliable transformerait `ERR-CFG-01`, un poste qui sert sa liste de fautes, en `ERR-SYS-02`, un poste qui n'est pas là du tout. **« En cas de doute, 8085 » cesse donc d'être le repère qu'un exploitant pouvait appliquer partout** : un poste fautif ailleurs que sur son bloc réseau sert sur l'adresse que son fichier déclare, et c'est celle-là qu'il faut ouvrir. C'est aussi pourquoi `Test-StationHealth` (`deploy/windows/common.ps1`) sonde les deux adresses, et dans cet ordre. > > **`--listen` prime sur les deux**, et il est appliqué **après** la validation. Écrit avant, il était jugé comme si le fichier l'avait porté : une faute de frappe en ligne de commande faisait basculer un poste sain en « configuration d'usine » et était reprochée à `config.json`, tandis qu'une vraie faute sur l'adresse du fichier était silencieusement réparée pour la durée du lancement et revenait au redémarrage suivant, seule, devant quelqu'un qui se croyait quitte. Un `--listen` mal formé est désormais refusé **en nommant le drapeau**, par la règle du contrôle 2 elle-même (`domain.CheckListenAddress`) et non par une seconde implémentation qui dériverait. @@ -4241,7 +4243,7 @@ Contrastes **AAA** (≥ 7:1) sur tout texte ≥ 24 px, **AA** partout ailleurs | Règles | Grille de tarifs (code/libellé/abrégé/**remise en %**/ordre), **la ligne du tarif de référence en lecture seule pour son prix et modifiable pour ses mots**, les deux arrondis **avec l'explication de l'écart au centime**, les 14 garde-fous avec **leur seuil et leur message modifiables, leur sévérité en lecture seule** (§6.4), **aperçu en direct** (« à 8 g, ce produit serait refusé »), code-barres. **La liste des dérogations de poids minimum actives** — un produit nommé par ligne, avec son `min_weight_g`, sa date et son auteur —, en lecture ici et **modifiable depuis l'onglet Catalogue**, où se trouve le produit (§10.6). | | Catalogue | **Où le poste va chercher le catalogue** — répertoire local ou serveur WebDAV, avec le répertoire surveillé, ou l'adresse et le compte (§10.1, ADR-038) ; le mot de passe s'y saisit **en écriture seule**, laissé vide il ne bouge pas, et choisir WebDAV avertit **au moment du choix** que le dépôt d'un CSV depuis l'écran n'y existe plus · Dernier import et son **inventaire** · **liste des anomalies et des unités divergentes, chacune avec le nom du produit, son numéro de ligne du CSV, son motif en français et la valeur fautive** · **liste des non-pesables** (préemballés, sans code-barres), nommés eux aussi, présentée comme un **inventaire neutre** et jamais comme une liste d'erreurs · produits **retirés** depuis l'import précédent (§10.9) · historique des 20 derniers imports · **glisser-déposer d'un CSV (A4)** · **interrupteur « Ne plus proposer ce produit »** · **« Autoriser ce produit à peser moins de 10 g »** (dérogation par produit, §10.6) · « Oublier la quarantaine » · état de la source · **le panneau « Ce que la grille montre »** : l'interrupteur des produits vendus à l'unité, le **choix des colonnes de la grille** — avec la phrase chiffrée qui dit ce que le brouillon donnerait sur cet écran (ci-dessous, ADR-057) — et **le seuil de puce** (`ui.min_products_for_chip`, champ nombre, plancher 1, sans plafond, ADR-059), sans cette phrase chiffrée : rien n'annonce sur cet écran, avant l'enregistrement, si une catégorie va perdre sa puce. | | Journal | 200 dernières pesées, filtres, export CSV, détail **avec la trame brute** et bouton « Rejouer cette trame ». Journal technique en dessous. | -| Poste | Numéro, nom, coop, empreinte, export/import avec **aperçu du diff champ par champ**, 5 versions restaurables, version du binaire, chemins, espace disque. **Rubrique Maintenance** en bas de page : *relire le fichier de configuration* (sans coupure) · *redémarrer le poste* (~5 s) · *redémarrer l'ordinateur* (rouge, confirmation puis 30 s annulables). *(Aucun bloc de configuration n'exige toujours de redémarrage — §11.4, ADR-027 : ces trois gestes sont des réparations, pas des conséquences d'un réglage.)* | +| Poste | Numéro, nom, coop, **adresse d'écoute et `admin_on_lan`** (§11.4 : enregistrer déplace la socket sur-le-champ et laisse 60 s pour confirmer, sinon le poste revient seul à l'adresse précédente ; les deux champs vont **ensemble**, une adresse ouverte au réseau derrière une garde fermée étant le demi-bloc que le repli de §11.3 refuse déjà d'emprunter), empreinte, export/import avec **aperçu du diff champ par champ**, 5 versions restaurables, version du binaire, chemins, espace disque. **Rubrique Maintenance** en bas de page : *relire le fichier de configuration* (sans coupure) · *redémarrer le poste* (~5 s) · *redémarrer l'ordinateur* (rouge, confirmation puis 30 s annulables). *(Aucun bloc de configuration n'exige toujours de redémarrage — §11.4, ADR-027 : ces trois gestes sont des réparations, pas des conséquences d'un réglage.)* | > **Les colonnes de la grille se règlent dans le panneau « Ce que la grille montre »**, page Catalogue, sous l'interrupteur `ui.show_by_unit_products` (ADR-057). Pas de page neuve, pas de panneau neuf : la note du panneau dit déjà le statut exact de ce réglage — *« Un réglage d'affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser »* —, et le mot de passe vient avec la page (ADR-033). **Onze choix visibles d'un coup, et non une glissière** : ce sont des entiers qu'on nomme, et « Automatique » n'est pas un cran de plus au bout d'une course, c'est une **autre nature de réponse**. Revenir à l'automatique écrit `0` et n'efface jamais la clé. > @@ -4274,6 +4276,10 @@ Contrastes **AAA** (≥ 7:1) sur tout texte ≥ 24 px, **AA** partout ailleurs **Mot de passe oublié** (important-10) : un **code de secours de 8 caractères** est généré à l'installation, **imprimé sur la fiche d'installation** et consigné dans le classeur du magasin. Il permet de réinitialiser le mot de passe **depuis l'écran lui-même**, sans ligne de commande — indispensable sur un poste en Assigned Access où il n'y a plus ni bureau ni invite. `openscale config password` reste disponible en ligne de commande. +**Le plancher du mot de passe d'administration est de QUATRE caractères** (10/08/2026, arbitrage du propriétaire du produit, ADR-060). Il n'était écrit nulle part dans ces pages, et c'est ce qui a permis au chiffre d'exister en **six exemplaires** — deux en Go, un en TypeScript, un en Svelte, deux en PowerShell — qui pouvaient diverger sans que rien ne rougisse. L'autorité est désormais **une constante unique**, `web.MinPasswordLength` ; les copies qu'un autre langage impose sont **reliées à elle par un banc qui lit le fichier source**. Le comptage se fait en **points de code** aux quatre portes : `« é »` est un caractère au clavier et deux octets sur le fil, et la porte HTTP comptait des octets — elle acceptait donc un secret que la ligne de commande refusait. + +> **Ce que ce plancher protège, et ce qu'il ne protège pas.** Il se saisit sur l'**écran tactile du poste**, en libre-service, au clavier virtuel — pas depuis le réseau. Ce qu'il garde est le droit de *changer* le poste : grille de prix, garde-fous, gabarit d'étiquette, plan du code-barres. Ce qu'il ne garde pas, ce sont les dix actions de dépannage sans risque, délibérément laissées libres (ADR-018). **Un point est assumé plutôt que tu** : le limiteur de `admin.attempts_per_minute` remet son compteur à zéro à chaque fenêtre d'une minute écoulée, si bien qu'un rythme régulier ne verrouille **jamais** — environ 236 essais à l'heure depuis une même adresse. Une fenêtre glissante est un chantier à part, et le mélanger à celui-ci rendrait impossible de mesurer lequel des deux a servi. **Aucune garde de composition** n'est ajoutée : un refus qu'un bénévole du samedi ne comprend pas ne protège rien. + > **Qui tire ces huit caractères, et pourquoi ce n'est pas l'écran.** `install.ps1` les tire, par `openscale config recovery-code`, juste après avoir copié la configuration livrée — et il le fait parce qu'un poste sort de l'installeur **sans mot de passe d'administration** : la configuration livrée est l'export de §11.5, qui ne porte aucun secret. Sans ce code, il n'existe donc **aucune porte d'entrée** vers les pages qui écrivent la configuration, sur un poste dont la configuration est incomplète *par construction* et doit être terminée depuis ces pages-là. PowerShell ne sait pas produire une empreinte argon2id ; le binaire, si — et il est le seul à afficher le code **en clair, une fois**. Le code n'est écrit ni dans `install.log`, qui reste sur le poste, ni dans la configuration, qui n'en garde que l'empreinte : **la fiche est la seule copie**. Une réinstallation ne le fait pas tourner, sans quoi la fiche déjà rangée dans le classeur deviendrait fausse en silence. > > L'alphabet exclut `I`, `L`, `O`, `U`, `0` et `1` : ce code est recopié à la main depuis une feuille de papier, des mois plus tard, et la paire `O`/`0` explique à elle seule la plus grande partie de ce qu'une transcription perd. La saisie est comparée **en majuscules** — une touche Maj n'est pas un facteur d'authentification. @@ -4481,6 +4487,12 @@ sc.exe config Balance start= auto # ... or "demand" during the pilot period Assert-Success "sc.exe config Balance" sc.exe failure Balance reset= 86400 actions= restart/5000/restart/10000/restart/30000 Assert-Success "sc.exe failure Balance" +# ★ AND THE FLAG, WITHOUT WHICH THE LINE ABOVE COVERS ONLY CRASHES. Windows defaults it +# to FALSE, and false means "apply the recovery actions only if the service stops WITHOUT +# reporting SERVICE_STOPPED". The restart of ADR-055 stops cleanly and reports it: without +# this flag the station stops and nobody starts it again. +sc.exe failureflag Balance 1 +Assert-Success "sc.exe failureflag Balance" schtasks /create /tn "Balance-Kiosk" /xml "$PSScriptRoot\balance-kiosk.xml" /f Assert-Success "schtasks /create Balance-Kiosk" # InteractiveToken: NO password to supply @@ -4637,7 +4649,7 @@ Le kiosque : unité séparée, `ExecStart=/usr/bin/cage -d -- /usr/local/bin/bal ### 15.5 Installer, mettre à jour, désinstaller -**Installer un poste — 15 minutes** : décompresser · `install.ps1` en administrateur · **redémarrer et cocher « le poste est revenu seul sur l'écran client »** (recette obligatoire, bloquant-7) · appui long 3 s dans le coin bas-droit de l'écran client (§14.3) → *Réglages avancés* → **le code de secours de la fiche** pose le mot de passe d'administration *(l'assistant en 5 étapes de §14.4 n'est pas écrit ; le chemin existe, il n'est pas encore guidé)* · Balance → *Détecter automatiquement* · Imprimante → *Imprimer une étiquette de test* → **superposer avec une étiquette actuelle** → régler le décalage ±1 dot · Catalogue → **source et numéro de poste** *(pas de « chemin » à saisir : `local_drop` est créé par le service, et le nom du fichier dérive du numéro de poste — §11.2)* → *Importer maintenant* (ou glisser-déposer un CSV). Postes 2 à 4 : *Poste → Importer* la configuration du poste 1 **sans le bloc matériel**, puis les deux étapes matériel. **Vérifier que l'empreinte affichée est identique.** +**Installer un poste — 15 minutes** : décompresser · `install.ps1` en administrateur, **qui demande le mot de passe d'administration, le numéro et le nom du poste** (ADR-060) · **redémarrer et cocher « le poste est revenu seul sur l'écran client »** (recette obligatoire, bloquant-7) · appui long 3 s dans le coin bas-droit de l'écran client (§14.3) → *Réglages avancés* → **le mot de passe posé à l'installation** *(le code de secours de la fiche ne sert plus que si le poste n'en a aucun — installation scriptée — ou s'il est perdu ; l'assistant en 5 étapes de §14.4 n'est toujours pas écrit, mais il n'est plus nécessaire pour démarrer)* · Balance → *Détecter automatiquement* → **« Utiliser cette balance »** sur le port qui a répondu, qui déclare la présence, le protocole et le port d'un seul geste · Imprimante → *Imprimer une étiquette de test* → **superposer avec une étiquette actuelle** → régler le décalage ±1 dot · Catalogue → **source et numéro de poste** *(pas de « chemin » à saisir : `local_drop` est créé par le service, et le nom du fichier dérive du numéro de poste — §11.2)* → *Importer maintenant* (ou glisser-déposer un CSV). Postes 2 à 4 : *Poste → Importer* la configuration du poste 1 **sans le bloc matériel**, puis les deux étapes matériel. **Vérifier que l'empreinte affichée est identique.** **Mettre à jour — un bouton, page « Mise à jour »** (ADR-040). Le poste sonde une fois par jour l'API des publications du dépôt suivi (`update.repository`, contrôle 48) et porte une pastille au tableau de bord dès qu'une version plus récente existe. Le bouton télécharge l'archive de la plateforme, **vérifie son empreinte SHA-256** contre `SHA256SUMS-archives.txt`, l'extrait sous `/updates//`, écrit `pending.json` **avant** de rendre la main, et lance `update.ps1` détaché. Le service tourne en `LocalSystem` : il n'y a aucune élévation à obtenir. Le poste **refuse** de tomber pendant une pesée ou tant qu'un catalogue lu n'est pas entré en service — sa suppression valant acquittement, l'arrêter là le perdrait. Un poste **hors service**, lui, passe : c'est la sortie de secours d'une version cassée. @@ -5755,7 +5767,9 @@ Le Planificateur de tâches est dans le second cas, comme tout `CreateProcess` p **Trois choix portent tout le reste.** -1. **Le redémarrage du poste passe par l'arrêt ordonné de §13.4 et un code de sortie non nul.** Le SCM applique alors les reprises de §15.2, systemd son `Restart=always`. Aucun script neuf, aucun second chemin d'arrêt — et **c'est littéralement le redémarrage qu'ADR-027 appelle légitime** : celui que le superviseur déclenche. La station ne se redémarre pas, elle s'arrête. +1. **Le redémarrage du poste passe par l'arrêt ordonné de §13.4 et un code de sortie non nul.** systemd applique alors son `Restart=always`. Aucun script neuf, aucun second chemin d'arrêt — et **c'est littéralement le redémarrage qu'ADR-027 appelle légitime** : celui que le superviseur déclenche. La station ne se redémarre pas, elle s'arrête. + + **Amendement du 10/08/2026 — le code de sortie n'est que la moitié du mécanisme, et l'autre moitié manquait.** Cet ADR écrivait « le SCM applique alors les reprises de §15.2 ». **C'est faux sous Windows tant qu'un second réglage n'est pas posé.** Les reprises de `sc failure` ne s'appliquent, par défaut, qu'à un service qui s'arrête **sans** signaler `SERVICE_STOPPED` — c'est-à-dire à un plantage. Or l'arrêt ordonné de §13.4 se termine proprement et le signale : le SCM voyait un arrêt normal, n'appliquait aucune reprise, et **le poste restait éteint**. Constaté sur un poste de production le 10/08/2026 : le bouton reste sur « En cours… », le poste ne revient pas, l'écran client est noir. `sc failureflag 1` — `SetRecoveryActionsOnNonCrashFailures` — étend les reprises aux arrêts signalés ; `InstallService` le pose désormais avec les délais, et un banc refuse qu'il parte sans eux. **Un poste enregistré avant cette date n'est pas réparé par une mise à jour** : `service install` est le seul appelant, donc il faut relancer `install.ps1` en administrateur. Le défaut avait la forme de celui de §15.3 côté Linux — un geste annoncé à l'écran, refusé en silence par la plateforme. 2. **Le délai avant le redémarrage machine est applicatif**, sur l'horloge injectée, et non `shutdown /r /t 30`. Sous Linux `systemctl reboot` est immédiat et n'a rien à annuler : un délai porté par l'OS donnerait **deux comportements pour un bouton**. Et un délai porté par l'OS ne se teste pas sans redémarrer une machine, là où celui-ci s'arme, s'annule et échoit en microsecondes. 3. **La relecture n'écrit pas le fichier, et un retour arrière ne le réécrit pas non plus.** `writeConfig` fait l'inverse, à juste titre : là le document venait de l'écran, qui en garde une copie. Ici il vient de la main de quelqu'un, et l'écraser détruirait le seul exemplaire. Le fichier et la configuration en service divergent alors jusqu'à la relecture suivante — ce que §11.3 sait déjà faire, avec le profil neutre. @@ -5917,6 +5931,25 @@ La conversion de `coef_num`/`coef_den` est **exacte ou n'est pas.** `discount_pe **Conséquences.** (a) **Le comportement livré ne bouge pas** : le fichier livré se tait sur cette clé, comme il se tait déjà sur `grid_columns`, et §11.2 dit pourquoi ce silence est sûr — un `Config` décodé sans la clé porte le défaut, jamais un zéro qui donnerait sa puce à une catégorie sans tuile. (b) **Le seuil retire une puce et jamais une tuile** : `categories[].visible` reste le seul mécanisme qui retire des produits d'un écran, et une catégorie sous le seuil garde toutes ses tuiles dans « Tout » et à la recherche. (c) **Le champ entre dans `presentationDigest` par réflexion** (§11.4) : un écran client voisin applique le nouveau seuil par le flux d'état, sans redémarrage. (d) **Conséquence assumée, et pas seulement possible** : sans plafond, un seuil supérieur au plus gros rayon d'une coopérative laisse la barre de catégories avec « Tout » seul, et rien à l'écran ne le signale avant l'enregistrement. C'est le prix d'« un seul paramètre » plutôt qu'un plafond dérivé du catalogue en service ; il est réversible en revenant sur le champ, exactement comme un mauvais nombre de colonnes l'est pour ADR-057. +### ADR-060 — L'installation demande ce qu'elle seule peut savoir, et le poste sort utilisable + +**Statut** : accepté · **Date** : 10/08/2026 · **Portée** : `deploy/windows`, `cmd/openscale`, `internal/web`, §11.2, §14.4, §15.1, §15.2, §15.5 · **Amende** : §15.2, §15.5, ADR-018 · **N'amende pas** : ADR-012 + +**Contexte.** Un poste sortait de l'installeur avec **quatre fautes** et personne pour les lire. Trois étaient des réglages de site qu'une ligne de commande ne peut effectivement pas deviner ; la quatrième, `network.listen`, était **le fichier livré lui-même** — `Export` vide le bloc réseau, donc le poste refusait sa propre configuration de livraison (§11.2). Le parcours de première mise en service demandait alors d'ouvrir l'écran, d'aller chercher **le code de secours sur une fiche imprimée** pour poser le premier mot de passe, puis de saisir le numéro du poste. Constaté à l'installation d'un poste de production le 10/08/2026 : l'opérateur n'a rien pu enregistrer — `PUT /admin/api/config` valide le document **entier**, et une seule faute portant sur un champ qu'aucun écran n'édite verrouille **toute** l'administration — et a corrigé `config.json` à la main. + +**Décision.** **L'installeur pose ce qu'il est le seul à pouvoir demander, et rien d'autre.** Trois questions — mot de passe d'administration, numéro et nom du poste —, posées quand elles ne sont pas fournies en paramètre, muettes quand personne n'est là pour répondre. La **balance sort déclarée absente**, parce qu'elle n'est ni branchée ni détectée au moment où le script tourne, et qu'un port série ne se devine pas. Une action de ligne de commande, `openscale config station`, écrit ces valeurs par le magasin atomique de §11.4 ; le mot de passe passe par `config password`, qui le lit sur **l'entrée standard** — **aucune option de `config` ne prend de secret**, un argument se lisant dans la liste des processus. + +**Quatre choix portent le reste.** + +1. **Le binaire arbitre, le script relaie.** Aucune borne n'est recopiée en PowerShell : `--number 0` part au binaire, qui le refuse avec la phrase du contrôle 1, et le script redemande. Un script qui aurait vérifié `[1,99]` lui-même aurait été un second exemplaire de la règle, appelé à diverger. +2. **Une installation scriptée ne s'arrête jamais devant une question.** Le fait qu'une session soit interactive **ne suffit pas** à décider qu'on peut demander : `Read-Host -AsSecureString` lit la **console**, pas le tube, et un `install.ps1 < réponses.txt` avec `UserInteractive` à vrai s'arrête indéfiniment sur la première invite — mesuré, et tué à 120 s. L'entrée redirigée et `-NonInteractive` entrent donc dans la décision. +3. **Le code de secours ne disparaît pas, il change de rôle.** Il reste tiré, imprimé sur la fiche et classé, mais il n'est plus le chemin **nominal** de première ouverture : il redevient ce que son nom dit, un recours — poste installé sans questions, mot de passe perdu. Le parcours « ce poste n'a pas encore de mot de passe » de l'écran reste en place et reste juste. +4. **La fiche dit ce qui a été posé, et jamais le secret.** Numéro, nom, et le fait que le mot de passe d'administration a été posé à l'installation, par qui le redemander. Il n'est écrit ni sur la fiche, ni au journal d'installation, ni dans la configuration, qui n'en garde qu'une empreinte. + +**Conséquences.** (a) **Un poste neuf ne compte plus qu'une faute**, `catalog.options.url` — la source du catalogue est une étape de mise en service à part entière, et le champ est éditable à l'écran. (b) **L'empreinte de configuration d'un poste dont la balance est déclarée absente diffère de celle du parc** : `6c06605a` devient `79ba8bfc`, mesuré. Ce n'est pas un défaut de calcul et **ADR-012 n'est pas amendé** ; c'est son usage de terrain qui gagne une exception, la comparaison à l'œil des quatre empreintes de §15.5. L'empreinte **rejoint exactement celle du parc** dès que la balance est redéclarée, ce que la fiche, le message de fin de l'installeur et `INSTALLATION.md` disent tous les trois. (c) **`--no-scale` vide le protocole mais garde le dialecte série** — vitesse, bits, parité, temporisations : ce sont des réglages que les quatre postes **partagent** et qui voyagent dans la configuration clonée ; les vider empêchait le poste de revenir à l'empreinte du parc, mesuré. (d) **La détection de balance écrit désormais ce qu'elle a reconnu** — présence, protocole et port — au lieu de rendre une phrase que le bénévole devait retranscrire dans un volet replié : c'est ce que `DetectScale` avait toujours écrit dans son propre commentaire (« *what goes into the form is the driver that recognised what came out of the cable* ») sans que l'écran le câble. (e) **ADR-018 n'est pas renversé** : le principe — le poste ne demande rien pour être *regardé*, tout pour être *changé* — ne bouge pas ; seul le parcours de premier accès change. (f) **Linux reste asymétrique** : `deploy/linux/install.sh` ne pose aucune question et ne tire aucun code de secours. C'est **dit** dans `INSTALLATION.md` plutôt que laissé croire, et le rendre symétrique est un chantier à part. + +**Ce qui reste dehors.** L'**assistant en 5 étapes** de §14.4 n'est toujours pas écrit : ce que cet ADR supprime, c'est la nécessité d'en traverser un pour qu'un poste démarre. Le plancher du mot de passe passe de 8 à 4 dans le même lot, sur demande du propriétaire du produit ; l'arbitrage et ce qu'il ne protège pas sont en §14.4. + --- ## 21. Inconnues à lever sur site diff --git a/internal/domain/config_json.go b/internal/domain/config_json.go index ca3cb61..ee0308c 100644 --- a/internal/domain/config_json.go +++ b/internal/domain/config_json.go @@ -156,6 +156,16 @@ func (c *Config) UnmarshalJSON(raw []byte) error { if c.UI.MinProductsForChip == 0 { c.UI.MinProductsForChip = DefaultMinProductsForChip } + // An empty address is that same silence, and the file DELIVERED on a new station is + // one of them by construction: it is produced by `openscale config export`, which + // clears the whole network block so that a clone cannot inherit the address of the + // station it came from. Refusing it here refused nothing an operator could repair -- + // no screen carries this key -- and writeConfig validates the whole document on every + // save, so the one fault nobody could clear locked the administration of a brand-new + // station on all of its pages at once. + if c.Network.Listen == "" { + c.Network.Listen = NeutralProfile().Network.Listen + } c.retired = nil scanRetired("", document, &c.retired) return nil diff --git a/internal/domain/config_json_test.go b/internal/domain/config_json_test.go index 431c22a..abff888 100644 --- a/internal/domain/config_json_test.go +++ b/internal/domain/config_json_test.go @@ -109,6 +109,84 @@ func TestUnknownRoundingIsAnErrorAndNotASilentTruncation(t *testing.T) { } } +// TestAnEmptyListenAddressReadsBackAsTheNeutralOne is the defect that shut the whole +// administration of a station installed from an export. +// +// `Config.Export(false)` clears the network block, and the release target REQUIRES the +// delivered configuration to come out of that command: the file installed on a new +// station therefore says `"listen": ""` in as many words. Control 6 refused it, and +// `writeConfig` validates the WHOLE document and answers 422 on the first fault — while +// the draft submits the whole document whatever page it was edited on. One fault with no +// field on any screen thus locked EVERY page at once, on a station nobody had touched. +// +// The two blocks above it in `Config.UnmarshalJSON` correct the same kind of silence for +// the same reason. This one had none. +func TestAnEmptyListenAddressReadsBackAsTheNeutralOne(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`{"network":{"listen":""}}`), &config); err != nil { + t.Fatalf("décodage : %v", err) + } + if config.Network.Listen != NeutralProfile().Network.Listen { + t.Fatalf("adresse d'écoute relue %q, attendu celle du profil neutre %q", + config.Network.Listen, NeutralProfile().Network.Listen) + } + if fault := findFault(config.Validate(testRegistries()), "network.listen"); fault != nil { + t.Fatalf("le silence du fichier est traité comme une faute : %s", fault.Message) + } +} + +// TestAPartialNetworkBlockKeepsTheAddressOfWhoeverReadsIt: a block that names ONE key +// overlays its target, it does not erase it. +// +// This is what makes the field-by-field merge of an import behave (§11.5), and it works +// here through the alias — nothing of this file's own. Pinning it is what keeps the +// correction above from becoming a correction of something nobody asked for: applied to a +// receiver that already carries an address, it must not fire at all. +func TestAPartialNetworkBlockKeepsTheAddressOfWhoeverReadsIt(t *testing.T) { + config := Config{Network: NetworkConfig{Listen: "0.0.0.0:8085"}} + if err := json.Unmarshal([]byte(`{"network":{"admin_on_lan":true}}`), &config); err != nil { + t.Fatalf("décodage : %v", err) + } + if config.Network.Listen != "0.0.0.0:8085" { + t.Errorf("adresse d'écoute relue %q, attendu celle du receveur 0.0.0.0:8085", + config.Network.Listen) + } + if !config.Network.AdminOnLAN { + t.Error("la clé que le bloc NOMME n'a pas été lue") + } +} + +// TestAStationInstalledFromAnExportCanStillBeAdministered walks the real path: the +// delivered file, exported the way the release target exports it, then read back the way +// a station reads it. +// +// The measurement that named this defect is the one this test holds: `config validate` on +// that file returned FOUR faults, three of which name a key an operator can type on the +// administration screen. The fourth named `network.listen`, which has no field anywhere — +// so the station refused every save and offered no way to make it stop. +func TestAStationInstalledFromAnExportCanStillBeAdministered(t *testing.T) { + delivered := loadDelivered(t) + exported := delivered.Export(false) + raw, err := json.Marshal(exported) + if err != nil { + t.Fatalf("encodage de l'export : %v", err) + } + // The FILE says nothing, and it must go on saying nothing: what a clone must not + // inherit is the address of the station it was cloned from. + if !strings.Contains(string(raw), `"listen":""`) { + t.Fatalf("l'export porte une adresse d'écoute : %s", raw) + } + + var installed Config + if err := json.Unmarshal(raw, &installed); err != nil { + t.Fatalf("un poste ne relit pas la configuration qu'on lui livre : %v", err) + } + if fault := findFault(installed.Validate(testRegistries()), "network.listen"); fault != nil { + t.Fatalf("le poste neuf refuse son propre fichier livré sur une clé qu'aucun écran "+ + "ne porte : %s", fault.Message) + } +} + // TestMalformedBlocksAreReadErrorsAndNotFaults is step 1 of §11.4: what // json.Unmarshal cannot read is a 400 Bad Request, not a list of faults. func TestMalformedBlocksAreReadErrorsAndNotFaults(t *testing.T) { diff --git a/internal/platform/service_windows.go b/internal/platform/service_windows.go index a88d5a1..e7615b7 100644 --- a/internal/platform/service_windows.go +++ b/internal/platform/service_windows.go @@ -73,19 +73,67 @@ func InstallService(spec ServiceSpec) error { return setRecovery(created, spec) } -// setRecovery applies the automatic restarts of §15.2 — what `sc failure` writes. -func setRecovery(service *mgr.Service, spec ServiceSpec) error { - if len(spec.RecoveryDelays) == 0 { - return nil +// recoveryTarget is what setRecovery writes to: the two SCM settings that make up the +// automatic restarts, and nothing else of a service. +// +// *mgr.Service satisfies it. It is an interface because the two settings only work as a +// pair — the second is what makes the first cover the button of the administration screen +// — and a bench that could not see BOTH leave together would not be guarding anything. +type recoveryTarget interface { + SetRecoveryActions(actions []mgr.RecoveryAction, resetSeconds uint32) error + SetRecoveryActionsOnNonCrashFailures(flag bool) error +} + +// recoveryPlan is everything the SCM is told about a station that fell over. +type recoveryPlan struct { + // Actions are the successive restarts, in the order they are attempted. + Actions []mgr.RecoveryAction + // ResetSeconds is how long the station has to stay up for the count to start over, + // in the seconds SetRecoveryActions takes. + ResetSeconds uint32 + // OnNonCrashFailures extends the actions to a stop the station SIGNALLED with a + // non-zero code. setRecovery says what hangs on it. + OnNonCrashFailures bool +} + +// recoveryPlanFor turns a specification into what will be said to the SCM. +// +// It is pure, and separate, because *mgr.Service wraps a Windows handle nobody can hold +// without registering a service on the machine running the suite: the decisions are taken +// here, where a bench reads them, and the syscalls stay downstream. +func recoveryPlanFor(spec ServiceSpec) recoveryPlan { + plan := recoveryPlan{ + ResetSeconds: uint32(spec.RecoveryReset / time.Second), + OnNonCrashFailures: true, } - actions := make([]mgr.RecoveryAction, 0, len(spec.RecoveryDelays)) for _, delay := range spec.RecoveryDelays { - actions = append(actions, mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: delay}) + plan.Actions = append(plan.Actions, mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: delay}) } - reset := uint32(spec.RecoveryReset / time.Second) - if err := service.SetRecoveryActions(actions, reset); err != nil { + return plan +} + +// setRecovery applies the automatic restarts of §15.2 — what `sc failure` writes. +// +// IT SAYS TWO THINGS, AND THE SECOND IS WHAT MAKES THE FIRST COVER THE BUTTON « Redémarrer +// le poste ». Windows defaults the failure-actions flag to FALSE, and false means the SCM +// performs the recovery actions only for a service that terminated WITHOUT reporting +// SERVICE_STOPPED — a crash, a process killed. A restart asked for from the administration +// screen is not that: the station takes the ordered shutdown of §13.4, the handler reports +// SERVICE_STOPPED with a non-zero exit code, and the SCM files that as a stop nobody +// undoes. Without the flag, the three delays cover crashes and nothing else: the station +// stays down, and the screen probes an address that will never answer again. +func setRecovery(target recoveryTarget, spec ServiceSpec) error { + plan := recoveryPlanFor(spec) + if len(plan.Actions) == 0 { + return nil + } + if err := target.SetRecoveryActions(plan.Actions, plan.ResetSeconds); err != nil { return fmt.Errorf("les actions de reprise du service %s n'ont pas pu être posées : %w", spec.Name, err) } + if err := target.SetRecoveryActionsOnNonCrashFailures(plan.OnNonCrashFailures); err != nil { + return fmt.Errorf("les actions de reprise du service %s n'ont pas pu être étendues aux arrêts "+ + "demandés : %w", spec.Name, err) + } return nil } diff --git a/internal/platform/service_windows_test.go b/internal/platform/service_windows_test.go index 8413218..b921fe8 100644 --- a/internal/platform/service_windows_test.go +++ b/internal/platform/service_windows_test.go @@ -1,6 +1,7 @@ package platform import ( + "errors" "strings" "testing" "time" @@ -13,8 +14,9 @@ import ( // deleting it all need an elevated session AND leave a service behind on the machine that // ran the tests. A suite that asked for that is a suite nobody runs, and one that left a // half-installed service behind would be worse than no suite. What is exercised here is -// every DECISION taken before the syscall — and the command line is the one that has a -// famous bug in it. +// every DECISION taken before the syscall — the command line, which has a famous bug in +// it, and the pair of recovery settings, whose second half was missing for a whole +// release. // TestTheServicePathIsQuotedEvenWithoutASpace guards the oldest bug in Windows service // registration. @@ -79,6 +81,129 @@ func TestWhatTheServicesConsoleShowsIsFrench(t *testing.T) { } } +// TestTheRestartsCoverAStopTheStationSIGNALLED is the defect the button « Redémarrer le +// poste » had on a station in production: it stopped, and nothing brought it back. +// +// Windows defaults the failure-actions flag to false, and false means the SCM applies the +// three delays ONLY to a service that terminated WITHOUT reporting SERVICE_STOPPED. The +// button does the opposite: the ordered shutdown of §13.4, SERVICE_STOPPED, a non-zero +// exit code — a stop the SCM then files as final. The flag is the whole difference between +// « le poste revient tout seul » and a black screen. +func TestTheRestartsCoverAStopTheStationSIGNALLED(t *testing.T) { + plan := recoveryPlanFor(ServiceSpec{ + Name: ServiceName, + RecoveryDelays: []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}, + RecoveryReset: 24 * time.Hour, + }) + + if !plan.OnNonCrashFailures { + t.Fatal("le drapeau est faux : le SCM ne relancerait que sur un plantage, et le " + + "bouton « Redémarrer le poste » arrêterait le poste sans que rien ne le relance") + } + if len(plan.Actions) != 3 { + t.Fatalf("%d reprises annoncées au SCM, attendu les trois de §15.2", len(plan.Actions)) + } + for rank, want := range []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second} { + if plan.Actions[rank].Type != mgr.ServiceRestart { + t.Errorf("reprise %d : le SCM ferait autre chose que redémarrer le service", rank+1) + } + if plan.Actions[rank].Delay != want { + t.Errorf("reprise %d après %s, attendu %s", rank+1, plan.Actions[rank].Delay, want) + } + } + // The SCM counts a reset period in SECONDS, and a day handed over as nanoseconds + // would overflow into a period nobody chose. + if plan.ResetSeconds != 24*60*60 { + t.Errorf("remise à zéro après %d s, attendu une journée en secondes", plan.ResetSeconds) + } +} + +// TestAStationThatAsksForNoRestartIsToldNothing: a specification without delays leaves the +// SCM configuration ALONE, rather than posing an empty list — which is a different thing, +// and one that would erase what a station already had. +func TestAStationThatAsksForNoRestartIsToldNothing(t *testing.T) { + scm := &recordingSCM{} + if err := setRecovery(scm, ServiceSpec{Name: ServiceName}); err != nil { + t.Fatalf("setRecovery a refusé une spécification sans reprise : %v", err) + } + if len(scm.told) != 0 { + t.Fatalf("le SCM s'est vu dire %v alors qu'aucune reprise n'était demandée", scm.told) + } +} + +// TestTheFlagLeavesWithTheActions is the one this whole file exists for. +// +// The two settings are USELESS APART: actions without the flag cover crashes only, and the +// flag without actions extends an empty list. Posing the first and forgetting the second is +// exactly what shipped, and a bench that only read the plan would not have caught it. +func TestTheFlagLeavesWithTheActions(t *testing.T) { + scm := &recordingSCM{} + spec := ServiceSpec{ + Name: ServiceName, + RecoveryDelays: []time.Duration{5 * time.Second}, + RecoveryReset: 24 * time.Hour, + } + + if err := setRecovery(scm, spec); err != nil { + t.Fatalf("setRecovery = %v", err) + } + if len(scm.actions) == 0 { + t.Fatal("aucune action de reprise n'a été posée") + } + if !scm.onNonCrashFailures { + t.Fatal("les actions sont parties sans le drapeau : le SCM les réserverait aux " + + "plantages, et un arrêt demandé depuis l'écran ne serait jamais suivi d'une reprise") + } +} + +// TestARefusedFlagIsReportedInFrench: the installation must not announce a success over a +// setting the SCM refused — §15.2's own rule about native calls, applied to this one. +func TestARefusedFlagIsReportedInFrench(t *testing.T) { + scm := &recordingSCM{refuseFlag: errors.New("accès refusé")} + err := setRecovery(scm, ServiceSpec{ + Name: ServiceName, + RecoveryDelays: []time.Duration{5 * time.Second}, + RecoveryReset: 24 * time.Hour, + }) + + if err == nil { + t.Fatal("un drapeau refusé est passé pour une installation réussie") + } + if !strings.Contains(err.Error(), ServiceName) { + t.Errorf("la phrase ne nomme pas le service : %v", err) + } +} + +// recordingSCM is a service manager that writes down what it was told instead of touching +// the machine running the suite. +type recordingSCM struct { + // told is the settings in the order they arrived, which is what proves the pair left + // together rather than one of them alone. + told []string + actions []mgr.RecoveryAction + resetSeconds uint32 + onNonCrashFailures bool + refuseFlag error +} + +// SetRecoveryActions records the restarts and the reset period. +func (s *recordingSCM) SetRecoveryActions(actions []mgr.RecoveryAction, resetSeconds uint32) error { + s.told = append(s.told, "actions") + s.actions = actions + s.resetSeconds = resetSeconds + return nil +} + +// SetRecoveryActionsOnNonCrashFailures records the flag, or refuses it. +func (s *recordingSCM) SetRecoveryActionsOnNonCrashFailures(flag bool) error { + s.told = append(s.told, "drapeau") + if s.refuseFlag != nil { + return s.refuseFlag + } + s.onNonCrashFailures = flag + return nil +} + // TestTheWaitHintNeverTellsWindowsWeStopInstantly is the Windows half of the §13.4 fix. // // The SCM decides on its own that a service hung once StopPending outlives the wait hint, diff --git a/internal/platform/supervised_windows.go b/internal/platform/supervised_windows.go index c9ee635..10c33bf 100644 --- a/internal/platform/supervised_windows.go +++ b/internal/platform/supervised_windows.go @@ -3,6 +3,9 @@ package platform // supervised is the SCM, which the service handler already knows how to ask. // // A service registered by InstallService carries the recovery actions of §15.2 — five -// seconds, ten, thirty — which the SCM applies to a NON-ZERO exit code. A binary run -// from a console carries nothing of the sort. +// seconds, ten, thirty — AND the failure-actions flag that extends them to a stop the +// station SIGNALLED with a non-zero code, which is the one the button of the +// administration screen produces. The flag is not decoration: Windows defaults it to +// false, and the actions then cover crashes only. A binary run from a console carries +// nothing of the sort. func supervised() bool { return StartedByServiceManager() } diff --git a/internal/web/argon2id.go b/internal/web/argon2id.go index badde0e..a2ed90e 100644 --- a/internal/web/argon2id.go +++ b/internal/web/argon2id.go @@ -2,6 +2,10 @@ // PHC string, how one is read back, and the eight-character code printed on the // installation sheet. // +// The two lengths a secret is held to are declared here as well, next to one another, +// and the password floor is deliberately NOT a cryptographic quantity: it is here +// because every door that sets a password has to compare against the same one. +// // The cost parameters are read FROM THE STORED HASH and never assumed, which is // what lets a cooperative raise them without locking itself out of the stations // that were installed before. @@ -71,6 +75,20 @@ func hashWithCost(secret string, memory, iterations uint32, threads uint8) (stri // RecoveryCodeLength is the eight characters §14.4 prints on the installation sheet. const RecoveryCodeLength = 8 +// MinPasswordLength is the floor an administration password is held to, counted in CODE +// POINTS and not in bytes. +// +// It is an arbitrage of USE, made by the product owner, and not a cryptographic one: the +// password is typed on the station's touch screen, on its on-screen keyboard, by a +// volunteer standing in a shop on a Saturday with a queue behind them. A floor that makes +// that gesture long enough to give up on is a floor that gets written on a sticker beside +// the screen. +// +// It is EXPORTED and it is the only place the figure exists, because two doors set a +// password — `openscale config password` and the recovery form of §14.4 — and a value +// spelled out twice is how one of them ends up refusing what the other has just accepted. +const MinPasswordLength = 4 + // recoveryAlphabet is what those eight characters are drawn from. // // Neither I, L, O, U, 0 nor 1. This code is not typed by whoever generated it: it is read diff --git a/internal/web/configtransfer.go b/internal/web/configtransfer.go index 4aa65a5..d505815 100644 --- a/internal/web/configtransfer.go +++ b/internal/web/configtransfer.go @@ -63,6 +63,15 @@ func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) { // inherit the password of the station it was cloned from, nor its number (§11.5). candidate.Admin = current.Admin candidate.Station.Number = current.Station.Number + // Nor the network block, which designates THE MACHINE just as the number does. An + // export carries it empty and the decoder reads that emptiness as the neutral + // loopback — it has to, or a station installed from such a file could not be + // administered at all — so without this line a file carried from the reference + // station would move the socket of the station reading it onto 127.0.0.1 and shut + // the administration screen on the volunteer holding it open from the shop's + // network. The two keys travel together for the reason fallbackProfile gives: half + // a network block is harder to diagnose than a whole one that is wrong. + candidate.Network = current.Network body := struct { configDTO diff --git a/internal/web/configtransfer_test.go b/internal/web/configtransfer_test.go new file mode 100644 index 0000000..dd63d42 --- /dev/null +++ b/internal/web/configtransfer_test.go @@ -0,0 +1,134 @@ +// This file holds WHAT AN IMPORTED FILE IS ALLOWED TO MOVE on the station that reads it. +// +// The export of §11.5 exists to seed a fleet from one reference station. What it must +// never seed is the address a station answers on: that address designates THE MACHINE, +// the way the station number and the two secrets do. + +package web + +import ( + "encoding/json" + "net/http" + "slices" + "testing" + + "openscale/internal/domain" +) + +// TestImportingAnExportNeverMovesTheListener is the guard rail that used to be a side +// effect, and stopped being one. +// +// Until the decoder learned to read an empty listen address as the neutral one, an +// export carried from the reference station was refused OUTRIGHT: `Config.Export(false)` +// clears the whole network block, the address came back empty, and control 6 turned the +// whole import into a 422. That refusal was the only thing keeping a clone from moving +// the socket of the station reading it. +// +// The decoder now fills that emptiness in — it has to, or a station installed from such a +// file could never be administered at all — so the file passes the controls, and +// `127.0.0.1:8085` would be applied to a station listening on the whole network: the +// administration screen would close onto a loopback nobody can reach from the shop floor, +// on the one gesture whose whole point is to configure a station remotely. +// +// The two keys go together and the composition root says why (`fallbackProfile`): half a +// network block — an address open to the network behind an administration closed to it — +// is harder to diagnose than either one being wrong on its own. +func TestImportingAnExportNeverMovesTheListener(t *testing.T) { + const fleetAddress = "0.0.0.0:8085" + b := adminBench(t, func(o *benchOptions) { + o.config = func(c *domain.Config) { + c.Network.Listen = fleetAddress + c.Network.AdminOnLAN = true + } + }) + + // The file a volunteer carries from the reference station: the export WITHOUT the + // hardware, which is the one §11.5 clones with. + reference := b.hub.Config() + carried := reference.Export(false) + + proposed, changed := importedConfig(t, b, carried) + + if proposed.Network.Listen != fleetAddress { + t.Errorf("l'import propose l'adresse d'écoute %q, le poste écoute %q : un fichier "+ + "apporté déplacerait l'écoute de ce poste", proposed.Network.Listen, fleetAddress) + } + if !proposed.Network.AdminOnLAN { + t.Error("l'import referme l'administration sur la boucle locale : le bénévole qui " + + "a apporté le fichier depuis le réseau perd l'écran en l'enregistrant") + } + // And the screen is told nothing moved, or a volunteer would read « le réseau change » + // on a block the station has just decided to keep. + if slices.Contains(changed, "network") { + t.Errorf("l'import annonce le bloc network parmi %v alors qu'il le reprend du poste", + changed) + } +} + +// TestImportingAFileThatNAMESAnotherAddressStillDoesNotMoveTheListener: the emptiness of +// an export is not what this guard rests on. +// +// A backup taken WITH the hardware carries a real address, and it is the file somebody +// reaches for when a station has to be rebuilt — from the machine next to it, as often as +// not. Deciding the block only when it arrives empty would leave that file free to move +// the socket, which is the same accident by a different door. +func TestImportingAFileThatNAMESAnotherAddressStillDoesNotMoveTheListener(t *testing.T) { + const fleetAddress = "0.0.0.0:8085" + b := adminBench(t, func(o *benchOptions) { + o.config = func(c *domain.Config) { + c.Network.Listen = fleetAddress + c.Network.AdminOnLAN = true + } + }) + + other := b.hub.Config() + other.Network = domain.NetworkConfig{Listen: "127.0.0.1:8086", AdminOnLAN: false} + + proposed, _ := importedConfig(t, b, other) + + if proposed.Network.Listen != fleetAddress { + t.Errorf("l'import propose %q, le poste écoute %q", proposed.Network.Listen, fleetAddress) + } + if !proposed.Network.AdminOnLAN { + t.Error("l'import a refermé l'administration sur la boucle locale") + } +} + +// TestImportingStillCarriesTheBusinessSettingsOver keeps the guard above from becoming a +// refusal of everything: what a clone EXISTS to carry must still cross. +func TestImportingStillCarriesTheBusinessSettingsOver(t *testing.T) { + b := adminBench(t) + + carried := b.hub.Config() + carried.Limits.MaxWeight = 12_345 + carried.Journal.MaxDays = 30 + + proposed, changed := importedConfig(t, b, carried) + + if proposed.Limits.MaxWeight != 12_345 { + t.Errorf("poids maximum proposé %d, attendu 12345 : le fichier ne passe plus", + proposed.Limits.MaxWeight) + } + if proposed.Journal.MaxDays != 30 { + t.Errorf("rétention proposée %d jour(s), attendu 30", proposed.Journal.MaxDays) + } + if len(changed) == 0 { + t.Error("l'import ne dit plus ce qui changerait") + } +} + +// importedConfig posts one candidate to the import route and returns what the station +// says it would apply, with the blocks it says would move. +func importedConfig(t *testing.T, b *bench, candidate domain.Config) (domain.Config, []string) { + t.Helper() + answer := decodeStatus[struct { + Config json.RawMessage `json:"config"` + Changed []string `json:"changed_blocks"` + }](t, b.post("/admin/api/config/import", marshal(t, candidate)), http.StatusOK) + + var proposed domain.Config + if err := json.Unmarshal(answer.Config, &proposed); err != nil { + t.Fatalf("configuration proposée illisible : %v", err) + } + return proposed, answer.Changed +} diff --git a/internal/web/dist/admin.html b/internal/web/dist/admin.html index d1a5580..fba5eea 100644 --- a/internal/web/dist/admin.html +++ b/internal/web/dist/admin.html @@ -5,11 +5,11 @@ Administration - + - + - +
diff --git a/internal/web/dist/assets/admin-FoRrpu8q.js b/internal/web/dist/assets/admin-FoRrpu8q.js new file mode 100644 index 0000000..b8aa02c --- /dev/null +++ b/internal/web/dist/assets/admin-FoRrpu8q.js @@ -0,0 +1 @@ +import"./app-BaRSXv80.js";import{mountAdmin as m}from"./mount-VqoFW5_m.js";m(document.getElementById("app")); diff --git a/internal/web/dist/assets/admin-tZr91ixu.js b/internal/web/dist/assets/admin-tZr91ixu.js deleted file mode 100644 index d59f2ee..0000000 --- a/internal/web/dist/assets/admin-tZr91ixu.js +++ /dev/null @@ -1 +0,0 @@ -import"./app-BaRSXv80.js";import{mountAdmin as m}from"./mount-1OvlPcKC.js";m(document.getElementById("app")); diff --git a/internal/web/dist/assets/index-DPFlyzED.js b/internal/web/dist/assets/index-9fjLb9_b.js similarity index 99% rename from internal/web/dist/assets/index-DPFlyzED.js rename to internal/web/dist/assets/index-9fjLb9_b.js index 8a75d0d..d153b2d 100644 --- a/internal/web/dist/assets/index-DPFlyzED.js +++ b/internal/web/dist/assets/index-9fjLb9_b.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/mount-1OvlPcKC.js","assets/app-BaRSXv80.js","assets/app-Cfxw4Luj.css","assets/mount-MmpjwdB2.css"])))=>i.map(i=>d[i]); -import{e as Ie,u as je,d as re,p as Y,a as q,i as W,I as ee,t as z,b as N,c as w,f as Z,g as E,h as t,s as h,j as ae,k as te,l as se,m as T,n as v,o as u,q as Pe,r as fe,w as De,v as Te,x as ne,y as Ce,R as Re,z as b,A as D,B as qe,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as ze,M as He,J as Ue,K as Ve,L as Ge,O as Xe,P as Ke,Q as Je,S as Qe,U as Ye,V as Ze,W as $e,X as et}from"./app-BaRSXv80.js";class be{#e=new WeakMap;#t;#n;static entries=new WeakMap;constructor(e){this.#n=e}observe(e,n){var r=this.#e.get(e)||new Set;return r.add(n),this.#e.set(e,r),this.#s().observe(e,this.#n),()=>{var l=this.#e.get(e);l.delete(n),l.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#s(){return this.#t??(this.#t=new ResizeObserver(e=>{for(var n of e){be.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}}))}}var tt=new be({box:"border-box"});function he(s,e,n){var r=tt.observe(s,()=>n(s[e]));Ie(()=>(je(()=>n(s[e])),r))}const nt="modulepreload",at=function(s){return"/"+s},ye={},st=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let _=function(o){return Promise.all(o.map(i=>Promise.resolve(i).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),d=c?.nonce||c?.getAttribute("nonce");l=_(n.map(o=>{if(o=at(o),o in ye)return;ye[o]=!0;const i=o.endsWith(".css"),y=i?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${o}"]${y}`))return;const k=document.createElement("link");if(k.rel=i?"stylesheet":nt,i||(k.as="script"),k.crossOrigin="",k.href=o,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((m,I)=>{k.addEventListener("load",m),k.addEventListener("error",()=>I(new Error(`Unable to preload CSS for ${o}`)))})}))}function f(c){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=c,window.dispatchEvent(d),!d.defaultPrevented)throw c}return l.then(c=>{for(const d of c||[])d.status==="rejected"&&f(d.reason);return e().catch(f)})};var rt=E('

kg

',1),it=E('

kg

Poids indisponible

',1),lt=E('');function ot(s,e){Y(e,!0);const n=q(e,"taring",3,!1),r=v(()=>{if(e.linkBanner!=="")return e.linkBanner;const A=e.snapshot?.message??null;if(A!==null)return A.text;switch(e.snapshot?.state){case"product_armed":return"Posez votre produit";case"awaiting_stability":return"Pesée en cours…";case"printing":return"Étiquette en cours…";case"entering_tare":return"Tapez la tare en grammes";case"scale_lost":return"Le poids n’est plus disponible.";default:return"Touchez votre produit, l’étiquette sort"}}),l=v(()=>e.snapshot===null?"var(--waiting)":e.snapshot.state==="faulted"||e.snapshot.state==="scale_lost"?"var(--fault)":e.snapshot.state==="rejected"?"var(--warning)":e.snapshot.state==="succeeded"||e.snapshot.weight.latched?"var(--ready)":"var(--waiting)"),f=v(()=>e.showWeight&&e.snapshot!==null&&e.snapshot.weight.available&&!e.snapshot.weight.expired),c=v(()=>{const A=e.snapshot?.message;return A!=null?A.level:e.snapshot?.state==="faulted"||e.snapshot?.state==="scale_lost"?"error":e.snapshot?.state==="rejected"?"warn":"info"});var d=lt(),_=u(d);let o;var i=u(_);{var y=A=>{var O=rt(),G=Pe(O),X=u(G),K=h(G,2),F=u(K);z(()=>{T(X,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=it();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var m=h(_,2);let I;var g=u(m),P=h(m,2);let B;var R=u(P);ee(R,{name:"tare",size:"1.4em"});var H=h(P,2);let V;z(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),o=te(_,"",o,{"border-left-color":t(l)}),I=se(m,1,"instruction svelte-4gizvm",null,I,{warn:t(c)==="warn",error:t(c)==="error"}),T(g,t(r)),B=se(P,1,"tare-key touch-target svelte-4gizvm",null,B,{active:n()}),V=te(H,"",V,{background:t(l)})}),N("click",P,function(...A){e.ontare?.apply(this,A)}),w(s,d),Z()}re(["click"]);var ct=E(''),ut=E('');function dt(s,e){Y(e,!0);var n=ut(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,_)=>{var o=ct();let i,y;var k=h(u(o),2),m=u(k),I=h(k,2),g=u(I);z(P=>{i=se(o,1,"chip touch-target svelte-atgty1",null,i,{active:t(_).code===e.active}),ae(o,"aria-pressed",t(_).code===e.active),y=te(o,"",y,P),T(m,t(_).label),T(g,t(_).count)},[()=>({"--chip-ink":Te(t(_).color),"--chip-wash":De(t(_).color)})]),N("click",o,()=>e.onselect(t(_).code)),w(d,o)});var l=h(r,2);let f;var c=u(l);ee(c,{name:"search",size:"1.75rem"}),z(()=>{f=se(l,1,"chip touch-target search-key svelte-atgty1",null,f,{active:e.searchFieldOpen}),ae(l,"aria-pressed",e.searchFieldOpen)}),N("click",l,function(...d){e.onopensearch?.apply(this,d)}),w(s,n),Z()}re(["click"]);var vt=E(''),gt=E('

'),ht=E('

');function we(s,e){Y(e,!0);const n=q(e,"action",3,null);var r=ht(),l=u(r),f=u(l),c=u(f);ee(c,{name:"alert",size:"3rem"});var d=h(f,2),_=u(d),o=h(d,2),i=u(o),y=h(o,2);{var k=g=>{var P=vt(),B=u(P);z(()=>T(B,n().label)),N("click",P,function(...R){n().run?.apply(this,R)}),w(g,P)};W(y,g=>{n()!==null&&g(k)})}var m=h(y,2);{var I=g=>{var P=gt(),B=u(P);z(()=>T(B,e.code)),w(g,P)};W(m,g=>{e.code!==""&&g(I)})}z(()=>{T(_,e.title),T(i,e.detail)}),w(s,r),Z()}re(["click"]);var ft=E('
'),pt=E('

Chargement du catalogue…

',1),_t=E('

'),mt=E('

'),bt=E('
'),yt=E('
'),wt=E('
');function Et(s,e){Y(e,!0);const n=q(e,"gridColumns",3,0),r=q(e,"colors",19,()=>({})),l=q(e,"primaryCode",3,""),f=q(e,"tierAbbrev",19,()=>({})),c=q(e,"selectedID",3,null),d=q(e,"rejectedID",3,null),_=q(e,"busyID",3,null),o=q(e,"showPrices",3,!0),i=q(e,"loading",3,!1),y=q(e,"emptyMessage",3,"Aucun produit ne correspond."),k=q(e,"emptyHint",3,""),m=8,I=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let R=D(0),H=D(0),V=D(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${Re}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=D(null),G=D(0),X=D(0);const K=v(()=>n()>0?n():t(R)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(R)+m)/(t(H)+m)))),F=v(()=>t(K)>0&&t(R)>0?(t(R)-(t(K)-1)*m)/t(K):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),C=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-I:0),ie=v(()=>t(G)>0?t(G):t($));ne(()=>{t(F),t(p),e.products.length;const x=t(O)?.querySelector(".name-box");b(G,x?.clientWidth??0,!0)});const oe=v(()=>{const x=new Map;if(t(A)===null||t(ie)<=0)return x;const L=t(X)>0?t(X):Oe;for(const j of e.products)x.set(j.id,Me(j.name,t(ie),t(A),L,t(C)));return x});var J=wt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=pt(),j=Pe(L);fe(j,21,()=>({length:B}),qe,(a,S)=>{var U=ft();w(a,U)}),z(()=>te(j,t(M))),he(j,"clientWidth",a=>b(R,a)),w(x,L)},de=x=>{var L=mt(),j=u(L),a=u(j),S=h(j,2);{var U=Q=>{var ge=_t(),_e=u(ge);z(()=>T(_e,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}z(()=>T(a,y())),w(x,L)},ve=x=>{var L=yt();fe(L,21,()=>e.products,j=>j.id,(j,a)=>{var S=bt(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(C)),ge=v(()=>t(a).id===c()),_e=v(()=>t(a).id===d()),Se=v(()=>t(a).id===_());Le(U,{get product(){return t(a)},get nameSizePx(){return t(Q)},get categoryColor(){return r()[t(a).category_code]},get primaryCode(){return l()},get tierAbbrev(){return f()},get selected(){return t(ge)},get rejected(){return t(_e)},get busy(){return t(Se)},get showPrice(){return o()},get onpick(){return e.onpick}})}w(j,S)}),z(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",j=>b(R,j)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}Ce(J,x=>b(O,x),()=>t(O)),z(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(X,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var kt=E(''),xt=E('
');function At(s,e){Y(e,!0);let n=D(null);ne(()=>{t(n)?.focus()});var r=xt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);Ce(f,i=>b(n,i),()=>t(n));var c=h(f,2);{var d=i=>{var y=kt();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(c,i=>{e.query!==""&&i(d)})}var _=h(c,2),o=u(_);ee(o,{name:"close",size:"1.5rem"}),z(()=>Fe(f,e.query)),N("input",f,i=>e.onquery(i.currentTarget.value)),N("keydown",f,i=>{i.key==="Escape"?(i.preventDefault(),e.onclose()):i.key==="Enter"&&(i.preventDefault(),e.onenter())}),N("click",_,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function Pt(s){if(s==="")return"";const e=new Date(s);if(Number.isNaN(e.getTime()))return"";const n=l=>String(l).padStart(2,"0");return`${`${n(e.getDate())}/${n(e.getMonth()+1)}/${e.getFullYear()}`} ${n(e.getHours())}:${n(e.getMinutes())}:${n(e.getSeconds())}`}function Ct(s){return s.mode==="by_unit"?`${s.product_name} ${s.quantity} ${s.quantity>1?"unités":"unité"}`:`${s.product_name} ${s.net_text} kg`}var zt=E('Catalogue du '),St=E('Catalogue en attente'),It=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),Tt=E(''),Rt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=q(e,"catalogAt",3,""),r=q(e,"productCount",3,0),l=q(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var c=Rt();let d;var _=u(c),o=u(_);{var i=p=>{var C=zt(),M=h(u(C)),$=u(M);z(()=>T($,n())),w(p,C)},y=p=>{var C=St();w(p,C)};W(o,p=>{n()!==""?p(i):p(y,-1)})}var k=h(o,2);{var m=p=>{var C=It(),M=u(C);z(()=>T(M,t(f))),w(p,C)};W(k,p=>{t(f)!==""&&p(m)})}var I=h(_,4),g=h(u(I),2);{var P=p=>{var C=jt();w(p,C)},B=p=>{var C=Dt(),M=u(C);z($=>T(M,$),[()=>Ct(e.label)]),w(p,C)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var R=h(I,2);{var H=p=>{var C=Tt();z(()=>C.disabled=!e.available),N("click",C,function(...M){e.onreprint?.apply(this,M)}),w(p,C)};W(R,p=>{e.label!==null&&p(H)})}var V=h(R,2),A=u(V);let O;var G=h(A,2),X=u(G),K=h(V,2),F=u(K);ee(F,{name:"settings",size:"1.75rem"}),z(()=>{d=se(c,1,"bar svelte-161y12f",null,d,{live:e.label!==null}),O=se(A,1,"dot svelte-161y12f",null,O,{fault:!e.healthy}),ae(A,"aria-label",e.healthy?"Matériel disponible":"Matériel indisponible"),T(X,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",K,function(...p){e.onadmin?.apply(this,p)}),w(s,c),Z()}re(["click"]);var Lt=E(''),Nt=E('

Tare g

');function Ot(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Nt(),l=u(r),f=h(u(l),2),c=u(f),d=h(l,2);fe(d,20,()=>n,m=>m,(m,I)=>{var g=Lt(),P=u(g);z(()=>T(P,I)),N("click",g,()=>e.ondigit(I)),w(m,g)});var _=h(d,2),o=u(_),i=h(o,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),z(()=>T(c,e.grams===""?"0":e.grams)),N("click",o,function(...m){e.onclear?.apply(this,m)}),N("click",i,function(...m){e.oncancel?.apply(this,m)}),N("click",y,function(...m){e.onconfirm?.apply(this,m)}),w(s,r),Z()}re(["click"]);function Mt(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Wt=2e3,ke=5e3;function Bt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Wt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Ft=250,Ht={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0,min_products_for_chip:He};class Ut{#e=D(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=D(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=D(ze({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=D("");get catalogError(){return t(this.#s)}set catalogError(e){b(this.#s,e,!0)}#r=null;#i=null;#l=Date.now();#a=!1;#o=!1;get presentation(){return this.catalog?.presentation??Ht}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Ft)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null,this.#r?.close(),this.#r=null,this.#a=!1}#c(){const e=new EventSource("/api/v1/stream");this.#r=e,e.onopen=()=>{this.#a=!0,this.#l=Date.now()},e.addEventListener("state",n=>this.#d(n)),e.onerror=()=>{this.#a=!1}}#d(e){this.#l=Date.now(),this.#a=!0;const n=JSON.parse(e.data),r=this.state;if(this.state=n,this.catalog===null)return;const l=n.catalog_count!==this.catalog.product_count,f=r!==null&&n.presentation_digest!==r.presentation_digest;(l||f)&&this.#u()}#v(){const e=Bt(Date.now()-this.#l,this.#a);this.link=e,e.mustReconnect&&(this.#r?.close(),this.#l=Date.now(),this.#c())}async#u(){if(!this.#o){this.#o=!0;try{this.catalog=await Ue(),this.catalogError=""}catch{this.catalog===null&&(this.catalogError="Catalogue indisponible. Prévenez un responsable.")}finally{this.#o=!1}}}}const xe="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function Ae(){let s=Date.now();const e=new Array(26);for(let r=9;r>=0;r--)e[r]=xe[s%32],s=Math.floor(s/32);const n=new Uint8Array(16);crypto.getRandomValues(n);for(let r=0;r<16;r++)e[10+r]=xe[n[r]%32];return e.join("")}var Vt=E('
');function Gt(s,e){Y(e,!0);const n=new Ut;let r=D(ze(Ve)),l=D(""),f=D(!1);const c=v(()=>t(l)!==""||t(f));let d=D(null),_=D(-1),o=D(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Qe(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),m=v(()=>Xe(t(y),t(r),t(l))),I=v(()=>{const a={};for(const S of t(i)?.categories??[])a[S.code]=S.color;return a}),g=v(()=>n.state),P=v(()=>n.presentation),B=v(()=>Mt(t(g))),R=v(()=>{if(n.catalogError!=="")return{message:n.catalogError,hint:"Le poste réessaie tout seul."};if(t(y).length>0)return{message:"Aucun produit ne correspond.",hint:"Effacez des lettres ou changez de rayon."};if(t(i)!==null&&t(i).products.length>0)return{message:t(i).products.every(U=>U.mode==="by_unit")&&!t(P).show_by_unit_products?"Aucun produit à montrer : ce poste masque les produits vendus à l’unité, et le catalogue reçu ne contient que ceux-là.":"Aucun produit à montrer : les réglages d’affichage de ce poste masquent tout ce qu’il a reçu.",hint:"Le fichier est bien arrivé : prévenez un responsable, c’est un réglage de ce poste qui vide la grille."};const a=t(g)?.station??0;return{message:a>0?`Catalogue vide. En attente du fichier flv_${a}.csv.`:"Catalogue vide. En attente du fichier du poste.",hint:"Prévenez un responsable : aucun produit ne peut être pesé."}}),H=v(()=>t(g)?.state==="rejected"?t(g).product?.id??null:null),V=v(()=>t(g)?.state==="faulted"||t(g)?.state==="out_of_service"),A=["product_armed","weight_present","weight_stable","awaiting_stability","validating","printing"],O=v(()=>t(g)!==null&&A.includes(t(g).state)?t(g).product?.id??null:null),G=v(()=>Pt(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(_)&&(b(d,null),b(_,-1))});async function X(a){if(t(d)===null){b(d,a.id,!0),b(_,n.state?.revision??-1,!0);try{await Ge({product_id:a.id,tare_g:n.state?.weight.tare_g??0,units:1,manual_weight_g:0,seen_weight_g:n.state?.weight.gross_g??0,measurement_seq:n.state?.weight.seq??0,key:Ae()})}catch{b(d,null)}}}async function K(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Je(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function C(){const a=t(m).length===1?t(m)[0]:void 0;a!==void 0&&X(a)}function M(a){if(!(a.metaKey||a.ctrlKey||a.altKey)&&!(a.target instanceof HTMLElement&&a.target.tagName==="INPUT")&&!(t(o)!==null||t(V))){if(a.key==="Escape"){a.preventDefault(),F();return}if(a.key==="Backspace"){a.preventDefault(),b(l,t(l).slice(0,-1),!0);return}if(a.key==="Enter"){C();return}a.key.length===1&&/[a-zA-Z0-9 ]/.test(a.key)&&(a.key===" "&&t(l)===""||(a.preventDefault(),b(l,t(l)+a.key)))}}ne(()=>(window.addEventListener("keydown",M),()=>window.removeEventListener("keydown",M)));async function $(){(await st(()=>import("./mount-1OvlPcKC.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Vt(),oe=u(ie);{let a=v(()=>t(o)!==null);ot(oe,{get snapshot(){return t(g)},get showWeight(){return n.link.showWeight},get linkBanner(){return n.link.banner},get taring(){return t(a)},ontare:()=>b(o,"")})}var J=h(oe,2);{var ce=a=>{Ot(a,{get grams(){return t(o)},ondigit:S=>b(o,(t(o)??"")+S),onclear:()=>b(o,""),oncancel:()=>b(o,null),onconfirm:()=>b(o,null)})};W(J,a=>{t(o)!==null&&a(ce)})}var le=h(J,2);{let a=v(()=>t(i)?.pricing.primary_code??""),S=v(()=>Object.fromEntries((t(i)?.pricing.tiers??[]).map(Q=>[Q.code,Q.abbrev]))),U=v(()=>t(i)===null&&n.catalogError==="");Et(le,{get products(){return t(m)},get colors(){return t(I)},get primaryCode(){return t(a)},get tierAbbrev(){return t(S)},get selectedID(){return t(O)},get rejectedID(){return t(H)},get busyID(){return t(d)},get showPrices(){return t(P).show_grid_prices},get gridColumns(){return t(P).grid_columns},get loading(){return t(U)},get emptyMessage(){return t(R).message},get emptyHint(){return t(R).hint},onpick:X})}var ue=h(le,2);{var pe=a=>{At(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:C})};W(ue,a=>{t(c)&&a(pe)})}var de=h(ue,2);dt(de,{get chips(){return t(k)},get active(){return t(r)},get searchFieldOpen(){return t(c)},onselect:a=>b(r,a,!0),onopensearch:p});var ve=h(de,2);{let a=v(()=>t(g)?.last_label??null),S=v(()=>t(g)?.reprint.available??!1),U=v(()=>t(i)?.app_version??"");qt(ve,{get label(){return t(a)},get available(){return t(S)},get catalogAt(){return t(G)},get productCount(){return t(y).length},get appVersion(){return t(U)},get healthy(){return t(B)},onreprint:K,onadmin:$})}var x=h(ve,2);{var L=a=>{{let S=v(()=>t(g).message?.text??"Prévenez un responsable.");we(a,{title:"Poste indisponible",get detail(){return t(S)},get code(){return t(g).fault_code},action:{label:"J’ai compris",run:()=>{Ye()}}})}},j=a=>{we(a,{title:"Poste hors service",detail:"Ce poste ne peut pas peser. Prévenez un responsable.",get code(){return t(g).fault_code}})};W(x,a=>{t(g)?.state==="faulted"?a(L):t(g)?.state==="out_of_service"&&a(j,1)})}w(s,ie),Z()}const Xt=5,Kt="ResizeObserver loop";function Jt(){let s=!1,e=!1;const n=(c,d)=>{s||(s=!0,$e(c,d),Qt(),setTimeout(()=>location.reload(),Xt*1e3))},r=c=>{e||(e=!0,Ze(c))},l=c=>{if((c.error===void 0||c.error===null)&&c.message.startsWith(Kt)){r(c.message);return}n(c.message,c.error?.stack??"")},f=c=>{n(String(c.reason),c.reason?.stack??"")};return window.addEventListener("error",l),window.addEventListener("unhandledrejection",f),()=>{window.removeEventListener("error",l),window.removeEventListener("unhandledrejection",f)}}function Qt(){const s=document.createElement("div");s.className="fatal",s.append(me("h1","Une erreur est survenue"),me("p","L’écran va se recharger tout seul."),me("p","ERR-UI-01","code")),document.body.appendChild(s)}function me(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Jt();document.addEventListener("contextmenu",s=>{Yt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());et(Gt,{target:document.getElementById("app")});function Yt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/mount-VqoFW5_m.js","assets/app-BaRSXv80.js","assets/app-Cfxw4Luj.css","assets/mount-DcOlpQNH.css"])))=>i.map(i=>d[i]); +import{e as Ie,u as je,d as re,p as Y,a as q,i as W,I as ee,t as z,b as N,c as w,f as Z,g as E,h as t,s as h,j as ae,k as te,l as se,m as T,n as v,o as u,q as Pe,r as fe,w as De,v as Te,x as ne,y as Ce,R as Re,z as b,A as D,B as qe,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as ze,M as He,J as Ue,K as Ve,L as Ge,O as Xe,P as Ke,Q as Je,S as Qe,U as Ye,V as Ze,W as $e,X as et}from"./app-BaRSXv80.js";class be{#e=new WeakMap;#t;#n;static entries=new WeakMap;constructor(e){this.#n=e}observe(e,n){var r=this.#e.get(e)||new Set;return r.add(n),this.#e.set(e,r),this.#s().observe(e,this.#n),()=>{var l=this.#e.get(e);l.delete(n),l.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#s(){return this.#t??(this.#t=new ResizeObserver(e=>{for(var n of e){be.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}}))}}var tt=new be({box:"border-box"});function he(s,e,n){var r=tt.observe(s,()=>n(s[e]));Ie(()=>(je(()=>n(s[e])),r))}const nt="modulepreload",at=function(s){return"/"+s},ye={},st=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let _=function(o){return Promise.all(o.map(i=>Promise.resolve(i).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),d=c?.nonce||c?.getAttribute("nonce");l=_(n.map(o=>{if(o=at(o),o in ye)return;ye[o]=!0;const i=o.endsWith(".css"),y=i?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${o}"]${y}`))return;const k=document.createElement("link");if(k.rel=i?"stylesheet":nt,i||(k.as="script"),k.crossOrigin="",k.href=o,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((m,I)=>{k.addEventListener("load",m),k.addEventListener("error",()=>I(new Error(`Unable to preload CSS for ${o}`)))})}))}function f(c){const d=new Event("vite:preloadError",{cancelable:!0});if(d.payload=c,window.dispatchEvent(d),!d.defaultPrevented)throw c}return l.then(c=>{for(const d of c||[])d.status==="rejected"&&f(d.reason);return e().catch(f)})};var rt=E('

kg

',1),it=E('

kg

Poids indisponible

',1),lt=E('');function ot(s,e){Y(e,!0);const n=q(e,"taring",3,!1),r=v(()=>{if(e.linkBanner!=="")return e.linkBanner;const A=e.snapshot?.message??null;if(A!==null)return A.text;switch(e.snapshot?.state){case"product_armed":return"Posez votre produit";case"awaiting_stability":return"Pesée en cours…";case"printing":return"Étiquette en cours…";case"entering_tare":return"Tapez la tare en grammes";case"scale_lost":return"Le poids n’est plus disponible.";default:return"Touchez votre produit, l’étiquette sort"}}),l=v(()=>e.snapshot===null?"var(--waiting)":e.snapshot.state==="faulted"||e.snapshot.state==="scale_lost"?"var(--fault)":e.snapshot.state==="rejected"?"var(--warning)":e.snapshot.state==="succeeded"||e.snapshot.weight.latched?"var(--ready)":"var(--waiting)"),f=v(()=>e.showWeight&&e.snapshot!==null&&e.snapshot.weight.available&&!e.snapshot.weight.expired),c=v(()=>{const A=e.snapshot?.message;return A!=null?A.level:e.snapshot?.state==="faulted"||e.snapshot?.state==="scale_lost"?"error":e.snapshot?.state==="rejected"?"warn":"info"});var d=lt(),_=u(d);let o;var i=u(_);{var y=A=>{var O=rt(),G=Pe(O),X=u(G),K=h(G,2),F=u(K);z(()=>{T(X,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=it();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var m=h(_,2);let I;var g=u(m),P=h(m,2);let B;var R=u(P);ee(R,{name:"tare",size:"1.4em"});var H=h(P,2);let V;z(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),o=te(_,"",o,{"border-left-color":t(l)}),I=se(m,1,"instruction svelte-4gizvm",null,I,{warn:t(c)==="warn",error:t(c)==="error"}),T(g,t(r)),B=se(P,1,"tare-key touch-target svelte-4gizvm",null,B,{active:n()}),V=te(H,"",V,{background:t(l)})}),N("click",P,function(...A){e.ontare?.apply(this,A)}),w(s,d),Z()}re(["click"]);var ct=E(''),ut=E('');function dt(s,e){Y(e,!0);var n=ut(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,_)=>{var o=ct();let i,y;var k=h(u(o),2),m=u(k),I=h(k,2),g=u(I);z(P=>{i=se(o,1,"chip touch-target svelte-atgty1",null,i,{active:t(_).code===e.active}),ae(o,"aria-pressed",t(_).code===e.active),y=te(o,"",y,P),T(m,t(_).label),T(g,t(_).count)},[()=>({"--chip-ink":Te(t(_).color),"--chip-wash":De(t(_).color)})]),N("click",o,()=>e.onselect(t(_).code)),w(d,o)});var l=h(r,2);let f;var c=u(l);ee(c,{name:"search",size:"1.75rem"}),z(()=>{f=se(l,1,"chip touch-target search-key svelte-atgty1",null,f,{active:e.searchFieldOpen}),ae(l,"aria-pressed",e.searchFieldOpen)}),N("click",l,function(...d){e.onopensearch?.apply(this,d)}),w(s,n),Z()}re(["click"]);var vt=E(''),gt=E('

'),ht=E('

');function we(s,e){Y(e,!0);const n=q(e,"action",3,null);var r=ht(),l=u(r),f=u(l),c=u(f);ee(c,{name:"alert",size:"3rem"});var d=h(f,2),_=u(d),o=h(d,2),i=u(o),y=h(o,2);{var k=g=>{var P=vt(),B=u(P);z(()=>T(B,n().label)),N("click",P,function(...R){n().run?.apply(this,R)}),w(g,P)};W(y,g=>{n()!==null&&g(k)})}var m=h(y,2);{var I=g=>{var P=gt(),B=u(P);z(()=>T(B,e.code)),w(g,P)};W(m,g=>{e.code!==""&&g(I)})}z(()=>{T(_,e.title),T(i,e.detail)}),w(s,r),Z()}re(["click"]);var ft=E('
'),pt=E('

Chargement du catalogue…

',1),_t=E('

'),mt=E('

'),bt=E('
'),yt=E('
'),wt=E('
');function Et(s,e){Y(e,!0);const n=q(e,"gridColumns",3,0),r=q(e,"colors",19,()=>({})),l=q(e,"primaryCode",3,""),f=q(e,"tierAbbrev",19,()=>({})),c=q(e,"selectedID",3,null),d=q(e,"rejectedID",3,null),_=q(e,"busyID",3,null),o=q(e,"showPrices",3,!0),i=q(e,"loading",3,!1),y=q(e,"emptyMessage",3,"Aucun produit ne correspond."),k=q(e,"emptyHint",3,""),m=8,I=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let R=D(0),H=D(0),V=D(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${Re}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=D(null),G=D(0),X=D(0);const K=v(()=>n()>0?n():t(R)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(R)+m)/(t(H)+m)))),F=v(()=>t(K)>0&&t(R)>0?(t(R)-(t(K)-1)*m)/t(K):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),C=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-I:0),ie=v(()=>t(G)>0?t(G):t($));ne(()=>{t(F),t(p),e.products.length;const x=t(O)?.querySelector(".name-box");b(G,x?.clientWidth??0,!0)});const oe=v(()=>{const x=new Map;if(t(A)===null||t(ie)<=0)return x;const L=t(X)>0?t(X):Oe;for(const j of e.products)x.set(j.id,Me(j.name,t(ie),t(A),L,t(C)));return x});var J=wt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=pt(),j=Pe(L);fe(j,21,()=>({length:B}),qe,(a,S)=>{var U=ft();w(a,U)}),z(()=>te(j,t(M))),he(j,"clientWidth",a=>b(R,a)),w(x,L)},de=x=>{var L=mt(),j=u(L),a=u(j),S=h(j,2);{var U=Q=>{var ge=_t(),_e=u(ge);z(()=>T(_e,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}z(()=>T(a,y())),w(x,L)},ve=x=>{var L=yt();fe(L,21,()=>e.products,j=>j.id,(j,a)=>{var S=bt(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(C)),ge=v(()=>t(a).id===c()),_e=v(()=>t(a).id===d()),Se=v(()=>t(a).id===_());Le(U,{get product(){return t(a)},get nameSizePx(){return t(Q)},get categoryColor(){return r()[t(a).category_code]},get primaryCode(){return l()},get tierAbbrev(){return f()},get selected(){return t(ge)},get rejected(){return t(_e)},get busy(){return t(Se)},get showPrice(){return o()},get onpick(){return e.onpick}})}w(j,S)}),z(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",j=>b(R,j)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}Ce(J,x=>b(O,x),()=>t(O)),z(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(X,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var kt=E(''),xt=E('
');function At(s,e){Y(e,!0);let n=D(null);ne(()=>{t(n)?.focus()});var r=xt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);Ce(f,i=>b(n,i),()=>t(n));var c=h(f,2);{var d=i=>{var y=kt();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(c,i=>{e.query!==""&&i(d)})}var _=h(c,2),o=u(_);ee(o,{name:"close",size:"1.5rem"}),z(()=>Fe(f,e.query)),N("input",f,i=>e.onquery(i.currentTarget.value)),N("keydown",f,i=>{i.key==="Escape"?(i.preventDefault(),e.onclose()):i.key==="Enter"&&(i.preventDefault(),e.onenter())}),N("click",_,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function Pt(s){if(s==="")return"";const e=new Date(s);if(Number.isNaN(e.getTime()))return"";const n=l=>String(l).padStart(2,"0");return`${`${n(e.getDate())}/${n(e.getMonth()+1)}/${e.getFullYear()}`} ${n(e.getHours())}:${n(e.getMinutes())}:${n(e.getSeconds())}`}function Ct(s){return s.mode==="by_unit"?`${s.product_name} ${s.quantity} ${s.quantity>1?"unités":"unité"}`:`${s.product_name} ${s.net_text} kg`}var zt=E('Catalogue du '),St=E('Catalogue en attente'),It=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),Tt=E(''),Rt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=q(e,"catalogAt",3,""),r=q(e,"productCount",3,0),l=q(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var c=Rt();let d;var _=u(c),o=u(_);{var i=p=>{var C=zt(),M=h(u(C)),$=u(M);z(()=>T($,n())),w(p,C)},y=p=>{var C=St();w(p,C)};W(o,p=>{n()!==""?p(i):p(y,-1)})}var k=h(o,2);{var m=p=>{var C=It(),M=u(C);z(()=>T(M,t(f))),w(p,C)};W(k,p=>{t(f)!==""&&p(m)})}var I=h(_,4),g=h(u(I),2);{var P=p=>{var C=jt();w(p,C)},B=p=>{var C=Dt(),M=u(C);z($=>T(M,$),[()=>Ct(e.label)]),w(p,C)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var R=h(I,2);{var H=p=>{var C=Tt();z(()=>C.disabled=!e.available),N("click",C,function(...M){e.onreprint?.apply(this,M)}),w(p,C)};W(R,p=>{e.label!==null&&p(H)})}var V=h(R,2),A=u(V);let O;var G=h(A,2),X=u(G),K=h(V,2),F=u(K);ee(F,{name:"settings",size:"1.75rem"}),z(()=>{d=se(c,1,"bar svelte-161y12f",null,d,{live:e.label!==null}),O=se(A,1,"dot svelte-161y12f",null,O,{fault:!e.healthy}),ae(A,"aria-label",e.healthy?"Matériel disponible":"Matériel indisponible"),T(X,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",K,function(...p){e.onadmin?.apply(this,p)}),w(s,c),Z()}re(["click"]);var Lt=E(''),Nt=E('

Tare g

');function Ot(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Nt(),l=u(r),f=h(u(l),2),c=u(f),d=h(l,2);fe(d,20,()=>n,m=>m,(m,I)=>{var g=Lt(),P=u(g);z(()=>T(P,I)),N("click",g,()=>e.ondigit(I)),w(m,g)});var _=h(d,2),o=u(_),i=h(o,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),z(()=>T(c,e.grams===""?"0":e.grams)),N("click",o,function(...m){e.onclear?.apply(this,m)}),N("click",i,function(...m){e.oncancel?.apply(this,m)}),N("click",y,function(...m){e.onconfirm?.apply(this,m)}),w(s,r),Z()}re(["click"]);function Mt(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Wt=2e3,ke=5e3;function Bt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Wt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Ft=250,Ht={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0,min_products_for_chip:He};class Ut{#e=D(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=D(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=D(ze({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=D("");get catalogError(){return t(this.#s)}set catalogError(e){b(this.#s,e,!0)}#r=null;#i=null;#l=Date.now();#a=!1;#o=!1;get presentation(){return this.catalog?.presentation??Ht}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Ft)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null,this.#r?.close(),this.#r=null,this.#a=!1}#c(){const e=new EventSource("/api/v1/stream");this.#r=e,e.onopen=()=>{this.#a=!0,this.#l=Date.now()},e.addEventListener("state",n=>this.#d(n)),e.onerror=()=>{this.#a=!1}}#d(e){this.#l=Date.now(),this.#a=!0;const n=JSON.parse(e.data),r=this.state;if(this.state=n,this.catalog===null)return;const l=n.catalog_count!==this.catalog.product_count,f=r!==null&&n.presentation_digest!==r.presentation_digest;(l||f)&&this.#u()}#v(){const e=Bt(Date.now()-this.#l,this.#a);this.link=e,e.mustReconnect&&(this.#r?.close(),this.#l=Date.now(),this.#c())}async#u(){if(!this.#o){this.#o=!0;try{this.catalog=await Ue(),this.catalogError=""}catch{this.catalog===null&&(this.catalogError="Catalogue indisponible. Prévenez un responsable.")}finally{this.#o=!1}}}}const xe="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function Ae(){let s=Date.now();const e=new Array(26);for(let r=9;r>=0;r--)e[r]=xe[s%32],s=Math.floor(s/32);const n=new Uint8Array(16);crypto.getRandomValues(n);for(let r=0;r<16;r++)e[10+r]=xe[n[r]%32];return e.join("")}var Vt=E('
');function Gt(s,e){Y(e,!0);const n=new Ut;let r=D(ze(Ve)),l=D(""),f=D(!1);const c=v(()=>t(l)!==""||t(f));let d=D(null),_=D(-1),o=D(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Qe(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),m=v(()=>Xe(t(y),t(r),t(l))),I=v(()=>{const a={};for(const S of t(i)?.categories??[])a[S.code]=S.color;return a}),g=v(()=>n.state),P=v(()=>n.presentation),B=v(()=>Mt(t(g))),R=v(()=>{if(n.catalogError!=="")return{message:n.catalogError,hint:"Le poste réessaie tout seul."};if(t(y).length>0)return{message:"Aucun produit ne correspond.",hint:"Effacez des lettres ou changez de rayon."};if(t(i)!==null&&t(i).products.length>0)return{message:t(i).products.every(U=>U.mode==="by_unit")&&!t(P).show_by_unit_products?"Aucun produit à montrer : ce poste masque les produits vendus à l’unité, et le catalogue reçu ne contient que ceux-là.":"Aucun produit à montrer : les réglages d’affichage de ce poste masquent tout ce qu’il a reçu.",hint:"Le fichier est bien arrivé : prévenez un responsable, c’est un réglage de ce poste qui vide la grille."};const a=t(g)?.station??0;return{message:a>0?`Catalogue vide. En attente du fichier flv_${a}.csv.`:"Catalogue vide. En attente du fichier du poste.",hint:"Prévenez un responsable : aucun produit ne peut être pesé."}}),H=v(()=>t(g)?.state==="rejected"?t(g).product?.id??null:null),V=v(()=>t(g)?.state==="faulted"||t(g)?.state==="out_of_service"),A=["product_armed","weight_present","weight_stable","awaiting_stability","validating","printing"],O=v(()=>t(g)!==null&&A.includes(t(g).state)?t(g).product?.id??null:null),G=v(()=>Pt(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(_)&&(b(d,null),b(_,-1))});async function X(a){if(t(d)===null){b(d,a.id,!0),b(_,n.state?.revision??-1,!0);try{await Ge({product_id:a.id,tare_g:n.state?.weight.tare_g??0,units:1,manual_weight_g:0,seen_weight_g:n.state?.weight.gross_g??0,measurement_seq:n.state?.weight.seq??0,key:Ae()})}catch{b(d,null)}}}async function K(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Je(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function C(){const a=t(m).length===1?t(m)[0]:void 0;a!==void 0&&X(a)}function M(a){if(!(a.metaKey||a.ctrlKey||a.altKey)&&!(a.target instanceof HTMLElement&&a.target.tagName==="INPUT")&&!(t(o)!==null||t(V))){if(a.key==="Escape"){a.preventDefault(),F();return}if(a.key==="Backspace"){a.preventDefault(),b(l,t(l).slice(0,-1),!0);return}if(a.key==="Enter"){C();return}a.key.length===1&&/[a-zA-Z0-9 ]/.test(a.key)&&(a.key===" "&&t(l)===""||(a.preventDefault(),b(l,t(l)+a.key)))}}ne(()=>(window.addEventListener("keydown",M),()=>window.removeEventListener("keydown",M)));async function $(){(await st(()=>import("./mount-VqoFW5_m.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Vt(),oe=u(ie);{let a=v(()=>t(o)!==null);ot(oe,{get snapshot(){return t(g)},get showWeight(){return n.link.showWeight},get linkBanner(){return n.link.banner},get taring(){return t(a)},ontare:()=>b(o,"")})}var J=h(oe,2);{var ce=a=>{Ot(a,{get grams(){return t(o)},ondigit:S=>b(o,(t(o)??"")+S),onclear:()=>b(o,""),oncancel:()=>b(o,null),onconfirm:()=>b(o,null)})};W(J,a=>{t(o)!==null&&a(ce)})}var le=h(J,2);{let a=v(()=>t(i)?.pricing.primary_code??""),S=v(()=>Object.fromEntries((t(i)?.pricing.tiers??[]).map(Q=>[Q.code,Q.abbrev]))),U=v(()=>t(i)===null&&n.catalogError==="");Et(le,{get products(){return t(m)},get colors(){return t(I)},get primaryCode(){return t(a)},get tierAbbrev(){return t(S)},get selectedID(){return t(O)},get rejectedID(){return t(H)},get busyID(){return t(d)},get showPrices(){return t(P).show_grid_prices},get gridColumns(){return t(P).grid_columns},get loading(){return t(U)},get emptyMessage(){return t(R).message},get emptyHint(){return t(R).hint},onpick:X})}var ue=h(le,2);{var pe=a=>{At(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:C})};W(ue,a=>{t(c)&&a(pe)})}var de=h(ue,2);dt(de,{get chips(){return t(k)},get active(){return t(r)},get searchFieldOpen(){return t(c)},onselect:a=>b(r,a,!0),onopensearch:p});var ve=h(de,2);{let a=v(()=>t(g)?.last_label??null),S=v(()=>t(g)?.reprint.available??!1),U=v(()=>t(i)?.app_version??"");qt(ve,{get label(){return t(a)},get available(){return t(S)},get catalogAt(){return t(G)},get productCount(){return t(y).length},get appVersion(){return t(U)},get healthy(){return t(B)},onreprint:K,onadmin:$})}var x=h(ve,2);{var L=a=>{{let S=v(()=>t(g).message?.text??"Prévenez un responsable.");we(a,{title:"Poste indisponible",get detail(){return t(S)},get code(){return t(g).fault_code},action:{label:"J’ai compris",run:()=>{Ye()}}})}},j=a=>{we(a,{title:"Poste hors service",detail:"Ce poste ne peut pas peser. Prévenez un responsable.",get code(){return t(g).fault_code}})};W(x,a=>{t(g)?.state==="faulted"?a(L):t(g)?.state==="out_of_service"&&a(j,1)})}w(s,ie),Z()}const Xt=5,Kt="ResizeObserver loop";function Jt(){let s=!1,e=!1;const n=(c,d)=>{s||(s=!0,$e(c,d),Qt(),setTimeout(()=>location.reload(),Xt*1e3))},r=c=>{e||(e=!0,Ze(c))},l=c=>{if((c.error===void 0||c.error===null)&&c.message.startsWith(Kt)){r(c.message);return}n(c.message,c.error?.stack??"")},f=c=>{n(String(c.reason),c.reason?.stack??"")};return window.addEventListener("error",l),window.addEventListener("unhandledrejection",f),()=>{window.removeEventListener("error",l),window.removeEventListener("unhandledrejection",f)}}function Qt(){const s=document.createElement("div");s.className="fatal",s.append(me("h1","Une erreur est survenue"),me("p","L’écran va se recharger tout seul."),me("p","ERR-UI-01","code")),document.body.appendChild(s)}function me(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Jt();document.addEventListener("contextmenu",s=>{Yt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());et(Gt,{target:document.getElementById("app")});function Yt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} diff --git a/internal/web/dist/assets/mount-1OvlPcKC.js b/internal/web/dist/assets/mount-1OvlPcKC.js deleted file mode 100644 index 7800b83..0000000 --- a/internal/web/dist/assets/mount-1OvlPcKC.js +++ /dev/null @@ -1,102 +0,0 @@ -import{Y as Tr,Z as zr,_ as jr,$ as Ea,a0 as Vt,e as Rr,a1 as or,a2 as Or,a3 as Ar,a4 as Nr,u as ma,a5 as Ca,a6 as Ir,a7 as ur,x as wt,A as B,h as e,z as v,H as vt,d as ot,a as Be,s as n,i as _,t as f,l as Nt,j as be,m as o,b as Ce,c as l,o as s,g as c,p as Ue,I as Dr,f as Me,n as u,q as ae,a8 as Ia,r as ze,G as Gt,a9 as ea,aa as Tt,B as da,ab as lt,ac as Xt,ad as ka,C as Fr,J as cr,y as sa,T as Ur,ae as Ht,F as Mr,E as Wr,D as Br,O as Vr,K as Gr,X as Hr,af as Jr}from"./app-BaRSXv80.js";function dr(a){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function Kr(a,t,...r){var i=new jr(a);Tr(()=>{const d=t()??null;i.ensure(d,d&&(p=>d(p,...r)))},zr)}function Pa(a,t,r=!1){if(a.multiple){if(t==null)return;if(!Or(t))return Ar();for(var i of a.options)i.selected=t.includes(la(i));return}for(i of a.options){var d=la(i);if(Nr(d,t)){i.selected=!0;return}}(!r||t!==void 0)&&(a.selectedIndex=-1)}function vr(a){var t=new MutationObserver(()=>{"__value"in a&&Pa(a,a.__value)});t.observe(a,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),or(()=>{t.disconnect()})}function $r(a,t,r=t){var i=new WeakSet,d=!0;Ea(a,"change",p=>{var y=p?"[selected]":":checked",z;if(a.multiple)z=[].map.call(a.querySelectorAll(y),la);else{var m=a.querySelector(y)??a.querySelector("option:not([disabled])");z=m&&la(m)}r(z),a.__value=z,Vt!==null&&i.add(Vt)}),Rr(()=>{var p=t();if(a===document.activeElement){var y=Vt;if(i.has(y))return}if(Pa(a,p,d),d&&p===void 0){var z=a.querySelector(":checked");z!==null&&(p=la(z),r(p))}a.__value=p,d=!1}),vr(a)}function la(a){return"__value"in a?a.__value:a.value}function ia(a,t,r=t){var i=new WeakSet;Ea(a,"input",async d=>{var p=d?a.defaultValue:a.value;if(p=ba(a)?wa(p):p,r(p),Vt!==null&&i.add(Vt),await Ir(),p!==(p=t())){var y=a.selectionStart,z=a.selectionEnd,m=a.value.length;if(a.value=p??"",z!==null){var j=a.value.length;y===z&&z===m&&j>m?(a.selectionStart=j,a.selectionEnd=j):(a.selectionStart=y,a.selectionEnd=Math.min(z,j))}}}),ma(t)==null&&a.value&&(r(ba(a)?wa(a.value):a.value),Vt!==null&&i.add(Vt)),Ca(()=>{var d=t();if(a===document.activeElement){var p=Vt;if(i.has(p))return}ba(a)&&d===wa(a.value)||a.type==="date"&&!d&&!a.value||d!==a.value&&(a.value=d??"")})}function Da(a,t,r=t){Ea(a,"change",i=>{var d=i?a.defaultChecked:a.checked;r(d)}),ma(t)==null&&r(a.checked),Ca(()=>{var i=t();a.checked=!!i})}function ba(a){var t=a.type;return t==="number"||t==="range"}function wa(a){return a===""?null:+a}function Fa(a,t,r,i,d){var p=()=>{i(r[a])};r.addEventListener(t,p),d?Ca(()=>{r[a]=d()}):p(),(r===document.body||r===window||r===document)&&or(()=>{r.removeEventListener(t,p)})}function Ta(a){ur===null&&dr(),wt(()=>{const t=ma(a);if(typeof t=="function")return t})}function Yr(a){ur===null&&dr(),Ta(()=>()=>ma(a))}const Xr="ERR-CFG-02";class Pt extends Error{constructor(t,r,i="",d=[]){super(r),this.status=t,this.code=i,this.faults=d,this.name="AdminError"}status;code;faults;get needsPassword(){return this.status===401}get needsFirstPassword(){return this.status===409&&this.code===Xr}get needsCredentials(){return this.needsPassword||this.needsFirstPassword}}function fa(a){return a instanceof Pt&&a.needsCredentials}function Qr(){return It("/admin/api/health")}function Zr(){return He("/admin/api/troubleshooting/reprint",{})}function en(){return He("/admin/api/troubleshooting/reload-catalog",{})}function tn(a){return He("/admin/api/troubleshooting/manual-entry",{on:a})}function an(){return He("/admin/api/troubleshooting/roll-changed",{})}function rn(a){return He("/admin/api/troubleshooting/fallback-printer",{on:a})}function nn(){return He("/admin/api/troubleshooting/test-scale",{})}function sn(){return He("/admin/api/troubleshooting/test-printer",{})}function ln(){return He("/admin/api/troubleshooting/test-label",{})}async function pr(a){const t=new FormData;t.append("file",a,a.name);const r=await fetch("/admin/api/catalog/import",{method:"POST",body:t});return Ra(r,"POST /admin/api/catalog/import")}const on="/admin/api/diagnostic.zip";function un(a){return He("/admin/api/session",{password:a})}async function cn(){await fetch("/admin/api/session",{method:"DELETE"})}function dn(a,t){return He("/admin/api/session/recovery",{code:a,password:t})}function za(){return It("/admin/api/config")}function vn(a){return ja("PUT","/admin/api/config",a)}function pn(){return He("/admin/api/config/reload",{})}function fn(){return He("/admin/api/restart",{})}function gn(){return He("/admin/api/reboot",{})}function mn(){return ja("DELETE","/admin/api/reboot",{})}function hn(){return He("/admin/api/config/confirm",{})}async function _n(){return(await It("/admin/api/config/versions")).versions}function bn(a){return He("/admin/api/config/restore",{version:a})}async function wn(){return(await It("/admin/api/ports")).ports}async function yn(){return(await It("/admin/api/printers")).printers}async function xn(){return(await He("/admin/api/printers/discover",{})).printers}function kn(a){return He("/admin/api/scale/detect",{port:a})}async function Ua(a,t){return(await He("/admin/api/scale/capture",{port:a,seconds:t})).frames}function fr(a){return He("/admin/api/printer/test?what="+a,{})}function qn(a,t,r,i){const d=new URLSearchParams({template:a,demo:t?"1":"0",dual:r?"1":"0"});return d.set("t",String(i)),"/admin/api/label/preview.png?"+d.toString()}async function Sn(a){const t=new URLSearchParams(a);return(await It("/admin/api/journal?"+t)).weighings}function Ln(a){return"/admin/api/journal/export.csv?"+new URLSearchParams(a).toString()}async function En(a){const t=new URLSearchParams(a);return(await It("/admin/api/technical?"+t)).entries}function Cn(a){const t=a===void 0?"":"?id="+String(a);return It("/admin/api/imports"+t)}function Pn(){return He("/admin/api/catalog/reload",{})}function Tn(){return He("/admin/api/catalog/forget-quarantine",{})}function Ma(a,t){return He("/admin/api/products/"+encodeURIComponent(a)+"/decision",t)}function zn(a){return He("/admin/api/replay",{frame:a})}async function It(a){const t=await fetch(a,{headers:{accept:"application/json"}});return Ra(t,"GET "+a)}function He(a,t){return ja("POST",a,t)}async function ja(a,t,r){const i=await fetch(t,{method:a,headers:{"content-type":"application/json"},body:JSON.stringify(r)});return Ra(i,a+" "+t)}async function Ra(a,t){const r=await a.text();if(!a.ok){const i=gr(r);throw new Pt(a.status,Rn(r,t,a.status)+jn(a),i?.code??"",i?.faults??[])}if(r!=="")return JSON.parse(r)}function jn(a){if(a.status!==429)return"";const t=Number(a.headers.get("Retry-After")??"");if(!Number.isFinite(t)||t<=0)return"";const r=Math.ceil(t/60);return r>1?` Réessayez dans ${String(r)} minutes.`:" Réessayez dans une minute."}function Rn(a,t,r){const i=gr(a);return i?.message!==void 0&&i.message!==""?i.message:`${t} a répondu ${String(r)}.`}function gr(a){try{return JSON.parse(a)}catch{return null}}function On(){return It("/admin/api/update")}function An(){return He("/admin/api/update/check",{})}function Nn(a){return He("/admin/api/update/apply",{version:a})}class In{constructor(t){this.admin=t}admin;#t=B(null);get config(){return e(this.#t)}set config(t){v(this.#t,t,!0)}#r=B("");get fingerprint(){return e(this.#r)}set fingerprint(t){v(this.#r,t,!0)}#a=B(vt([]));get retired(){return e(this.#a)}set retired(t){v(this.#a,t,!0)}#e=B(null);get pending(){return e(this.#e)}set pending(t){v(this.#e,t,!0)}#n=B(vt([]));get faults(){return e(this.#n)}set faults(t){v(this.#n,t,!0)}#l=B(!1);get dirty(){return e(this.#l)}set dirty(t){v(this.#l,t,!0)}async load(){const t=await this.admin.load(()=>za());t!==null&&(this.config=t.config,this.fingerprint=t.config_fingerprint,this.retired=t.retired_keys??[],this.pending=t.pending_confirmation,this.faults=[],this.dirty=!1)}value(t){let r=this.config;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}text(t){const r=this.value(t);return r==null?"":String(r)}number(t){const r=this.value(t);return typeof r=="number"?r:Number(r??0)}flag(t){return this.value(t)===!0}set(t,r){if(this.config===null)return;const i=t.split("."),d=i.pop();if(d===void 0)return;let p=this.config;for(const y of i){const z=p[y];(z===null||typeof z!="object")&&(p[y]={}),p=p[y]}p[d]=r,this.dirty=!0}unset(t){const r=t.split("."),i=r.pop();if(i===void 0||this.config===null)return!1;let d=this.config;for(const p of r){if(d===null||typeof d!="object")return!1;d=d[p]}return d===null||typeof d!="object"?!1:(delete d[i],this.dirty=!0,!0)}async save(){if(this.config===null)return!1;this.faults=[];const t=this.config;let r;try{r=await vn(t)}catch(i){if(fa(i))throw i;return this.admin.report(i),this.faults=Dn(this.admin),!1}return this.config=r.config,this.fingerprint=r.config_fingerprint,this.retired=r.retired_keys??[],this.pending=r.pending_confirmation,this.dirty=!1,!0}async confirm(){try{await hn()}catch(t){if(fa(t))throw t;this.admin.report(t);return}this.admin.notice="La configuration est confirmée.",this.pending=null,await this.admin.refresh()}dropRetired(t){if(t.includes("[")){this.admin.actionError=`« ${t} » est logée dans un tableau : cet écran ne sait pas la retirer de là. Modifiez le fichier de configuration lui-même.`;return}this.unset(t)&&(this.retired=this.retired.filter(r=>r!==t))}}function Dn(a){return a.lastFaults}const Fn={"station.number":"Numéro du poste","station.name":"Nom du poste","station.coop":"Nom de la coopérative","network.listen":"Adresse d’écoute","network.admin_on_lan":"Administration accessible depuis le réseau","ui.language":"Langue","ui.sound":"Son","ui.idle_timeout_s":"Retour à l’accueil après (secondes)","ui.reprint_window_s":"Réimpression possible pendant (secondes)","ui.show_grid_prices":"Afficher les prix sur les tuiles","ui.show_by_unit_products":"Afficher les produits vendus à l’unité","ui.grid_columns":"Colonnes de la grille","ui.min_products_for_chip":"Articles minimum pour afficher une catégorie","scale.type":"Protocole de la balance","scale.present":"Ce poste a une balance","scale.manual_entry_allowed":"Saisie manuelle autorisée","scale.degrade_after_s":"Passer en mode dégradé après (secondes)","scale.options.port":"Port série","scale.options.baud":"Vitesse (bauds)","scale.options.bits":"Bits de données","scale.options.parity":"Parité","scale.options.stop":"Bits d’arrêt","scale.options.backoff_min_ms":"Attente minimale avant réessai (ms)","scale.options.backoff_max_ms":"Attente maximale avant réessai (ms)","printer.type":"Pilote d’impression","printer.template":"Gabarit d’étiquette","printer.options.transport":"Transport","printer.options.queue":"File d’impression","printer.options.path":"Fichier de périphérique","printer.options.address":"Adresse réseau","printer.options.darkness":"Noircissement","printer.options.speed":"Vitesse d’impression","printer.options.offset_x":"Décalage horizontal (dots)","printer.options.offset_y":"Décalage vertical (dots)","printer.options.invert_bits":"Inverser les points","printer.options.copies":"Exemplaires","printer.options.roll_capacity":"Étiquettes par rouleau","pricing.amount_rounding":"Arrondi du montant","pricing.unit_price_rounding":"Arrondi du prix au kilo","pricing.primary_code":"Tarif principal","pricing.reference_code":"Tarif de référence","barcode.verify_reference_check_digit":"Vérifier la clé de contrôle","limits.empty_max_g":"Plateau considéré vide en dessous de (g)","limits.basket_check_enabled":"Vérifier la présence du panier","limits.basket_min_g":"Poids du panier, borne basse (g)","limits.basket_max_g":"Poids du panier, borne haute (g)","limits.min_weight_g":"Poids minimum accepté","limits.max_weight_g":"Poids maximum accepté","limits.max_tare_g":"Tare maximale","limits.min_units":"Unités minimum","limits.max_units":"Unités maximum","limits.max_amount_cents":"Montant maximum (centimes)","stability.mode":"Exigence de stabilité","stability.min_duration_ms":"Durée de stabilité (ms)","stability.tolerance_g":"Tolérance de stabilité (g)","stability.timeout_ms":"Délai d’attente de stabilité (ms)","stability.on_timeout":"Au bout du délai","stability.min_latch_rate":"Taux d’accroche minimal","stability.latch_rate_window_ms":"Fenêtre de mesure du taux (ms)","stability.expiry_floor_ms":"Péremption, plancher (ms)","stability.expiry_ceiling_ms":"Péremption, plafond (ms)","stability.expiry_factor":"Péremption, facteur","catalog.type":"Où le poste va chercher le catalogue","catalog.options.directory":"Répertoire surveillé","catalog.options.url":"Adresse du serveur","catalog.options.username":"Compte","catalog.options.password":"Mot de passe","catalog.options.separator":"Séparateur du CSV","catalog.options.poll_interval_s":"Vérifier toutes les (secondes)","catalog.options.stable_polls":"Vérifications avant lecture","catalog.options.max_file_size_mb":"Taille maximale du fichier (Mo)","catalog.options.max_image_size_kb":"Taille maximale d’une image (Ko)","catalog.options.min_readable_ratio":"Part minimale de lignes lisibles","catalog.options.max_weighable_drop":"Baisse maximale des produits pesables","catalog.options.max_archives":"Archives conservées","catalog.options.archive_days":"Archives conservées (jours)","catalog.options.failures_before_reject":"Échecs avant mise en quarantaine","catalog.images.source":"Origine des photos","catalog.images.path":"Répertoire des photos","catalog.fallback_category":"Rayon par défaut","journal.max_rows":"Pesées conservées","journal.max_days":"Pesées conservées (jours)","journal.max_technical":"Événements techniques conservés","admin.session_minutes":"Durée d’une session (minutes)","admin.attempts_per_minute":"Tentatives par minute","maintenance.weekly_integrity_check":"Contrôle d’intégrité hebdomadaire","maintenance.disk_alert_mb":"Alerte disque en dessous de (Mo)"};function qt(a){return Fn[a]??a}const Un={station:"l’identité du poste",network:"le réseau",ui:"l’écran client",scale:"la balance",printer:"l’imprimante",pricing:"les tarifs",barcode:"le code-barres",limits:"les garde-fous",stability:"la stabilité",catalog:"le catalogue",journal:"le journal",maintenance:"la maintenance"};function Mn(a){return Un[a]??a}const Wn={scale:"balance",printer:"imprimante",catalog:"catalogue",ui:"écran",config:"configuration",http:"réseau",system:"système"};function mr(a){return Wn[a]??"origine inconnue"}const qa="openscale.admin.preferences",hr="technical";class Bn{#t=B(vt(Vn()));get showTechnicalNames(){return e(this.#t)}set showTechnicalNames(t){v(this.#t,t,!0)}toggleTechnicalNames(){this.showTechnicalNames=!this.showTechnicalNames,Gn(this.showTechnicalNames)}}function Vn(){try{return globalThis.localStorage?.getItem(qa)===hr}catch{return!1}}function Gn(a){try{a?globalThis.localStorage?.setItem(qa,hr):globalThis.localStorage?.removeItem(qa)}catch{}}const St=new Bn,Hn=3e3,Jn=["hardware","label","rules","catalog","journal","station","update"];function Wa(a){return Jn.includes(a)}function Kn(a){return a instanceof Pt&&a.needsCredentials}class $n{constructor(t=Hn){this.periodMs=t}periodMs;#t=B(null);get health(){return e(this.#t)}set health(t){v(this.#t,t,!0)}#r=B("dashboard");get page(){return e(this.#r)}set page(t){v(this.#r,t,!0)}#a=B(!1);get expert(){return e(this.#a)}set expert(t){v(this.#a,t,!0)}#e=B("");get linkError(){return e(this.#e)}set linkError(t){v(this.#e,t,!0)}#n=B("");get actionError(){return e(this.#n)}set actionError(t){v(this.#n,t,!0)}#l=B(!1);get needsFirstPassword(){return e(this.#l)}set needsFirstPassword(t){v(this.#l,t,!0)}#d=B("");get notice(){return e(this.#d)}set notice(t){v(this.#d,t,!0)}#v=B(!1);get busy(){return e(this.#v)}set busy(t){v(this.#v,t,!0)}#p=B(vt([]));get lastFaults(){return e(this.#p)}set lastFaults(t){v(this.#p,t,!0)}#f=B(null);get pending(){return e(this.#f)}set pending(t){v(this.#f,t,!0)}#i=null;#o=null;start(){this.refresh(),this.#i=setInterval(()=>{this.refresh()},this.periodMs)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null}async refresh(){try{this.health=await Qr(),this.linkError=""}catch(t){this.linkError=Ba(t)}}#u(){this.busy=!0,this.notice="",this.actionError="",this.needsFirstPassword=!1}#s(t){this.actionError=Ba(t),this.needsFirstPassword=t instanceof Pt&&t.needsFirstPassword,this.#m(t)}async run(t){this.#u();let r=null;try{r=await t(),this.notice=r.message}catch(i){this.#s(i)}finally{this.busy=!1}return await this.refresh(),r}async login(t){this.#u();try{return await un(t),this.expert=!0,this.notice="Session d’administration ouverte.",!0}catch(r){return this.expert=!1,this.#s(r),!1}finally{this.busy=!1}}async recover(t,r){this.#u();try{const i=await dn(t,r);return this.expert=!0,this.notice=i.warning??"Le mot de passe est remplacé et la session est ouverte.",!0}catch(i){return this.#s(i),!1}finally{this.busy=!1}}async protect(t){try{return await t()}catch(r){if(!Kn(r))return this.#s(r),null;if(!await this.#g(r))return null;try{return await t()}catch(i){return this.#s(i),null}}}#g(t){return this.pending={kind:t.needsFirstPassword?"first-password":"password",message:t.message},new Promise(r=>{this.#o=r})}async answerPassword(t){await this.login(t)&&this.#c(!0)}async answerRecovery(t,r){await this.recover(t,r)&&this.#c(!0)}cancelPassword(){this.#c(!1)}#c(t){this.pending=null,this.notice="";const r=this.#o;this.#o=null,r?.(t)}async logout(){await cn(),this.expert=!1,this.page="dashboard",this.notice="Session d’administration fermée."}open(t){this.actionError="",this.notice="",this.needsFirstPassword=!1,this.page=t}async load(t){try{const r=await t();return this.actionError="",this.lastFaults=[],r}catch(r){return this.lastFaults=r instanceof Pt?r.faults:[],this.#s(r),null}}report(t){this.lastFaults=t instanceof Pt?t.faults:[],this.#s(t)}#m(t){t instanceof Pt&&t.needsPassword&&(this.expert=!1)}}function Ba(a){return a instanceof Pt?a.message:a instanceof Error?"Le poste n’a pas répondu : "+a.message:"Le poste n’a pas répondu."}var Yn=c('clé'),Xn=c('');function Te(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"busy",3,!1),d=Be(t,"disabled",3,!1),p=Be(t,"protected",3,!1),y=Be(t,"act",3,void 0);var z=Xn();let m;var j=s(z),S=n(j);{var A=L=>{var W=Yn();l(L,W)};_(S,L=>{p()&&L(A)})}f(()=>{m=Nt(z,1,`act ${r()??""}`,"svelte-5tpq0o",m,{"touch-target":r()==="destructive",busy:i()}),be(z,"data-kind",r()),be(z,"data-act",y()),z.disabled=d()||i(),o(j,`${(i()?"En cours…":t.label)??""} `)}),Ce("click",z,function(...L){t.onrun?.apply(this,L)}),l(a,z)}ot(["click"]);var Qn=c(`

Le code de secours a été tiré à l’installation de ce poste et imprimé - sur sa fiche, rangée dans le classeur du magasin. Il n’existe nulle part ailleurs.

`,1),Zn=c(`

Oublié ? Le code de secours de la fiche d’installation en repose un. - Un responsable peut aussi le faire en ligne de commande, avec openscale config password.

`,1),es=c('

'),ts=c('');function as(a,t){Ue(t,!0);const r=u(()=>t.admin.pending?.kind==="first-password");let i=B(""),d=B("");const p=8,y=u(()=>e(r)?e(d).trim().length>0&&e(i).length>=p:e(i).length>0);async function z(){!e(y)||t.admin.busy||(e(r)?await t.admin.answerRecovery(e(d),e(i)):await t.admin.answerPassword(e(i)),v(i,""),v(d,""))}var m=ts(),j=s(m),S=s(j),A=s(S),L=s(A);Dr(L,{name:"settings",size:"1.5rem"});var W=n(A,2),O=s(W),C=n(S,2),K=s(C),U=n(C,2);{var E=T=>{var w=Qn(),k=n(ae(w),2),R=n(s(k),2);Ia(R);var F=n(k,2),h=s(F);h.textContent="Nouveau mot de passe — 8 caractères au moins";var G=n(h,2);Ce("keydown",R,Y=>Y.key==="Enter"&&void z()),ia(R,()=>e(d),Y=>v(d,Y)),Ce("keydown",G,Y=>Y.key==="Enter"&&void z()),ia(G,()=>e(i),Y=>v(i,Y)),l(T,w)},x=T=>{var w=Zn(),k=ae(w),R=n(s(k),2);Ia(R),Ce("keydown",R,F=>F.key==="Enter"&&void z()),ia(R,()=>e(i),F=>v(i,F)),l(T,w)};_(U,T=>{e(r)?T(E):T(x,-1)})}var g=n(U,2);{var D=T=>{var w=es(),k=s(w);f(()=>o(k,t.admin.actionError)),l(T,w)};_(g,T=>{t.admin.actionError!==""&&T(D)})}var H=n(g,2),V=s(H);Te(V,{label:"Annuler",onrun:()=>t.admin.cancelPassword()});var $=n(V,2);{let T=u(()=>e(r)?"Poser ce mot de passe":"Continuer"),w=u(()=>!e(y)||t.admin.busy);Te($,{kind:"write",get label(){return e(T)},get disabled(){return e(w)},onrun:()=>{z()}})}f(()=>{o(O,e(r)?"Ce poste n’a pas encore de mot de passe":"Mot de passe d’administration"),o(K,t.admin.pending?.message??"")}),l(a,m),Me()}ot(["keydown"]);var rs=c(' '),ns=c(""),ss=c(''),ls=c(''),is=c(""),os=c(""),us=c('

'),cs=c(" "),ds=c('

'),vs=c('
');function at(a,t){Ue(t,!0);const r=Be(t,"hint",3,""),i=Be(t,"kind",3,"text"),d=Be(t,"disabled",3,!1),p=Be(t,"fault",3,""),y=Be(t,"allowed",19,()=>[]),z=Be(t,"choices",19,()=>[]),m=u(()=>`field-${t.path.replace(/\./gu,"-")}`);var j=vs();let S;var A=s(j),L=s(A),W=s(L),O=n(L,2);{var C=T=>{var w=rs(),k=s(w);f(()=>o(k,t.path)),l(T,w)};_(O,T=>{St.showTechnicalNames&&T(C)})}var K=n(A,2);{var U=T=>{var w=ss();ze(w,21,z,R=>R.value,(R,F)=>{var h=ns(),G=s(h),Y={};f(()=>{o(G,e(F).label),Y!==(Y=e(F).value)&&(h.value=(h.__value=e(F).value)??"")}),l(R,h)});var k;vr(w),f(()=>{be(w,"id",e(m)),w.disabled=d(),k!==(k=t.value)&&(w.value=(w.__value=t.value)??"",Pa(w,t.value))}),Ce("change",w,R=>t.onchange(R.currentTarget.value)),l(T,w)},E=T=>{var w=ls();f(()=>{be(w,"id",e(m)),be(w,"type",i()),Gt(w,t.value),w.disabled=d(),be(w,"list",y().length>0?e(m)+"-allowed":void 0)}),Ce("input",w,k=>t.onchange(k.currentTarget.value)),l(T,w)};_(K,T=>{z().length>0?T(U):T(E,-1)})}var x=n(K,2);{var g=T=>{var w=os();ze(w,20,y,k=>k,(k,R)=>{var F=is(),h={};f(()=>{h!==(h=R)&&(F.value=(F.__value=R)??"")}),l(k,F)}),f(()=>be(w,"id",e(m)+"-allowed")),l(T,w)};_(x,T=>{y().length>0&&T(g)})}var D=n(x,2);{var H=T=>{var w=us(),k=s(w);f(()=>o(k,r())),l(T,w)};_(D,T=>{r()!==""&&T(H)})}var V=n(D,2);{var $=T=>{var w=ds(),k=s(w),R=n(k);{var F=h=>{var G=cs(),Y=s(G);f(ne=>o(Y,`Valeurs acceptées : ${ne??""}.`),[()=>y().join(", ")]),l(h,G)};_(R,h=>{y().length>0&&h(F)})}f(()=>o(k,`${p()??""} `)),l(T,w)};_(V,T=>{p()!==""&&T($)})}f(()=>{S=Nt(j,1,"field svelte-1oilv5s",null,S,{refused:p()!==""}),be(A,"for",e(m)),o(W,t.label)}),l(a,j),Me()}ot(["change","input"]);var ps=c('

'),fs=c('

');function Oe(a,t){const r=Be(t,"note",3,"");var i=fs(),d=s(i),p=s(d),y=n(d,2);{var z=j=>{var S=ps(),A=s(S);f(()=>o(A,r())),l(j,S)};_(y,j=>{r()!==""&&j(z)})}var m=n(y,2);Kr(m,()=>t.children),f(()=>o(p,t.title)),l(a,i)}function $e(a,t){return a.faults.find(r=>r.field===t)?.message??""}function Qt(a,t){return a.faults.find(r=>r.field===t)?.allowed??[]}var gs=c('

Lecture des réglages du poste…

'),ms=c(`

Le poste y cherche le fichier , et le supprime - une fois lu : c’est ce qui dit au producteur que la livraison est prise.

`,1),hs=c(`

Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus - possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours - du jour de la mise en service.

`,1),_s=c(`

Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira - chercher aucun catalogue.

`),bs=c('
',1);function ws(a,t){Ue(t,!0);const r={local_drop:["catalog.options.directory"],webdav:["catalog.options.url","catalog.options.username","catalog.options.password"]},i=u(()=>t.draft.text("catalog.type"));function d(p){t.draft.set("catalog.type",p);for(const[y,z]of Object.entries(r))if(y!==p)for(const m of z)t.draft.unset(m)}Oe(a,{title:"Où le poste va chercher le catalogue",children:(p,y)=>{var z=bs(),m=ae(z),j=s(m),S=s(j),A=n(j,2),L=s(A),W=n(m,2);{var O=E=>{var x=gs();l(E,x)},C=E=>{var x=ms(),g=ae(x);{let $=u(()=>qt("catalog.options.directory")),T=u(()=>t.draft.text("catalog.options.directory")),w=u(()=>$e(t.draft,"catalog.options.directory"));at(g,{get label(){return e($)},path:"catalog.options.directory",get value(){return e(T)},hint:"Laissez vide pour le répertoire du poste, celui que le service crée lui-même. Un répertoire nommé ici doit exister : le poste ne le crée pas.",get fault(){return e(w)},onchange:k=>t.draft.set("catalog.options.directory",k)})}var D=n(g,2),H=n(s(D)),V=s(H);f(()=>o(V,`flv_${t.station??""}.csv`)),l(E,x)},K=E=>{var x=hs(),g=ae(x);{let V=u(()=>qt("catalog.options.url")),$=u(()=>t.draft.text("catalog.options.url")),T=u(()=>$e(t.draft,"catalog.options.url"));at(g,{get label(){return e(V)},path:"catalog.options.url",get value(){return e($)},get fault(){return e(T)},onchange:w=>t.draft.set("catalog.options.url",w)})}var D=n(g,2);{let V=u(()=>qt("catalog.options.username")),$=u(()=>t.draft.text("catalog.options.username")),T=u(()=>$e(t.draft,"catalog.options.username"));at(D,{get label(){return e(V)},path:"catalog.options.username",get value(){return e($)},get fault(){return e(T)},onchange:w=>t.draft.set("catalog.options.username",w)})}var H=n(D,2);{let V=u(()=>qt("catalog.options.password")),$=u(()=>$e(t.draft,"catalog.options.password"));at(H,{get label(){return e(V)},path:"catalog.options.password",kind:"password",value:"",hint:"Laissez vide : le mot de passe actuel est conservé.",get fault(){return e($)},onchange:T=>t.draft.set("catalog.options.password",T)})}l(E,x)},U=E=>{var x=_s();l(E,x)};_(W,E=>{t.draft.config===null?E(O):e(i)==="local_drop"?E(C,1):e(i)==="webdav"?E(K,2):E(U,-1)})}f(()=>{ea(S,e(i)==="local_drop"),ea(L,e(i)==="webdav")}),Ce("change",S,()=>d("local_drop")),Ce("change",L,()=>d("webdav")),l(p,z)},$$slots:{default:!0}}),Me()}ot(["change"]);const Va=1e3;function va(a){const t=Oa(a);return t===null?"":[Zt(t.getDate()),Zt(t.getMonth()+1),String(t.getFullYear())].join("/")}function Lt(a){const t=Oa(a);return t===null?"":`${va(a)} à ${Zt(t.getHours())}:${Zt(t.getMinutes())}`}function ys(a){const t=Oa(a);return t===null?"":[Zt(t.getHours()),Zt(t.getMinutes()),Zt(t.getSeconds())].join(":")}function Sa(a){const t=a/1e6;return t<1e3?`${Math.round(t)} Mo`:`${_r(t/1e3)} Go`}function se(a){const t=String(Math.abs(Math.trunc(a))),r=[];for(let i=t.length;i>0;i-=3)r.unshift(t.slice(Math.max(0,i-3),i));return(a<0?"-":"")+r.join(" ")}function ua(a){return a0}function qs(a){const t=a.motive!=="";return{canWithdraw:t||!pa(!1,a.waiverInForce),canOfferAgain:t||!pa(!0,a.waiverInForce),canSaveWaiver:ks(a.typedWaiver)&&(t||!pa(a.offeredInForce,a.typedWaiver)),canDropWaiver:t||!pa(a.offeredInForce,null)}}function br(a){const t=[];return a.offered||t.push("retiré de la grille"),a.min_weight_g!==null&&t.push(`peut peser à partir de ${se(a.min_weight_g)} g`),t.length===0?"aucune restriction":t.join(" · ")}function ca(a,t,r,i){const d=t>1?i:r;return a>=t?`${se(t)} ${d}.`:`${se(a)} lignes affichées sur ${se(t)} ${d}.`}function Ss(a,t){return t>a?`${se(a)} produits affichés sur ${se(t)} trouvés — précisez votre recherche.`:`${se(t)} ${t>1?"produits trouvés":"produit trouvé"}.`}function Ls(a){return`${se(a)} ${a>1?"imports affichés":"import affiché"} : le poste n’en publie jamais plus de vingt.`}function ya(a,t,r,i){const d=`${se(r)} ${r>1?t:a}`;return r<=i?d+".":`${d} — seules les ${se(i)} premières lignes sont affichées.`}function Es(a,t){const r=`${se(a)} ${a>1?"produits retirés":"produit retiré"}`;return t===""?r+".":`${r} depuis l’import du ${Lt(t)}.`}var Cs=c('

Aucune décision locale : la grille est celle du fichier.

'),Ps=c('
  • '),Ts=c('

      ',1);function zs(a,t){Ue(t,!0);const r=20,i=u(()=>t.decisions.slice(0,r));Oe(a,{title:"Décisions en vigueur",note:"Ce qu’un humain a décidé, produit par produit. C’est ici que se reprend un produit retiré de la grille.",children:(d,p)=>{var y=Tt(),z=ae(y);{var m=S=>{var A=Cs();l(S,A)},j=S=>{var A=Ts(),L=ae(A),W=s(L),O=n(L,2),C=s(O);ze(C,21,()=>e(i),K=>K.product_id,(K,U)=>{var E=Ps(),x=s(E),g=n(x,2),D=s(g),H=n(g,2),V=s(H),$=n(H,2),T=s($),w=n($,2),k=s(w),R=n(w,2),F=s(R);f((h,G,Y)=>{o(D,h),o(V,e(U).product_id),o(T,G),o(k,e(U).reason),o(F,Y)},[()=>t.nameOf(e(U).product_id),()=>br(e(U)),()=>va(e(U).decided_at)]),Ce("click",x,()=>t.onchoose(e(U).product_id)),l(K,E)}),f(K=>o(W,K),[()=>ca(e(i).length,t.decisions.length,"décision en vigueur","décisions en vigueur")]),l(S,A)};_(z,S=>{t.decisions.length===0?S(m):S(j,-1)})}l(d,y)},$$slots:{default:!0}}),Me()}ot(["click"]);function js(a){return a==="loading"?"Lecture des signalements du dernier import…":"Les signalements du dernier import n’ont pas pu être lus : cet écran ne sait pas ce qu’ils disent."}function Rs(a){return a==="loading"?"Lecture de l’historique des imports…":"L’historique des imports n’a pas pu être lu : cet écran ne sait pas ce qu’il contient."}function Aa(a,t,r){return a!==void 0?a:t==="loading"?"Lecture du nom…":t==="unread"?r:"Produit absent du catalogue en service"}var Ga=c('

      '),Os=c(' '),As=c('
    • '),Ns=c('

        ',1);function xa(a,t){Ue(t,!0);const r=Be(t,"remedy",3,""),i=50,d=u(()=>t.findings.slice(0,i)),p=u(()=>t.findings.length>e(d).length);Oe(a,{get title(){return t.title},get note(){return t.note},children:(y,z)=>{var m=Tt(),j=ae(m);{var S=W=>{var O=Ga(),C=s(O);f(K=>{be(O,"data-unread",t.list),o(C,K)},[()=>js(t.state)]),l(W,O)},A=W=>{var O=Ga(),C=s(O);f(()=>o(C,t.none)),l(W,O)},L=W=>{var O=Ns(),C=ae(O),K=s(C),U=n(K);{var E=D=>{var H=lt();f(()=>o(H,r())),l(D,H)};_(U,D=>{e(p)&&r()!==""&&D(E)})}var x=n(C,2),g=s(x);ze(g,21,()=>e(d),da,(D,H)=>{var V=As(),$=s(V),T=s($),w=n($,2);{var k=M=>{var b=Os(),N=s(b);f(()=>o(N,e(H).product_name)),l(M,b)};_(w,M=>{e(H).product_name!==""&&M(k)})}var R=n(w,2),F=s(R),h=n(R,2),G=s(h),Y=n(h,2),ne=s(Y);f(M=>{o(T,`ligne ${M??""}`),o(F,e(H).product_id),o(G,e(H).value),o(ne,e(H).message)},[()=>se(e(H).csv_line)]),l(D,V)}),f(D=>{be(C,"data-tally",t.list),o(K,`${D??""} `),be(x,"data-rows",t.list)},[()=>ca(e(d).length,t.findings.length,t.singular,t.plural)]),l(W,O)};_(j,W=>{t.state!=="read"?W(S):t.findings.length===0?W(A,1):W(L,-1)})}l(y,m)},$$slots:{default:!0}}),Me()}function wr(a,t){const r=a.rows_read_count,i=a.images_decoded_count,d=r-i,p=[{count:_t(a.weighable_count),label:"pesables",note:`(${_t(i)} avec photo, ${_t(d)} sans)`,link:""},{count:_t(a.not_weighable_count),label:"non pesables",note:Bs(t),link:""}];if(a.anomalies_count>0&&p.push({count:_t(a.anomalies_count),label:"anomalies",note:"à corriger dans Odoo",link:Ha(a.anomalies_count)}),a.unit_mismatches_count>0){const y=a.unit_mismatches_count;p.push({count:"+ "+_t(y),label:y===1?"unité divergente":"unités divergentes",note:"pesable, unité à corriger",link:Ha(y)})}return{headline:`Catalogue du ${va(a.occurred_at)} — ${_t(r)} produits reçus`,lines:p,oneLine:Is(a)}}function Is(a){const t=[`${_t(a.rows_read_count)} reçus`,`${_t(a.weighable_count)} pesables`,`${_t(a.not_weighable_count)} non pesables`,`${_t(a.anomalies_count)} anomalies`],r=a.unit_mismatches_count;return r>0&&t.push(`${_t(r)} ${r===1?"unité divergente":"unités divergentes"}`),t.join(" · ")}const Ds={applied:"appliqué",unchanged:"identique au précédent",rejected:"refusé",failed:"échec"},Fs={local_drop:"dépôt local",webdav:"WebDAV",manual:"déposé sur l’écran"};function ha(a){return Ds[a]??"résultat inconnu"}function yr(a){return Fs[a]??"source inconnue"}function Us(a,t){const r=[a.file_name===""?"Le dernier fichier":a.file_name,ha(a.result)],i=Lt(a.occurred_at);i!==""&&r.push("le "+i),r.push("via "+yr(a.source));const d=a.reason===""?"":" "+a.reason;return`${r.join(" ")} — ${wr(a,t).oneLine}.${d}`}function Ms(a){return a===""?"Aucun nouvel import enregistré à cet instant, et ce poste ne publie pas ce qu’il surveille.":"Aucun nouvel import enregistré à cet instant. Le poste surveille "+a+" : le fichier n’y était pas, ou il n’a pas encore fini d’arriver."}function Ws(a){return"Ce poste n’a pas de journal : il ne pourra rien dire de l’issue de cette relecture."+(a===""?"":" Le poste surveille "+a+".")}function Ha(a){return a===1?"voir la ligne":`voir les ${_t(a)} lignes`}function Bs(a){return a.map(t=>`${Vs(t)} (${_t(t.count)})`).join(", ")}function Vs(a){switch(a.code){case"PREPACKAGED_PRODUCT":return"préemballés";case"INTERNAL_CODE_NOT_WEIGHABLE":return a.value===""?"code interne":"code interne "+a.value;case"NO_BARCODE":return"sans code-barres";default:return a.code}}function _t(a){return String(a)}var Gs=c('

        '),Hs=c('

        Aucun import dans l’historique.

        '),Js=c(' '),Ks=c('

        QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
        ',1);function $s(a,t){Ue(t,!0);function r(i){const d=ha(i.result);return i.code===""?d:`${d} (${i.code})`}Oe(a,{title:"Vingt derniers imports",children:(i,d)=>{var p=Tt(),y=ae(p);{var z=S=>{var A=Gs(),L=s(A);f(W=>o(L,W),[()=>Rs(t.state)]),l(S,A)},m=S=>{var A=Hs();l(S,A)},j=S=>{var A=Ks(),L=ae(A),W=s(L),O=n(L,2),C=s(O),K=n(s(C));ze(K,21,()=>t.imports,U=>U.id,(U,E)=>{var x=Js(),g=s(x),D=s(g),H=n(g),V=s(H),$=n(H),T=s($),w=n($),k=s(w),R=n(w),F=s(R),h=n(R),G=s(h),Y=n(h),ne=s(Y),M=n(Y),b=s(M),N=n(M),X=s(N),de=n(N),he=s(de);f((fe,oe,I,Q,re,ee,ve,ye)=>{o(D,fe),o(V,e(E).file_name),o(T,oe),o(k,I),o(F,e(E).reason),o(G,Q),o(ne,re),o(b,ee),o(X,ve),o(he,ye)},[()=>Lt(e(E).occurred_at),()=>yr(e(E).source),()=>r(e(E)),()=>se(e(E).rows_read_count),()=>se(e(E).weighable_count),()=>se(e(E).not_weighable_count),()=>se(e(E).anomalies_count),()=>se(e(E).products_withdrawn_count)]),l(U,x)}),f(U=>o(W,U),[()=>Ls(t.imports.length)]),l(S,A)};_(y,S=>{t.state!=="read"?S(z):t.imports.length===0?S(m,1):S(j,-1)})}l(i,p)},$$slots:{default:!0}}),Me()}var Ys=c(' '),Xs=c(''),Qs=c('
        '),Zs=c('

        ');function xr(a,t){Ue(t,!0);const r=u(()=>wr(t.record,t.motives));var i=Zs(),d=s(i),p=s(d),y=n(d,2);ze(y,21,()=>e(r).lines,j=>j.label,(j,S)=>{var A=Qs(),L=s(A),W=s(L),O=n(L,2),C=s(O),K=s(C),U=n(C,2);{var E=D=>{var H=Ys(),V=s(H);f(()=>o(V,e(S).note)),l(D,H)};_(U,D=>{e(S).note!==""&&D(E)})}var x=n(U,2);{var g=D=>{var H=Xs(),V=s(H);f(()=>o(V,e(S).link)),Ce("click",H,()=>t.onshowrows(e(S).label==="anomalies"?"anomalies":"units")),l(D,H)};_(x,D=>{e(S).link!==""&&t.onshowrows!==void 0&&D(g)})}f(()=>{be(A,"data-row",e(S).label),o(W,e(S).count),o(K,e(S).label)}),l(j,A)});var z=n(y,2),m=s(z);f(()=>{o(p,e(r).headline),o(m,e(r).oneLine)}),l(a,i),Me()}ot(["click"]);var el=c(" "),tl=c(' '),al=c('');function Na(a,t){Ue(t,!0);const r=Be(t,"hint",3,"");var i=al(),d=s(i),p=n(d,2),y=s(p),z=s(y),m=n(y,2);{var j=L=>{var W=el(),O=s(W);f(()=>o(O,t.path)),l(L,W)};_(m,L=>{St.showTechnicalNames&&L(j)})}var S=n(m,2);{var A=L=>{var W=tl(),O=s(W);f(()=>o(O,r())),l(L,W)};_(S,L=>{r()!==""&&L(A)})}f(L=>{be(i,"data-flag",t.path),be(i,"data-on",L),ea(d,t.on),o(z,t.label)},[()=>String(t.on)]),Ce("change",d,L=>t.onchange(L.currentTarget.checked)),l(a,i),Me()}ot(["change"]);function Ja(a){const t=`${se(a.columns)} ${a.columns>1?"colonnes":"colonne"}`,r=`${se(a.rows)} ${a.rows>1?"rangées":"rangée"}`;return`${t} × ${r}`}function rl(a){const t=a.layout,r=[];if(a.columns===Xt)return r.push(t===null?"Automatique : la grille suit la largeur de l’écran. Un écran plus large en montre davantage sans qu’on y revienne.":`Automatique : ${Ja(t)} sur cet écran. Un écran plus large en montrera davantage sans qu’on y revienne.`),r;if(t===null)return r.push(`${se(a.columns)} ${a.columns>1?"colonnes":"colonne"} sur tous les écrans. Cet écran ne sait pas dire combien de rangées cela fait ici.`),r;const i=t.columns*t.rows;if(r.push(`${Ja(t)} — ${se(i)} ${i>1?"tuiles":"tuile"} d’un coup, sur cet écran (${String(a.viewport.width)} × ${String(a.viewport.height)}).`),a.tileCount>0){const p=Math.ceil(a.tileCount/i);r.push(a.tileCount>1?`Les ${se(a.tileCount)} tuiles de la grille tiennent en ${se(p)} ${p>1?"écrans":"écran"}.`:"La seule tuile de la grille tient en un écran.")}const d=a.floor;if(d!==null&&d.names>0){const p=d.names>1;r.push(`${se(d.names)} ${p?"noms":"nom"} sur ${se(a.tileCount)} ${p?"atteignent":"atteint"} le plancher de ${se(ka)} px : `+(d.rows>1?`leurs ${se(d.rows)} rangées peuvent être plus hautes que les autres.`:"leur rangée peut être plus haute que les autres."))}return r}function nl(a,t,r){if(a==="loading")return"Lecture du catalogue en service…";if(a==="unread")return"Le catalogue en service n’a pas pu être lu : cet écran ne sait pas combien de produits se vendent à l’unité.";if(t===0)return"Aucun produit vendu à l’unité dans le catalogue en service.";const i=t>1,d=i?"produits vendus à l’unité sont":"produit vendu à l’unité est",p=r?`${i?"montrés":"montré"} dans la grille de ce poste`:`${i?"masqués":"masqué"} sur ce poste`;return`${se(t)} ${d} ${p}.`}function sl(a){return["localhost","127.0.0.1"].includes(a)?"":"Cet écran n’est pas celui du poste : ce compte vaut pour l’écran que vous lisez."}const ll=-1,il=10;class kr{#t=B("");get sentence(){return e(this.#t)}set sentence(t){v(this.#t,t,!0)}#r=0;#a="";#e=0;begin(t){this.sentence="",this.#r=t.last_import_id,this.#a=t.watched,this.#e=il}forget(){this.sentence="",this.#e=0}observe(t){if(this.#e===0)return;if(t.counters.journal_rows_count===ll){this.#n(Ws(this.#a));return}const r=t.catalog;if(r!==null&&r.id!==this.#r){this.#n(Us(r,t.catalog_motives));return}this.#e-=1,this.#e===0&&(this.sentence=Ms(this.#a))}#n(t){this.sentence=t,this.#e=0}}var ol=c('

        Aucun import enregistré sur ce poste.

        '),ul=c('

        '),cl=c(`

        « Oublier la quarantaine » fait relire un fichier que le poste avait écarté : c’est le - seul geste de cette page qui puisse remettre en service un catalogue refusé.

        `,1),Ka=c(""),dl=c(''),vl=c('

        '),pl=c('

        '),fl=c('
        '),gl=c(`

        Un produit masqué reste vendable : la caisse lit toujours son code-barres, et une - étiquette déjà imprimée reste valable. Ce réglage ne fait que retirer sa tuile.

        Colonnes de la grille

        Se tromper ne coûte rien d’autre que de revenir ici : le réglage ne change ni le - fichier reçu, ni les étiquettes déjà imprimées.

        En dessous de ce nombre, la catégorie n’a pas de puce dans la barre du bas. Ses - produits restent dans « Tout » et à la recherche : ce réglage ne retire aucune - tuile. Pour ne plus montrer une catégorie du tout, c’est sa case « visible » qu’il - faut décocher.

        `,1),ml=c('

        '),hl=c(`

        Déposez ici le fichier clé

        Ce dépôt remplace toute la grille par le fichier apporté : il change ce que le poste - vend, et le mot de passe est donc demandé au moment du dépôt.

        `,1),_l=c('

        Aucun import enregistré : rien n’a encore pu être retiré.

        '),bl=c('

        Aucun produit retiré par le dernier import.

        '),wl=c(`

        Ils restent enregistrés avec leur historique : une étiquette déjà collée reste - lisible en caisse, et un produit qui revient dans un prochain fichier retrouve sa - tuile. Ce poste n’en publie encore que le nombre — leurs noms se lisent dans Odoo, - en comparant avec l’export précédent.

        `,1),yl=c('
      • '),xl=c(`

          Un produit retiré de la grille ne se trouve plus ici : il se reprend dans - « Décisions en vigueur », plus bas.

          `,1),kl=c(`

          Sans motif, le poste refuse la décision. Le seul acte qui s’en passe est celui qui - EFFACE la décision en vigueur : proposer de nouveau un produit qui ne porte aucune - dérogation.

          `),ql=c(`

          Produit choisi :

          Ce produit est-il proposé dans la grille ?

          La dérogation s’enregistre seule : elle ne remet pas dans la grille un produit - qui en a été retiré, et retirer un produit n’efface pas sa dérogation.

          `),Sl=c(' ',1),Ll=c('
          ');function El(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=20,d=["NO_BARCODE","PREPACKAGED_PRODUCT","INTERNAL_CODE_NOT_WEIGHABLE"],p="ui.grid_columns",y=[3,4,5,6,7,8,9,10,11,12],z="ui.min_products_for_chip";let m=B(vt([])),j=B(vt([])),S=B(null);const A=u(()=>e(S)?.products??[]),L=u(()=>e(S)?.presentation??{}),W=u(()=>e(S)?.pricing?.primary_code??"");let O=B(""),C=B(""),K=B(""),U=B(""),E=B(!1),x=B(""),g=B(""),D=B("loading"),H=B("loading");const V=u(()=>r().busy||e(g)!==""),$=u(()=>e(j).filter(J=>J.issue==="anomaly")),T=u(()=>e(j).filter(J=>d.includes(J.code))),w=u(()=>e(j).filter(J=>J.code==="UNIT_MISMATCH")),k=u(()=>new Map(e(A).map(J=>[J.id,J.name]))),R=u(()=>e(A).filter(J=>J.mode==="by_unit").length),F=u(()=>nl(e(H),e(R),t.draft.flag("ui.show_by_unit_products"))),h=u(()=>t.draft.number(p)),G=u(()=>t.draft.number(z)),Y=u(()=>M("ui.show_by_unit_products",e(L).show_by_unit_products??!1)?e(A):e(A).filter(J=>J.mode!=="by_unit")),ne=u(()=>e(Y)[0]===void 0?null:{...e(Y)[0],name:"·",image_url:"",prices:e(Y)[0].prices??[]});function M(J,me){return t.draft.value(J)===void 0?me:t.draft.flag(J)}let b=B(null),N=B(null),X=B(null),de=B(null),he=B(null),fe=B(0),oe=B(null);wt(()=>{const J=()=>{v(fe,e(fe)+1)};return window.addEventListener("resize",J),()=>window.removeEventListener("resize",J)}),wt(()=>{const J=e(h);e(fe),v(oe,ee(J),!0)});const I=u(()=>{const J=e(oe);if(J===null||J.contentWidthPx<=0||J.nameBoxPx<=0||e(Y).length===0)return null;const me=ye();if(me===null)return null;const we=Mr(me.family,me.weight);if(we===null)return null;const Le=Wr(J.tileScale),Ke=new Set;let Ve=0;for(const[Ye,Fe]of e(Y).entries())Br(Fe.name,J.contentWidthPx,we,J.nameBoxPx,Le)>ka||(Ve+=1,Ke.add(Math.floor(Ye/J.columns)));return{names:Ve,rows:Ke.size}}),Q=u(()=>rl({columns:e(h),layout:e(oe),tileCount:e(Y).length,floor:e(I),viewport:{width:window.innerWidth,height:window.innerHeight}})),re=u(()=>e(oe)===null?"":sl(window.location.hostname));function ee(J){const me=e(b),we=e(N),Le=e(X),Ke=e(de),Ve=e(he);if(me===null||we===null||Ke===null||Ve===null||Le===null)return null;we.style.gridTemplateColumns=Fr(J);const Ye=ve(we),Fe=Ye[0];if(Fe===void 0)return null;const Ae=Ke.clientWidth,Xe=J===Xt||Ae<=0?1:Fe/Ae;me.style.setProperty("--tile-scale",String(Xe));const Ge=Number.parseFloat(getComputedStyle(we).rowGap),Qe=Ve.clientHeight,it=Le.offsetHeight,qe=Le.querySelector(".name-box");return!Number.isFinite(Ge)||Qe<=0||it<=0||qe===null?null:{columns:Ye.length,rows:Math.max(1,Math.floor((Qe+Ge)/(it+Ge))),contentWidthPx:qe.clientWidth,nameBoxPx:qe.clientHeight,tileScale:Xe}}function ve(J){const me=getComputedStyle(J).gridTemplateColumns.split(/\s+/u).filter(Le=>Le!=="");if(me.length===0)return[];const we=me.map(Le=>Le.endsWith("px")?Number.parseFloat(Le):Number.NaN);return we.every(Le=>Number.isFinite(Le)&&Le>0)?we:[]}function ye(){const J=e(X)?.querySelector(".name")??null;if(J===null)return null;const me=getComputedStyle(J),we=Number.parseInt(me.fontWeight,10);return me.fontFamily===""||!Number.isFinite(we)?null:{family:me.fontFamily,weight:we}}const Ie=u(()=>e(O)===""?[]:Vr(e(A),Gr,e(O))),q=u(()=>e(Ie).slice(0,i)),le=u(()=>Ss(e(q).length,e(Ie).length)),ge=u(()=>[...t.health.decisions].sort((J,me)=>me.decided_at.localeCompare(J.decided_at))),ue=u(()=>e(C)===""?null:e(ge).find(J=>J.product_id===e(C))??null),ie=u(()=>e(ue)===null?!0:e(ue).offered),Ne=u(()=>e(ue)?.min_weight_g??null),je=u(()=>xs(e(U))),ke=u(()=>e(K).trim()),Re=u(()=>qs({motive:e(ke),offeredInForce:e(ie),waiverInForce:e(Ne),typedWaiver:e(je)})),We=u(()=>t.health.catalog?.products_withdrawn_count??0),xe=u(()=>e(m).find(J=>t.health.catalog!==null&&J.idEs(e(We),e(xe)?.occurred_at??""));let rt;wt(()=>{const J=t.health.catalog?.id??null;J!==rt&&(rt=J,pt())});const nt=new kr;wt(()=>{nt.observe(t.health)});async function pt(){const J=t.health.catalog_findings_id,me=await r().load(()=>Cn(J===0?void 0:J));me===null?v(D,"unread"):(v(m,me.imports,!0),v(j,me.findings,!0),v(D,"read"));const we=await r().load(()=>cr());if(we===null){v(H,"unread");return}v(S,we,!0),v(H,"read")}async function ct(J,me){v(g,J,!0),r().actionError="",r().notice="",nt.forget();try{const we=await r().protect(me);return we===null?null:(r().notice=we.message,await r().refresh(),we)}finally{v(g,"")}}async function dt(){const J=await ct("reload",Pn);J!==null&&nt.begin(J)}function ft(J){return Aa(e(k).get(J),e(H),"Nom inconnu : le catalogue n’a pas pu être lu")}function bt(J){v(C,J,!0),v(K,"");const me=e(ge).find(we=>we.product_id===J)?.min_weight_g??null;v(U,me===null?"":String(me),!0)}async function et(J){if(e(C)==="")return;const me=e(C),we=e(Ne),Le=e(ke);await ct("offered",()=>Ma(me,{offered:J,min_weight_g:we,reason:Le}))!==null&&v(K,"")}async function _e(J){if(e(C)==="")return;const me=e(C),we=e(ie),Le=e(ke);await ct(J===null?"waiver-off":"waiver",()=>Ma(me,{offered:we,min_weight_g:J,reason:Le}))!==null&&v(K,"")}async function Se(J){if(v(E,!1),J!=null){v(x,""),r().actionError="",r().notice="",v(g,"import");try{const me=await r().protect(()=>pr(J));if(me===null)return;v(x,`${J.name} : ${se(me.rows_read_count)} lignes lues, ${se(me.weighable_count)} pesables. `+(me.reason===""?"Le fichier est déposé ; son résultat s’inscrira dans l’historique des imports.":me.reason)),await r().refresh()}finally{v(g,"")}}}function st(J){J.preventDefault(),v(E,!1);const me=J.dataTransfer?.files.item(0);if(e(V)){v(x,`${me?.name??"Ce fichier"} n’a pas été déposé : un acte est déjà en cours sur cette page. Réessayez quand il aura répondu.`);return}Se(me)}function Dt(J){const me=J.files?.item(0);J.value="",Se(me)}var zt=Ll(),Et=s(zt);ws(Et,{get draft(){return t.draft},get station(){return t.health.station}});var jt=n(Et,2);Oe(jt,{title:"Dernier import",children:(J,me)=>{var we=cl(),Le=ae(we);{var Ke=qe=>{var Ze=ol();l(qe,Ze)},Ve=qe=>{xr(qe,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives}})};_(Le,qe=>{t.health.catalog===null?qe(Ke):qe(Ve,-1)})}var Ye=n(Le,2),Fe=s(Ye),Ae=n(Ye,2);{var Xe=qe=>{var Ze=ul(),Je=s(Ze);f(()=>o(Je,nt.sentence)),l(qe,Ze)};_(Ae,qe=>{nt.sentence!==""&&qe(Xe)})}var Ge=n(Ae,2),Qe=s(Ge);{let qe=u(()=>e(g)==="reload");Te(Qe,{act:"reload",kind:"write",label:"Recharger le catalogue",protected:!0,get busy(){return e(qe)},get disabled(){return e(V)},onrun:()=>{dt()}})}var it=n(Qe,2);{let qe=u(()=>e(g)==="quarantine");Te(it,{act:"quarantine",kind:"destructive",label:"Oublier la quarantaine",protected:!0,get busy(){return e(qe)},get disabled(){return e(V)},onrun:()=>{ct("quarantine",Tn)}})}f(()=>o(Fe,t.health.catalog_source===null?"Aucune source de catalogue publiée par ce poste.":"Source : "+t.health.catalog_source.label)),l(J,we)},$$slots:{default:!0}});var Rt=n(jt,2);Oe(Rt,{title:"Ce que la grille montre",note:"Un réglage d’affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser.",children:(J,me)=>{var we=gl(),Le=ae(we);{let Z=u(()=>t.draft.flag("ui.show_by_unit_products"));Na(Le,{path:"ui.show_by_unit_products",label:"Afficher les produits vendus à l’unité",hint:"Décoché, leurs tuiles quittent la grille et la recherche ne les retrouve plus. Ce que le poste perd : une tuile vendue à l’unité imprime une étiquette sans jamais lire la balance, et c’est le seul geste que ce réglage retire.",get on(){return e(Z)},onchange:ce=>t.draft.set("ui.show_by_unit_products",ce)})}var Ke=n(Le,2),Ve=s(Ke),Ye=n(Ke,4),Fe=n(s(Ye));{var Ae=Z=>{var ce=Ka();ce.textContent="ui.grid_columns",l(Z,ce)};_(Fe,Z=>{St.showTechnicalNames&&Z(Ae)})}var Xe=n(Ye,2),Ge=s(Xe),Qe=s(Ge),it=n(Ge,2);ze(it,16,()=>y,Z=>Z,(Z,ce)=>{var Pe=dl(),pe=s(Pe),Ee=n(pe);f((tt,mt)=>{be(Pe,"data-columns",ce),be(Pe,"data-on",tt),Gt(pe,ce),ea(pe,e(h)===ce),o(Ee,` ${mt??""}`)},[()=>String(e(h)===ce),()=>se(ce)]),Ce("change",pe,()=>t.draft.set(p,ce)),l(Z,Pe)});var qe=n(Xe,2),Ze=s(qe);ze(Ze,17,()=>e(Q),da,(Z,ce)=>{var Pe=vl(),pe=s(Pe);f(()=>o(pe,e(ce))),l(Z,Pe)});var Je=n(Ze,2);{var ut=Z=>{var ce=pl(),Pe=s(ce);f(()=>o(Pe,e(re))),l(Z,ce)};_(Je,Z=>{e(re)!==""&&Z(ut)})}var gt=n(qe,4),yt=n(s(gt),2);{var Ct=Z=>{var ce=Ka();ce.textContent="ui.min_products_for_chip",l(Z,ce)};_(yt,Z=>{St.showTechnicalNames&&Z(Ct)})}var xt=n(gt,2),Ot=n(xt,4),kt=s(Ot),Mt=s(kt);{var na=Z=>{var ce=fl(),Pe=s(ce);{let pe=u(()=>M("ui.show_grid_prices",e(L).show_grid_prices??!0));Ur(Pe,{get product(){return e(ne)},get nameSizePx(){return ka},get primaryCode(){return e(W)},get showPrice(){return e(pe)},onpick:()=>{}})}sa(ce,pe=>v(X,pe),()=>e(X)),l(Z,ce)};_(Mt,Z=>{e(ne)!==null&&Z(na)})}sa(kt,Z=>v(N,Z),()=>e(N));var P=n(kt,2);sa(P,Z=>v(de,Z),()=>e(de));var te=n(P,2);sa(te,Z=>v(he,Z),()=>e(he)),sa(Ot,Z=>v(b,Z),()=>e(b)),f(Z=>{o(Ve,e(F)),be(Ge,"data-columns",Xt),be(Ge,"data-on",Z),Gt(Qe,Xt),ea(Qe,e(h)===Xt),Gt(xt,e(G))},[()=>String(e(h)===Xt)]),Ce("change",Qe,()=>t.draft.set(p,Xt)),Ce("input",xt,Z=>t.draft.set(z,Number(Z.currentTarget.value))),l(J,we)},$$slots:{default:!0}});var At=n(Rt,2);Oe(At,{title:"Déposer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(J,me)=>{var we=hl(),Le=ae(we);{var Ke=qe=>{var Ze=ml(),Je=s(Ze);f(()=>o(Je,e(x))),l(qe,Ze)};_(Le,qe=>{e(x)!==""&&qe(Ke)})}var Ve=n(Le,2);let Ye;var Fe=s(Ve),Ae=n(s(Fe)),Xe=s(Ae),Ge=n(Fe,2),Qe=s(Ge),it=n(Qe);f(()=>{Ye=Nt(Ve,1,"drop svelte-3gk7u7",null,Ye,{dropping:e(E),working:e(g)==="import"}),o(Xe,`flv_${t.health.station??""}.csv`),o(Qe,`${e(g)==="import"?"Import en cours…":"Choisir un fichier"} `),it.disabled=e(V)}),Ht("dragover",Ve,qe=>{qe.preventDefault(),!e(V)&&v(E,!0)}),Ht("dragleave",Ve,()=>v(E,!1)),Ht("drop",Ve,st),Ce("change",it,qe=>Dt(qe.currentTarget)),l(J,we)},$$slots:{default:!0}});var Ft=n(At,2);xa(Ft,{title:"Anomalies à corriger dans Odoo",note:"Chaque ligne porte le nom du produit, son numéro dans le CSV, son motif et la valeur fautive.",list:"anomalies",get state(){return e(D)},get findings(){return e($)},singular:"anomalie",plural:"anomalies",none:"Aucune anomalie sur le dernier import.",remedy:"Corrigez celles-ci dans Odoo : l’import suivant ne signalera que ce qui reste."});var Jt=n(Ft,2);xa(Jt,{title:"Unités divergentes",note:"Le produit reste proposé : le code-barres fait foi, seul le libellé du prix est faux.",list:"mismatches",get state(){return e(D)},get findings(){return e(w)},singular:"unité divergente",plural:"unités divergentes",none:"Aucune unité divergente sur le dernier import."});var Ut=n(Jt,2);xa(Ut,{title:"Produits non pesables",note:"Un inventaire, pas une liste d’erreurs : ces produits portent déjà leur code-barres et n’ont aucune raison d’être pesés.",list:"not-weighable",get state(){return e(D)},get findings(){return e(T)},singular:"produit non pesable",plural:"produits non pesables",none:"Aucun produit non pesable sur le dernier import."});var ta=n(Ut,2);Oe(ta,{title:"Produits retirés depuis l’import précédent",note:"Un produit absent du nouveau fichier est marqué retiré à sa date, jamais supprimé.",children:(J,me)=>{var we=Tt(),Le=ae(we);{var Ke=Fe=>{var Ae=_l();l(Fe,Ae)},Ve=Fe=>{var Ae=bl();l(Fe,Ae)},Ye=Fe=>{var Ae=wl(),Xe=ae(Ae),Ge=s(Xe);f(()=>o(Ge,e(De))),l(Fe,Ae)};_(Le,Fe=>{t.health.catalog===null?Fe(Ke):e(We)===0?Fe(Ve,1):Fe(Ye,-1)})}l(J,we)},$$slots:{default:!0}});var Kt=n(ta,2);Oe(Kt,{title:"Décider d’un produit",note:"Retirer un produit et l’autoriser à peser moins sont deux décisions séparées : l’une n’efface pas l’autre.",children:(J,me)=>{var we=Sl(),Le=n(ae(we),2),Ke=n(Le,2);{var Ve=Ae=>{var Xe=xl(),Ge=ae(Xe),Qe=s(Ge),it=n(Ge,2),qe=s(it);ze(qe,21,()=>e(q),Ze=>Ze.id,(Ze,Je)=>{var ut=yl(),gt=s(ut),yt=s(gt),Ct=n(gt,2),xt=s(Ct),Ot=n(Ct,2),kt=s(Ot);f(()=>{o(yt,e(Je).name),o(xt,e(Je).id),o(kt,`${e(Je).unit_price_text??""}${e(Je).price_suffix??""}`)}),Ce("click",gt,()=>bt(e(Je).id)),l(Ze,ut)}),f(()=>o(Qe,e(le))),l(Ae,Xe)};_(Ke,Ae=>{e(O)!==""&&Ae(Ve)})}var Ye=n(Ke,2);{var Fe=Ae=>{var Xe=ql(),Ge=s(Xe),Qe=n(s(Ge)),it=s(Qe),qe=n(Qe),Ze=n(Ge,2),Je=s(Ze),ut=n(Ze,4),gt=n(ut,2);{var yt=pe=>{var Ee=kl();l(pe,Ee)};_(gt,pe=>{e(ke)===""&&pe(yt)})}var Ct=n(gt,2),xt=n(s(Ct),2),Ot=s(xt);{var kt=pe=>{{let Ee=u(()=>e(g)==="offered"),tt=u(()=>e(V)||!e(Re).canWithdraw);Te(pe,{act:"offered",kind:"destructive",label:"Ne plus proposer ce produit",protected:!0,get busy(){return e(Ee)},get disabled(){return e(tt)},onrun:()=>{et(!1)}})}},Mt=pe=>{{let Ee=u(()=>e(g)==="offered"),tt=u(()=>e(V)||!e(Re).canOfferAgain);Te(pe,{act:"offered",kind:"write",label:"Le proposer de nouveau",protected:!0,get busy(){return e(Ee)},get disabled(){return e(tt)},onrun:()=>{et(!0)}})}};_(Ot,pe=>{e(ie)?pe(kt):pe(Mt,-1)})}var na=n(Ct,2),P=n(s(na),2),te=n(P,2),Z=s(te);{let pe=u(()=>e(g)==="waiver"),Ee=u(()=>e(V)||!e(Re).canSaveWaiver);Te(Z,{act:"waiver",kind:"write",label:"Enregistrer la dérogation",protected:!0,get busy(){return e(pe)},get disabled(){return e(Ee)},onrun:()=>{_e(e(je))}})}var ce=n(Z,2);{var Pe=pe=>{{let Ee=u(()=>e(g)==="waiver-off"),tt=u(()=>e(V)||!e(Re).canDropWaiver);Te(pe,{act:"waiver-off",kind:"write",label:"Retirer la dérogation",protected:!0,get busy(){return e(Ee)},get disabled(){return e(tt)},onrun:()=>{_e(null)}})}};_(ce,pe=>{e(Ne)!==null&&pe(Pe)})}f((pe,Ee)=>{be(Xe,"data-decision",e(C)),o(it,pe),o(qe,` (${e(C)??""})`),o(Je,`En vigueur : ${Ee??""}`),Gt(P,e(U))},[()=>ft(e(C)),()=>e(ue)===null?"aucune décision — ce produit suit les règles générales":br(e(ue))]),ia(ut,()=>e(K),pe=>v(K,pe)),Ce("input",P,pe=>v(U,pe.currentTarget.value,!0)),l(Ae,Xe)};_(Ye,Ae=>{e(C)!==""&&Ae(Fe)})}ia(Le,()=>e(O),Ae=>v(O,Ae)),l(J,we)},$$slots:{default:!0}});var $t=n(Kt,2);zs($t,{get decisions(){return e(ge)},nameOf:ft,onchoose:bt});var aa=n($t,2);$s(aa,{get imports(){return e(m)},get state(){return e(D)}}),l(a,zt),Me()}ot(["change","input","click"]);function Cl(a){switch(a){case"ok":return"OK";case"warn":return"À SURVEILLER";case"fault":return"EN PANNE";case"off":return"SANS OBJET";default:return"INCONNU"}}var Pl=c('

          '),Tl=c('

          ');function zl(a,t){Ue(t,!0);var r=Tl(),i=s(r),d=s(i),p=n(d,2),y=s(p),z=n(p,2),m=s(z),j=n(i,2),S=s(j),A=n(j,2);{var L=W=>{var O=Pl(),C=s(O);f(()=>o(C,t.light.remedy)),l(W,O)};_(A,W=>{t.light.remedy!==""&&W(L)})}f(W=>{be(r,"data-light",t.light.id),be(r,"data-level",t.light.level),be(d,"data-level",t.light.level),o(y,t.light.label),o(m,W),o(S,t.light.value)},[()=>Cl(t.light.level)]),l(a,r),Me()}function jl(a){return[Rl(a),Ol(a),Nl(a),Il(a),Fl(a),Ul(a)]}function Rl(a){const t=a.state.scale,r=ua(t.median_ms);return a.scale_present?t.connected?t.too_slow?{id:"scale",label:"Balance",level:"warn",value:`une mesure toutes les ${r}, plus lent que la péremption`,remedy:"À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante. Vérifiez le câble et l’adaptateur USB, puis la cadence sur la page Matériel."}:{id:"scale",label:"Balance",level:"ok",value:Ml(t.provisional,`une mesure toutes les ${r}`),remedy:""}:{id:"scale",label:"Balance",level:"fault",value:"elle ne répond plus",remedy:"Vérifiez le câble et l’alimentation de la balance, puis touchez « Tester la balance ». En attendant, « Basculer en saisie manuelle » permet de continuer à servir."}:{id:"scale",label:"Balance",level:"off",value:"ce poste est déclaré sans balance",remedy:""}}function Ol(a){const t=a.state.printer;switch(t.health){case"faulted":return{id:"printer",label:"Imprimante",level:"fault",value:t.detail===""?"elle ne peut pas imprimer":t.detail,remedy:Al(a)};case"consumable":return{id:"printer",label:"Imprimante",level:"warn",value:"elle imprime, mais le rouleau arrive en fin de vie",remedy:"Changez le rouleau quand vous passez derrière le comptoir, puis touchez « J’ai changé le rouleau ». Le poste continue de servir en attendant."};case"ready":return{id:"printer",label:"Imprimante",level:"ok",value:"elle répond et n’a rien à signaler",remedy:""};default:return{id:"printer",label:"Imprimante",level:"unknown",value:"elle prend les étiquettes et ne dit rien en retour",remedy:"C’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne. Pour savoir si elle imprime, touchez « Imprimer une étiquette de test »."}}}function Al(a){const t="Regardez le capot, le rouleau et le câble, puis touchez « Tester l’imprimante ».";return a.printing?.fallback_available===!0?t+" Le poste peut servir en attendant : « Imprimer sur l’imprimante du poste voisin ».":t}function Nl(a){const t=a.roll;return t===null?{id:"roll",label:"Rouleau",level:"unknown",value:"aucun compteur d’étiquettes sur ce poste",remedy:`Sans imprimante construite, il n’y a pas de rouleau à compter : vérifiez le « ${qt("printer.type")} » et ses réglages sur la page Matériel.`}:t.known?t.level==="warn"?{id:"roll",label:"Rouleau",level:"warn",value:t.message,remedy:"Changez le rouleau, puis touchez « J’ai changé le rouleau »."}:{id:"roll",label:"Rouleau",level:"ok",value:t.message,remedy:""}:{id:"roll",label:"Rouleau",level:"unknown",value:t.message,remedy:"Touchez « J’ai changé le rouleau » en mettant un rouleau neuf : c’est le seul geste qui dise quelque chose de vrai du papier."}}function Il(a){const t=a.catalog_source?.label??"";if(a.state.catalog_count===0)return{id:"catalog",label:"Catalogue",level:"fault",value:"aucun produit dans la grille",remedy:Dl(t)};const r=a.catalog;return r!==null&&r.result!=="applied"&&r.result!=="unchanged"?{id:"catalog",label:"Catalogue",level:"warn",value:`dernier fichier refusé : ${r.reason===""?r.result:r.reason}`,remedy:"La grille tourne toujours sur le catalogue précédent. Corrigez le fichier dans Odoo, ou touchez « Oublier la quarantaine » puis redéposez-le."}:{id:"catalog",label:"Catalogue",level:"ok",value:`${se(a.state.catalog_count)} produits dans la grille`,remedy:""}}function Dl(a){return"Déposez le fichier du catalogue là où le poste le guette, ou utilisez « Importer un catalogue » ci-contre pour le glisser depuis une clé USB."+(a===""?"":` Ce poste surveille : ${a}.`)}function Fl(a){const t=a.disk;if(t===null)return{id:"disk",label:"Disque",level:"unknown",value:"la place libre n’a pas pu être mesurée",remedy:"Téléchargez le fichier de diagnostic : il porte ce que le poste a pu lire du volume, et c’est la pièce qu’un support demandera."};const r=Sa(t.free_bytes),i=`seuil ${se(t.alert_mb)} Mo`;return t.free_bytes===0?{id:"disk",label:"Disque",level:"fault",value:`plus un octet libre sur ${t.path}`,remedy:"Le journal ne peut plus rien écrire, alors que les étiquettes continuent de sortir. Faites de la place sur le disque, puis rechargez cette page."}:t.alert_mb>0&&t.free_bytes0?{id:"journal",label:"Journal",level:"fault",value:`${se(t)} pesées imprimées mais non enregistrées`,remedy:"Les étiquettes sortent, les ventes sont bonnes, c’est la trace qui manque. Téléchargez le fichier de diagnostic et prévenez le support : ne redémarrez rien."}:a.counters.journal_rows_count<0?{id:"journal",label:"Journal",level:"unknown",value:"ce poste n’a pas de journal ouvert",remedy:"Le poste pèse et imprime quand même. Téléchargez le fichier de diagnostic : il dit pourquoi la base n’a pas pu être ouverte."}:{id:"journal",label:"Journal",level:"ok",value:`${se(a.counters.journal_rows_count)} pesées enregistrées`,remedy:""}}function Ml(a,t){return a?t+" (cadence encore provisoire)":t}function Wl(a){return a===null||!a.known?"unknown":a.configured?"ok":"missing"}var Bl=c("Une mesure toutes les ",1),Vl=c('

          '),Gl=c("Source : ",1),Hl=c(`

          Aucun import enregistré sur ce poste : le catalogue n’est jamais arrivé, ou le - journal ne le porte pas.

          `),Jl=c('

          ',1),Kl=c('

          ',1),$l=c('

          Aucune décision locale : le catalogue est proposé tel qu’il arrive.

          '),Yl=c('

          '),Xl=c(' '),Ql=c('
        • '),Zl=c('
            ',1),ei=c(`INCONNU — la question n’a pas pu être posée à ce système. Ce - n’est pas « non configuré » : personne ne sait encore.`,1),ti=c('OK ',1),ai=c('NON CONFIGURÉ ',1),ri=c('

            '),ni=c('

            ',1),si=c('

            Rien à signaler depuis le démarrage du poste.

            '),li=c(' '),ii=c('
          • '),oi=c('
              '),ui=c(''),ci=c('

              '),di=c('
              Numéro de poste
              Nom
              Coopérative
              Version
              Empreinte de configuration
              ',1),vi=c('
              ');function pi(a,t){Ue(t,!0);const r=u(()=>t.health.new_version),i=u(()=>jl(t.health)),d=u(()=>t.health.state.scale),p=u(()=>t.health.unattended_restart),y=u(()=>t.health.decisions),z=5,m=u(()=>e(y).slice(0,z));var j=vi(),S=s(j);ze(S,21,()=>e(i),U=>U.id,(U,E)=>{zl(U,{get light(){return e(E)}})});var A=n(S,2);Oe(A,{title:"Cadence de la balance",children:(U,E)=>{var x=Vl(),g=s(x);{var D=T=>{var w=lt("Ce poste est déclaré sans balance : le poids est saisi à la main.");l(T,w)},H=T=>{var w=lt(`La balance ne répond pas : aucune cadence n’est mesurable tant qu’aucune trame - n’arrive.`);l(T,w)},V=T=>{var w=lt(`Aucun intervalle n’a encore été mesuré : la cadence apparaîtra dès les premières - trames.`);l(T,w)},$=T=>{var w=Bl(),k=n(ae(w)),R=s(k),F=n(k),h=n(F);{var G=M=>{var b=lt("C’est encore une valeur d’attente : moins de huit intervalles ont été observés.");l(M,b)};_(h,M=>{e(d).provisional&&M(G)})}var Y=n(h,2);{var ne=M=>{var b=lt("À cette cadence, un poids serait périmé avant l’arrivée de la mesure suivante.");l(M,b)};_(Y,M=>{e(d).too_slow&&M(ne)})}f((M,b)=>{o(R,M),o(F,`, médiane - observée sur ${b??""} intervalles. `)},[()=>ua(e(d).median_ms),()=>se(e(d).observations_count)]),l(T,w)};_(g,T=>{t.health.scale_present?e(d).connected?e(d).observations_count===0?T(V,2):T($,-1):T(H,1):T(D)})}l(U,x)},$$slots:{default:!0}});var L=n(A,2);Oe(L,{title:"Catalogue",children:(U,E)=>{var x=Kl(),g=ae(x),D=s(g);{var H=k=>{var R=lt("Aucune source de catalogue n’est publiée par ce poste.");l(k,R)},V=k=>{var R=Gl(),F=n(ae(R)),h=s(F);f(()=>o(h,t.health.catalog_source.label)),l(k,R)};_(D,k=>{t.health.catalog_source===null?k(H):k(V,-1)})}var $=n(g,2);{var T=k=>{var R=Hl();l(k,R)},w=k=>{var R=Jl(),F=ae(R),h=s(F),G=n(F,2);xr(G,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives},get onshowrows(){return t.onshowrows}}),f((Y,ne)=>o(h,`Dernier essai : ${Y??""} — - ${ne??""}${t.health.catalog.file_name===""?"":" ("+t.health.catalog.file_name+")"}${t.health.catalog.reason===""?"":" : "+t.health.catalog.reason}`),[()=>Lt(t.health.catalog.occurred_at),()=>ha(t.health.catalog.result)]),l(k,R)};_($,k=>{t.health.catalog===null?k(T):k(w,-1)})}l(U,x)},$$slots:{default:!0}});var W=n(L,2);Oe(W,{title:"Décisions locales en vigueur",note:"Ce qu’un humain a décidé de ce catalogue, avec son motif et sa date.",children:(U,E)=>{var x=Tt(),g=ae(x);{var D=V=>{var $=$l();l(V,$)},H=V=>{var $=Zl(),T=ae($);{var w=R=>{var F=Yl(),h=s(F);f(G=>o(h,`${G??""} décisions en vigueur — les - 5 plus récentes ci-dessous. La liste entière est sur la page - Catalogue.`),[()=>se(e(y).length)]),l(R,F)};_(T,R=>{e(y).length>z&&R(w)})}var k=n(T,2);ze(k,21,()=>e(m),R=>R.product_id,(R,F)=>{var h=Ql(),G=s(h),Y=s(G),ne=n(G,2);{var M=he=>{var fe=Xl(),oe=s(fe);f(I=>o(oe,`peut peser à partir de ${I??""} g`),[()=>se(e(F).min_weight_g)]),l(he,fe)};_(ne,he=>{e(F).min_weight_g!==null&&he(M)})}var b=n(ne,2),N=s(b),X=n(b,2),de=s(X);f(he=>{o(Y,`${e(F).offered?"Dérogation de poids":"Produit retiré"} — ${e(F).product_id??""}`),o(N,e(F).reason),o(de,`${he??""}, ${e(F).decided_by??""}`)},[()=>va(e(F).decided_at)]),l(R,h)}),l(V,$)};_(g,V=>{e(y).length===0?V(D):V(H,-1)})}l(U,x)},$$slots:{default:!0}});var O=n(W,2);Oe(O,{title:"Redémarrage sans intervention",children:(U,E)=>{var x=ni(),g=ae(x),D=s(g);{var H=k=>{var R=ei();l(k,R)},V=k=>{var R=ti(),F=n(ae(R));f(()=>o(F,` — après une coupure de courant, ce poste revient seul sur - l’écran client. ${e(p).detail??""}`)),l(k,R)},$=k=>{var R=ai(),F=n(ae(R));f(()=>o(F,` — ${e(p).detail??""}`)),l(k,R)};_(D,k=>{e(p)===null||!e(p).known?k(H):e(p).configured?k(V,1):k($,-1)})}var T=n(g,2);{var w=k=>{var R=ri(),F=s(R);f(()=>o(F,e(p).remedy)),l(k,R)};_(T,k=>{e(p)!==null&&e(p).known&&!e(p).configured&&e(p).remedy!==""&&k(w)})}f(k=>be(g,"data-verdict",k),[()=>Wl(e(p))]),l(U,x)},$$slots:{default:!0}});var C=n(O,2);Oe(C,{title:"Dix derniers événements",children:(U,E)=>{var x=Tt(),g=ae(x);{var D=V=>{var $=si();l(V,$)},H=V=>{var $=oi();ze($,21,()=>t.health.events,T=>T.id,(T,w)=>{var k=ii(),R=s(k),F=s(R),h=n(R,2),G=s(h),Y=n(h,2);{var ne=N=>{var X=li(),de=s(X);f(()=>o(de,e(w).code)),l(N,X)};_(Y,N=>{e(w).code!==""&&St.showTechnicalNames&&N(ne)})}var M=n(Y,2),b=s(M);f((N,X)=>{be(k,"data-level",e(w).level),o(F,N),o(G,X),o(b,e(w).message)},[()=>ys(e(w).occurred_at),()=>mr(e(w).source)]),l(T,k)}),l(V,$)};_(g,V=>{t.health.events.length===0?V(D):V(H,-1)})}l(U,x)},$$slots:{default:!0}});var K=n(C,2);Oe(K,{title:"Ce poste",children:(U,E)=>{var x=di(),g=ae(x),D=n(s(g),2),H=s(D),V=n(D,4),$=s(V),T=n(V,4),w=s(T),k=n(T,4),R=s(k),F=n(k,4),h=s(F),G=n(g,2);{var Y=ne=>{var M=ci(),b=s(M),N=n(b);{var X=de=>{var he=ui();Ce("click",he,function(...fe){t.onshowupdate?.apply(this,fe)}),l(de,he)};_(N,de=>{t.onshowupdate!==void 0&&de(X)})}f(()=>{be(M,"data-update-available",e(r)),o(b,`Version ${e(r)??""} disponible. `)}),l(ne,M)};_(G,ne=>{e(r)!==""&&ne(Y)})}f(()=>{o(H,t.health.station),o($,t.health.station_name===""?"non renseigné":t.health.station_name),o(w,t.health.coop),o(R,t.health.version),o(h,t.health.config_fingerprint)}),l(U,x)},$$slots:{default:!0}}),l(a,j),Me()}ot(["click"]);var fi=c('

              ');function $a(a,t){Ue(t,!0);var r=fi(),i=s(r),d=s(i),p=n(i,2),y=n(s(p),1,!0);f(()=>{o(d,t.title),be(p,"data-standing",t.name),be(p,"data-level",t.standing.level),o(y,t.standing.word)}),l(a,r),Me()}const gi={0:"NUL",2:"STX",3:"ETX",4:"EOT",5:"ENQ",6:"ACK",9:"TAB",10:"LF",13:"CR",21:"NAK",27:"ESC",127:"DEL"};function mi(a){return[...new TextEncoder().encode(a)].map(t=>t.toString(16).toUpperCase().padStart(2,"0")).join(" ")}function hi(a){let t="";for(const r of a){const i=r.codePointAt(0)??0;if(i>=32&&i!==127){t+=r;continue}t+=`⟨${gi[i]??i.toString(16).toUpperCase().padStart(2,"0")}⟩`}return t}function _i(a){const t=bi(a);return t===""?Ya(a):`${Ya(a)} ${t}`}function Ya(a){return a.configRead?a.declaredWithoutScale?"Ce poste est déclaré sans balance : aucun port n’est écouté.":a.port===""?"Aucun port n’est indiqué : choisissez-en un dans la liste ci-dessus pour écouter les trames.":a.listed?a.portKnown?a.halt!==""?`L’écoute de ${a.port} est arrêtée.`:a.acting!==""?`L’écoute de ${a.port} est suspendue le temps de l’acte en cours.`:`Écoute de ${a.port}.`:`${a.port} n’est pas visible depuis ce poste : rien n’est écouté en continu.`:a.acting==="ports"?`Énumération des ports en cours : l’écoute de ${a.port} démarre dès qu’il est vu.`:`Les ports de ce poste n’ont pas été énumérés : « Lister les ports » dira si ${a.port} existe.`:"Lecture de la configuration en cours : le port à écouter n’est pas encore connu."}function bi(a){return a.framesShown===0?"Aucune trame reçue pour l’instant.":a.framesShown===1?`Une seule trame reçue — ${se(a.framesKept)} au plus sont gardées.`:`Les ${se(a.framesShown)} dernières trames — ${se(a.framesKept)} au plus, la plus récente en bas.`}function wi(a){return!a.configRead||a.declaredWithoutScale||a.port===""?"":a.halt!==""?"Reprendre l’écoute":a.listed&&!a.portKnown?"Écouter ce port une fois":""}function yi(a){return a.acting!=="detect"?"Détecter automatiquement":a.listening?"Détection : le port se libère…":a.toScan===0?"Détection : énumération des ports…":`Détection : port ${se(a.scanned)} sur ${se(a.toScan)}…`}function xi(a,t){return a?t.connected?t.too_slow?{level:"warn",word:"Trop lente",detail:Xa(t)+" À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante."}:{level:"ok",word:"Connectée",detail:"Elle répond. "+Xa(t)}:{level:"fault",word:"Sans réponse",detail:"Elle ne répond plus. Vérifiez le câble et l’alimentation, puis « Tester la balance » sur la page Dépannage."}:{level:"off",word:"Sans balance",detail:"Ce poste est déclaré sans balance : le feu est éteint et le poids se saisit à la main."}}function Xa(a){return a.observations_count===0?"Aucun intervalle n’a encore été mesuré : la cadence sera connue dès les premières trames.":`Une mesure toutes les ${ua(a.median_ms)} sur ${se(a.observations_count)} intervalles`+(a.provisional?", cadence encore provisoire.":".")}const ki={ready:{level:"ok",word:"Prête",detail:"Elle répond et n’a rien à signaler."},consumable:{level:"warn",word:"Rouleau en fin de vie",detail:"Elle imprime, mais le rouleau arrive en fin de vie."},faulted:{level:"fault",word:"En panne",detail:"Elle ne peut pas imprimer."},unknown:{level:"unknown",word:"Silencieuse",detail:"Elle prend les étiquettes et ne dit rien en retour : c’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne."}};function qi(a){const t=ki[a.health]??{level:"unknown",word:"État inconnu",detail:"Le poste a répondu un état que cet écran ne sait pas nommer."};return{...t,detail:a.detail===""?t.detail:a.detail}}function Si(a){const t=a.observed_at===""?"Jamais observée depuis le démarrage":`Observée le ${Lt(a.observed_at)}`,r=a.pending_jobs_count;return`${t}, ${se(r)} ${r>1?"travaux":"travail"} en attente.`}const Li=["queue","path","address"],Ei="queue",Ci={queue:"Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.",path:"Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.",address:"L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque."};function Pi(a,t){return t!==""&&!a.some(r=>r.id===t)}function Ti(a,t){return a.length===0?[]:[...Pi(a,t)?[{value:t,label:`${t} — inconnu de ce poste`}]:[],...a.map(r=>({value:r.id,label:r.label}))]}function zi(a,t){return a.find(r=>r.id===t)?.key??Ei}function ji(a,t,r){const i=[...new Set(a.filter(y=>y.key!==r).map(y=>Ri(t,y.key)))].filter(y=>y!==""),d=a.filter(y=>y.key!==r).length,p=`${se(d)} ${d>1?"destinations ne sont pas proposées":"destination n’est pas proposée"}`;return i.length===0?`${p} : aucun transport de ce poste ne les lit.`:`${p} : choisissez « ${i.join(" » ou « ")} » pour les voir.`}function Ri(a,t){return a.find(r=>r.key===t)?.label??""}var Oi=c(''),Ai=c(`

              Lecture de la configuration en cours… tant qu’elle n’est pas arrivée, cette page ne - déclare rien de ce poste.

              `),Qa=c('
            • '),Ni=c('
                '),Ii=c('

                ',1),Di=c('
              • '),Fi=c('

                  ',1),Ui=c('

                  '),Mi=c('
                • '),Wi=c('
                    '),Bi=c('

                    '),Vi=c('

                      ',1),Gi=c('

                      '),Hi=c('

                      L’état et la cadence viennent de ce que le poste observe vraiment, jamais d’un réglage.

                      Réglages série de la balance

                      Réglages de l’imprimante
                      ');function Ji(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=20,d=3,p=250,y=50,z=d*1e3+p+1e3,m=12;let j=B(vt([])),S=B(vt([])),A=B(vt([])),L=B(vt([])),W=B(""),O=B(!1),C=B(""),K=B(0),U=B(0),E=B(!1),x=B(null),g=B(!1),D=B(!1),H=!1;const V=u(()=>t.health.state.scale),$=u(()=>t.health.state.printer),T=u(()=>t.draft.config!==null),w=u(()=>e(T)&&!t.draft.flag("scale.present")),k=u(()=>t.draft.text("scale.options.port")),R=u(()=>e(x)!==null&&e(x).port===e(k)?e(x).reason:""),F=u(()=>e(W)===e(k)?e(L):[]),h=u(()=>t.health.printer_transports),G=u(()=>t.draft.text("printer.options.transport")),Y=u(()=>Ti(e(h),e(G))),ne=u(()=>zi(e(h),e(G))),M=u(()=>"printer.options."+e(ne)),b=u(()=>e(S).filter(P=>P.key===e(ne))),N=u(()=>e(S).length-e(b).length),X=u(()=>xi(t.health.scale_present,e(V))),de=u(()=>qi(e($))),he=u(()=>({configRead:e(T),declaredWithoutScale:e(w),port:e(k),listed:e(O),portKnown:ve(e(k)),halt:e(R),acting:e(C),framesShown:e(F).length,framesKept:i})),fe=u(()=>_i(e(he))),oe=u(()=>wi(e(he))),I=u(()=>yi({acting:e(C),listening:e(E),scanned:e(K),toScan:e(U)})),Q=u(()=>$e(t.draft,"scale.type")!==""||$e(t.draft,"scale.options.port")!==""),re=u(()=>$e(t.draft,"printer.type")!==""||$e(t.draft,"printer.options.transport")!==""||Li.some(P=>$e(t.draft,"printer.options."+P)!==""));wt(()=>{e(Q)&&v(g,!0)}),wt(()=>{e(re)&&v(D,!0)}),Yr(()=>{H=!0}),Ta(()=>{ue("ports",ie)}),wt(()=>{e(E)||!ee(e(k))||ye(e(k))});function ee(P){return!H&&e(T)&&!e(w)&&P!==""&&P===e(k)&&ve(P)&&e(R)===""&&e(C)===""}function ve(P){return e(j).some(te=>te.name===P)}async function ye(P){v(E,!0);try{for(;ee(P);){try{q(P,await Ua(P,d))}catch(te){v(x,{port:P,reason:De(te)},!0);return}await le()}}finally{v(E,!1)}}async function Ie(){const P=e(k),te=await r().protect(()=>Ua(P,d));te!==null&&(q(P,te),v(x,null))}function q(P,te){if(H||te.length===0)return;const Z=P===e(W)?e(L):[];v(W,P,!0),v(L,[...Z,...te].slice(-i),!0)}function le(P=p){return new Promise(te=>setTimeout(te,P))}async function ge(){const P=Date.now()+z;for(;e(E)&&Date.now()fr(P));te!==null&&(r().notice=te.message,await r().refresh())}async function Re(){v(K,0),v(U,0),e(O)||await ie();const P=e(j);if(P.length===0){v(A,[],!0);return}await r().protect(()=>We(P))}async function We(P){v(A,[],!0),v(U,P.length,!0);for(const[te,Z]of P.entries()){v(K,te+1);try{const ce=await kn(Z.name);v(A,[...e(A),{port:Z.name,message:ce.message,refused:!1}],!0)}catch(ce){if(fa(ce))throw ce;v(A,[...e(A),{port:Z.name,message:De(ce),refused:!0}],!0)}}}function xe(P,te){const Z=Qt(t.draft,P);return Z.length>0?Z:te.slice(0,m)}function De(P){return P instanceof Pt?P.message:P instanceof Error?"Le poste n’a pas répondu : "+P.message:"Le poste n’a pas répondu."}const rt=[{what:"label",name:"étiquette"},{what:"alignment",name:"alignement"},{what:"ruler",name:"réglette"}],nt=u(()=>rt.filter(P=>t.health.printer_self_tests.includes(P.what)));var pt=Hi(),ct=s(pt),dt=s(ct);$a(dt,{title:"Balance",name:"scale",get standing(){return e(X)}});var ft=n(dt,2),bt=s(ft),et=n(ft,4);{var _e=P=>{var te=Oi(),Z=s(te);f(()=>ea(Z,e(w))),Ce("change",Z,ce=>t.draft.set("scale.present",!ce.currentTarget.checked)),l(P,te)},Se=P=>{var te=Ai();l(P,te)};_(et,P=>{e(T)?P(_e):P(Se,-1)})}var st=n(et,2),Dt=s(st);{let P=u(()=>e(C)==="ports"),te=u(()=>e(C)!=="");Te(Dt,{label:"Lister les ports",get busy(){return e(P)},get disabled(){return e(te)},onrun:()=>{ue("ports",ie)}})}var zt=n(Dt,2);{let P=u(()=>e(C)!=="");Te(zt,{act:"detect",get label(){return e(I)},protected:!0,get disabled(){return e(P)},onrun:()=>{ue("detect",Re)}})}var Et=n(st,2);{var jt=P=>{var te=Ii(),Z=ae(te),ce=s(Z),Pe=n(Z,2);{var pe=Ee=>{var tt=Ni();ze(tt,21,()=>e(j).slice(0,m),mt=>mt.name,(mt,ht)=>{var Wt=Qa(),Yt=s(Wt),_a=s(Yt),Cr=n(Yt,2),Pr=s(Cr);f(()=>{o(_a,e(ht).name),o(Pr,`${(e(ht).description===""?"aucune description USB":e(ht).description)??""} - ${e(ht).vid===""?"":` — VID ${e(ht).vid} PID ${e(ht).pid}`}`)}),Ce("click",Yt,()=>t.draft.set("scale.options.port",e(ht).name)),l(mt,Wt)}),l(Ee,tt)};_(Pe,Ee=>{e(j).length>0&&Ee(pe)})}f(Ee=>o(ce,Ee),[()=>e(j).length===0?"Aucun port série n’est visible depuis ce poste.":ya("port détecté","ports détectés",e(j).length,m)]),l(P,te)};_(Et,P=>{e(O)&&P(jt)})}var Rt=n(Et,2);{var At=P=>{var te=Fi(),Z=ae(te),ce=s(Z),Pe=n(Z,2);ze(Pe,21,()=>e(A).slice(0,m),pe=>pe.port,(pe,Ee)=>{var tt=Di();let mt;var ht=s(tt),Wt=s(ht),Yt=n(ht,2),_a=s(Yt);f(()=>{mt=Nt(tt,1,"svelte-1p4i0xm",null,mt,{refused:e(Ee).refused}),o(Wt,e(Ee).port),o(_a,e(Ee).message)}),l(pe,tt)}),f(pe=>o(ce,pe),[()=>ya("port interrogé","ports interrogés",e(A).length,m)]),l(P,te)};_(Rt,P=>{e(A).length>0&&P(At)})}var Ft=n(Rt,2),Jt=n(s(Ft),2),Ut=s(Jt);{let P=u(()=>t.draft.text("scale.type")),te=u(()=>$e(t.draft,"scale.type")),Z=u(()=>Qt(t.draft,"scale.type")),ce=u(()=>!e(T));at(Ut,{label:"Protocole",path:"scale.type",get value(){return e(P)},hint:"Les valeurs acceptées apparaissent ici si l’enregistrement est refusé.",get fault(){return e(te)},get allowed(){return e(Z)},get disabled(){return e(ce)},onchange:Pe=>t.draft.set("scale.type",Pe)})}var ta=n(Ut,2);{let P=u(()=>t.draft.text("scale.options.port")),te=u(()=>$e(t.draft,"scale.options.port")),Z=u(()=>xe("scale.options.port",e(j).map(Pe=>Pe.name))),ce=u(()=>!e(T));at(ta,{label:"Port série",path:"scale.options.port",get value(){return e(P)},hint:"Choisissez-le dans la liste détectée ci-dessus plutôt que de le taper : l’écoute permanente ne suit que des ports détectés.",get fault(){return e(te)},get allowed(){return e(Z)},get disabled(){return e(ce)},onchange:Pe=>t.draft.set("scale.options.port",Pe)})}var Kt=n(Ft,2),$t=s(Kt),aa=s($t),J=n($t,2);{var me=P=>{var te=Ui(),Z=s(te),ce=n(Z);{var Pe=pe=>{{let Ee=u(()=>e(C)==="listen"),tt=u(()=>e(C)!=="");Te(pe,{act:"listen",get label(){return e(oe)},protected:!0,get busy(){return e(Ee)},get disabled(){return e(tt)},onrun:()=>{ue("listen",Ie)}})}};_(ce,pe=>{e(oe)!==""&&pe(Pe)})}f(()=>o(Z,`${e(R)??""} `)),l(P,te)};_(J,P=>{(e(R)!==""||e(oe)!=="")&&P(me)})}var we=n(J,2);{var Le=P=>{var te=Wi();ze(te,21,()=>e(F),da,(Z,ce)=>{var Pe=Mi(),pe=s(Pe),Ee=s(pe),tt=n(pe,2),mt=s(tt);f((ht,Wt)=>{o(Ee,ht),o(mt,Wt)},[()=>mi(e(ce)),()=>hi(e(ce))]),l(Z,Pe)}),l(P,te)};_(we,P=>{e(F).length>0&&P(Le)})}var Ke=n(ct,2),Ve=s(Ke);$a(Ve,{title:"Imprimante",name:"printer",get standing(){return e(de)}});var Ye=n(Ve,2),Fe=s(Ye),Ae=n(Ye,2),Xe=s(Ae),Ge=n(Ae,2),Qe=s(Ge);{let P=u(()=>e(C)==="printers"),te=u(()=>e(C)!=="");Te(Qe,{label:"Lister les files",get busy(){return e(P)},get disabled(){return e(te)},onrun:()=>{ue("printers",Ne)}})}var it=n(Qe,2);{let P=u(()=>e(C)==="discover"),te=u(()=>e(C)!=="");Te(it,{label:"Rechercher l’imprimante",protected:!0,get busy(){return e(P)},get disabled(){return e(te)},onrun:()=>{ue("discover",je)}})}var qe=n(it,2);ze(qe,17,()=>e(nt),P=>P.what,(P,te)=>{{let Z=u(()=>`Auto-test : ${e(te).name}${e(C)===e(te).what?" — en cours…":""}`),ce=u(()=>e(C)!=="");Te(P,{get act(){return e(te).what},get label(){return e(Z)},protected:!0,get disabled(){return e(ce)},onrun:()=>{ue(e(te).what,()=>ke(e(te).what))}})}});var Ze=n(Ge,2);{var Je=P=>{var te=Bi(),Z=s(te);f(()=>o(Z,e(nt).length===0?"Le driver d’impression en service n’imprime aucun auto-test.":"Les autres auto-tests ne sont pas proposés : le driver d’impression en service ne les imprime pas.")),l(P,te)};_(Ze,P=>{e(nt).length{var te=Vi(),Z=ae(te),ce=s(Z),Pe=n(Z,2);ze(Pe,21,()=>e(b).slice(0,m),pe=>pe.name,(pe,Ee)=>{var tt=Qa(),mt=s(tt),ht=s(mt),Wt=n(mt,2),Yt=s(Wt);f(()=>{o(ht,e(Ee).name),o(Yt,`${e(Ee).detail??""}${e(Ee).default?" — file par défaut du système":""}`)}),Ce("click",mt,()=>t.draft.set(e(M),e(Ee).name)),l(pe,tt)}),f(pe=>o(ce,pe),[()=>ya("destination","destinations",e(b).length,m)]),l(P,te)};_(ut,P=>{e(b).length>0&&P(gt)})}var yt=n(ut,2);{var Ct=P=>{var te=Gi(),Z=s(te);f(ce=>o(Z,ce),[()=>ji(e(S),e(h),e(ne))]),l(P,te)};_(yt,P=>{e(N)>0&&P(Ct)})}var xt=n(yt,2),Ot=n(s(xt),2),kt=s(Ot);{let P=u(()=>t.draft.text("printer.type")),te=u(()=>$e(t.draft,"printer.type")),Z=u(()=>Qt(t.draft,"printer.type")),ce=u(()=>!e(T));at(kt,{label:"Driver",path:"printer.type",get value(){return e(P)},hint:"Gardez le driver raster : c’est celui que les postes en service utilisent.",get fault(){return e(te)},get allowed(){return e(Z)},get disabled(){return e(ce)},onchange:Pe=>t.draft.set("printer.type",Pe)})}var Mt=n(kt,2);{let P=u(()=>$e(t.draft,"printer.options.transport")),te=u(()=>Qt(t.draft,"printer.options.transport")),Z=u(()=>!e(T));at(Mt,{label:"Transport",path:"printer.options.transport",get value(){return e(G)},hint:"Local par défaut : une file Windows ou un nœud d’impression de ce poste.",get fault(){return e(P)},get allowed(){return e(te)},get choices(){return e(Y)},get disabled(){return e(Z)},onchange:ce=>t.draft.set("printer.options.transport",ce)})}var na=n(Mt,2);{let P=u(()=>qt(e(M))),te=u(()=>t.draft.text(e(M))),Z=u(()=>Ci[e(ne)]??""),ce=u(()=>$e(t.draft,e(M))),Pe=u(()=>xe(e(M),e(b).map(Ee=>Ee.name))),pe=u(()=>!e(T));at(na,{get label(){return e(P)},get path(){return e(M)},get value(){return e(te)},get hint(){return e(Z)},get fault(){return e(ce)},get allowed(){return e(Pe)},get disabled(){return e(pe)},onchange:Ee=>t.draft.set(e(M),Ee)})}f(P=>{o(bt,e(X).detail),o(aa,e(fe)),o(Fe,e(de).detail),o(Xe,P)},[()=>Si(e($))]),Fa("open","toggle",Ft,P=>v(g,P),()=>e(g)),Fa("open","toggle",xt,P=>v(D,P),()=>e(D)),l(a,pt),Me()}ot(["change","click"]);const Za={sent:"envoyée à l’imprimante",rejected:"refusée",failed:"en échec",reprint:"réimpression"},er=[{value:"",label:"toutes"},{value:"sent",label:"envoyées à l’imprimante"},{value:"rejected",label:"refusées"},{value:"failed",label:"en échec"},{value:"reprint",label:"réimpressions"}],Ki={scale:"balance",manual:"saisie manuelle",replay:"trame rejouée"},$i={stable:"stable",unstable:"instable",unknown:"non déclarée par la balance",not_applicable:"sans objet — saisie manuelle"},Yi={by_weight:"au poids",by_unit:"à l’unité"},Xi={debug:"mise au point",info:"information",warn:"avertissement",error:"erreur",critical:"critique"};function ra(a,t,r){return a[t]??r}var Qi=c(' '),Zi=c(' '),eo=c('
                    • '),to=c('
                        ');function ao(a,t){Ue(t,!0);var r=to(),i=s(r);ze(i,21,()=>t.lines,d=>d.id,(d,p)=>{var y=eo(),z=s(y),m=s(z),j=n(z,2),S=s(j),A=n(j,2),L=s(A),W=n(A,2);{var O=x=>{var g=Qi(),D=s(g);f(()=>o(D,e(p).code)),l(x,g)};_(W,x=>{e(p).code!==""&&St.showTechnicalNames&&x(O)})}var C=n(W,2),K=s(C),U=n(C,2);{var E=x=>{var g=Zi(),D=s(g);f(()=>o(D,e(p).detail)),l(x,g)};_(U,x=>{e(p).detail!==""&&x(E)})}f((x,g,D)=>{be(y,"data-level",e(p).level),o(m,x),o(S,g),o(L,D),o(K,e(p).message)},[()=>Lt(e(p).occurred_at),()=>ra(Xi,e(p).level,"niveau inconnu"),()=>mr(e(p).source)]),l(d,y)}),l(a,r),Me()}var ro=c(""),no=c('L’export sera proposé quand le journal aura répondu.'),so=c('L’export n’est pas proposé : ce poste n’a pas répondu à la lecture du journal.'),lo=c('Exporter en CSV'),io=c('

                        '),oo=c('

                        '),uo=c(`

                        Aucune trame brute n’a été enregistrée pour cette pesée : il n’y a - rien à rejouer.

                        `),co=c(`

                        La trame repart dans le décodeur du poste EN SERVICE : le poids - affiché au client change, et rien ne le remet comme il était. C’est - ce qui fait d’un refus inexpliqué un test permanent, sans - déplacement au magasin et sans balance.

                        `,1),vo=c('

                        Produit
                        Référence
                        Vente
                        Brut / tare / net
                        Stabilité
                        Origine du poids
                        Résultat
                        Trame brute
                        '),po=c(' ',1),fo=c('

                        QuandProduitNetCode-barresRésultatDuréeDétail
                        ',1),go=c('
                        ',1),mo=c('

                        '),ho=c('

                        ',1),_o=c('
                        ');function bo(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=200,d=5e3,p=50,y=500,z=7;let m=B(vt([])),j=B(vt([])),S=B(""),A=B(null),L=B(!1),W=B(!1),O=B("loading"),C=B("loading"),K=B(""),U=B("");const E=u(()=>({limit:String(i),...e(S)===""?{}:{result:e(S)}})),x=u(()=>({...e(E),limit:String(d)})),g=u(()=>e(m).find(h=>h.id===e(A))??null),D=u(()=>er.find(h=>h.value===e(S))?.label??""),H=u(()=>w(e(m).length,i,"pesée","pesées",`L’export CSV en emporte jusqu’à ${se(d)}.`)),V=u(()=>w(e(j).length,p,"ligne","lignes",`Le fichier de diagnostic emporte les ${se(y)} dernières.`));$();async function $(){v(A,null),v(W,!0),v(O,"loading"),v(C,"loading"),v(m,[],!0),v(j,[],!0),v(K,""),v(U,"");try{const h=await r().load(()=>Sn(e(E)));v(O,h===null?"unread":"read",!0),v(K,h===null?r().actionError:"",!0),v(m,h??[],!0);const G=await r().load(()=>En({limit:String(p)}));v(C,G===null?"unread":"read",!0),v(U,G===null?r().actionError:"",!0),v(j,G??[],!0)}finally{v(W,!1),r().actionError=""}}async function T(h){r().notice="",r().actionError="",v(L,!0);try{const G=await r().protect(()=>zn(h));G!==null&&(r().notice=G.message)}finally{v(L,!1)}}function w(h,G,Y,ne,M){const b=h>1?ne:Y;return h{var Y=go(),ne=ae(Y),M=n(s(ne),2);ze(M,21,()=>er,ee=>ee.value,(ee,ve)=>{var ye=ro(),Ie=s(ye),q={};f(()=>{o(Ie,e(ve).label),q!==(q=e(ve).value)&&(ye.value=(ye.__value=e(ve).value)??"")}),l(ee,ye)});var b=n(M,2);Te(b,{label:"Rafraîchir",get busy(){return e(W)},onrun:()=>{$()}});var N=n(b,2);{var X=ee=>{var ve=no();l(ee,ve)},de=ee=>{var ve=so();l(ee,ve)},he=ee=>{var ve=lo();f(ye=>be(ve,"href",ye),[()=>Ln(e(x))]),l(ee,ve)};_(N,ee=>{e(O)==="loading"?ee(X):e(O)==="unread"?ee(de,1):ee(he,-1)})}var fe=n(ne,2);{var oe=ee=>{var ve=io(),ye=s(ve);f(Ie=>o(ye,`L’export emporte le même filtre que le tableau, mais pas son plafond : il descend - jusqu’à ${Ie??""} pesées, en point-virgule et en UTF-8 — il s’ouvre - tel quel dans le tableur d’un Windows français. Il ne demande aucun mot de passe : la - lecture du journal n’en demande pas non plus, et le fichier de diagnostic emporte déjà - les deux cents dernières pesées.`),[()=>se(d)]),l(ee,ve)};_(fe,ee=>{e(O)==="read"&&ee(oe)})}var I=n(fe,2);{var Q=ee=>{var ve=oo(),ye=s(ve);{var Ie=ue=>{var ie=lt("Lecture du journal…");l(ue,ie)},q=ue=>{var ie=lt();f(()=>o(ie,`Le journal n’a pas pu être lu : ${e(K)??""} Ce n’est pas « aucune pesée ».`)),l(ue,ie)},le=ue=>{var ie=lt("Le journal ne contient aucune pesée.");l(ue,ie)},ge=ue=>{var ie=lt();f(()=>o(ie,`Aucune pesée ne correspond au filtre « ${e(D)??""} ».`)),l(ue,ie)};_(ye,ue=>{e(O)==="loading"?ue(Ie):e(O)==="unread"?ue(q,1):e(S)===""?ue(le,2):ue(ge,-1)})}l(ee,ve)},re=ee=>{var ve=fo(),ye=ae(ve),Ie=s(ye),q=n(ye,2),le=s(q),ge=n(s(le));ze(ge,21,()=>e(m),ue=>ue.id,(ue,ie)=>{var Ne=po(),je=ae(Ne);let ke;var Re=s(je),We=s(Re),xe=n(Re),De=s(xe),rt=n(xe),nt=s(rt),pt=n(rt),ct=s(pt),dt=n(pt),ft=s(dt),bt=n(dt),et=s(bt),_e=n(bt),Se=s(_e),st=s(Se),Dt=n(je,2);{var zt=Et=>{var jt=vo(),Rt=s(jt);be(Rt,"colspan",z);var At=s(Rt),Ft=s(At),Jt=n(At,2),Ut=n(s(Jt),2),ta=s(Ut),Kt=n(Ut,4),$t=s(Kt),aa=n(Kt,4),J=s(aa),me=n(aa,4),we=s(me),Le=n(me,4),Ke=s(Le),Ve=n(Le,4),Ye=s(Ve),Fe=n(Ve,4),Ae=s(Fe),Xe=n(Fe,4),Ge=s(Xe),Qe=s(Ge),it=n(Jt,2);{var qe=Je=>{var ut=uo();l(Je,ut)},Ze=Je=>{var ut=co(),gt=ae(ut),yt=s(gt);Te(yt,{kind:"destructive",label:"Rejouer cette trame",protected:!0,get busy(){return e(L)},onrun:()=>{T(e(g).frame)}}),l(Je,ut)};_(it,Je=>{e(g).frame===""?Je(qe):Je(Ze,-1)})}f((Je,ut,gt,yt,Ct,xt,Ot,kt,Mt)=>{be(jt,"data-detail",e(ie).id),o(Ft,`Pesée ${e(g).id??""}`),o(ta,`${e(g).product_name??""} (${e(g).product_id??""})`),o($t,e(g).reference),o(J,`${Je??""} — - ${ut??""} - ${e(g).quantity>1?"unités":"unité"}`),o(we,`${gt??""} g / ${yt??""} g / - ${Ct??""} g`),o(Ke,`${xt??""} — cadence - médiane ${Ot??""}`),o(Ye,kt),o(Ae,`${Mt??""}${e(g).detail===""?"":` — ${e(g).detail}`}`),o(Qe,e(g).frame===""?"aucune trame enregistrée":e(g).frame)},[()=>ra(Yi,e(g).mode,"mode de vente inconnu"),()=>se(e(g).quantity),()=>se(e(g).gross_g),()=>se(e(g).tare_g),()=>se(e(g).net_g),()=>ra($i,e(g).stability,"stabilité inconnue"),()=>ua(e(g).rate_ms),()=>ra(Ki,e(g).source,"origine inconnue"),()=>ra(Za,e(g).result,"résultat inconnu")]),l(Et,jt)};_(Dt,Et=>{e(A)===e(ie).id&&e(g)!==null&&Et(zt)})}f((Et,jt,Rt,At)=>{ke=Nt(je,1,"svelte-86o8oz",null,ke,{open:e(A)===e(ie).id}),o(We,Et),o(De,e(ie).product_name),o(nt,`${jt??""} g`),o(ct,e(ie).barcode),o(ft,Rt),o(et,At),be(Se,"aria-expanded",e(A)===e(ie).id),o(st,e(A)===e(ie).id?"fermer":"détail")},[()=>Lt(e(ie).occurred_at),()=>se(e(ie).net_g),()=>ra(Za,e(ie).result,"résultat inconnu"),()=>ua(e(ie).duration_ms)]),Ce("click",Se,()=>v(A,e(A)===e(ie).id?null:e(ie).id,!0)),l(ue,Ne)}),f(()=>o(Ie,e(H))),l(ee,ve)};_(I,ee=>{e(m).length===0?ee(Q):ee(re,-1)})}Ce("change",M,()=>{$()}),$r(M,()=>e(S),ee=>v(S,ee)),l(h,Y)},$$slots:{default:!0}});var F=n(R,2);Oe(F,{title:"Journal technique",children:(h,G)=>{var Y=Tt(),ne=ae(Y);{var M=N=>{var X=mo(),de=s(X);{var he=I=>{var Q=lt("Lecture du journal technique…");l(I,Q)},fe=I=>{var Q=lt();f(()=>o(Q,`Le journal technique n’a pas pu être lu : ${e(U)??""} Ce n’est pas « aucune - ligne ».`)),l(I,Q)},oe=I=>{var Q=lt("Aucune ligne technique.");l(I,Q)};_(de,I=>{e(C)==="loading"?I(he):e(C)==="unread"?I(fe,1):I(oe,-1)})}l(N,X)},b=N=>{var X=ho(),de=ae(X),he=s(de),fe=n(de,2);ao(fe,{get lines(){return e(j)}}),f(()=>o(he,e(V))),l(N,X)};_(ne,N=>{e(j).length===0?N(M):N(b,-1)})}l(h,Y)},$$slots:{default:!0}}),l(a,k),Me()}ot(["change","click"]);const tr="L’aperçu n’a pas pu être rendu par le poste.",wo="Le poste a rendu l’aperçu, mais le navigateur ne l’a pas affiché.";async function yo(a){try{const t=await fetch(a,{headers:{accept:"application/json"}});if(t.ok)return wo;const r=JSON.parse(await t.text());return typeof r.message=="string"&&r.message!==""?r.message:tr}catch{return tr}}function ar(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return 0;r=r[i]}return typeof r=="number"?r:0}function qr(a){const t=Number(a);return a.trim()===""||Number.isNaN(t)?null:t}function xo(a){const t=a.trim().replace(",",".");if(!/^\d{1,3}(\.\d)?$/u.test(t))return null;const r=Number(t);return r>100?null:r}function Sr(a,t){!(a instanceof HTMLInputElement)||a.value===t||(a.value=t)}var ko=c('

                        '),qo=c('

                        '),So=c('

                        '),Lo=c('

                        '),Eo=c('

                        Lecture de la configuration en cours… les flèches attendent qu’elle soit arrivée.

                        '),Co=c(`
                        Aperçu de l’étiquette telle qu’elle serait imprimée

                        Décalage horizontal

                        Décalage vertical

                        Le décalage ne descend pas sous zéro : le poste refuse un décalage négatif quel que - soit le gabarit. Le maximum, lui, dépend de la géométrie du gabarit ; il est annoncé - ici si l’enregistrement le dépasse.

                        Le symbole code-barres est volontairement tronqué : un symbole conforme n’entre pas - sur 40 × 25 mm avec les cinq champs texte. Ce n’est pas un défaut de rendu et il n’y - a rien à corriger.

                        Le détail chiffré du symbole — largeur de module, modules rendus, modules attendus — - n’est pas servi par ce poste. Cet écran n’affiche pas un chiffre qu’il aurait deviné.

                        `,1),Po=c(`

                        Chaque appui sort une étiquette pour de bon : le mot de passe est demandé au moment - d’imprimer, et l’impression repart d’elle-même une fois la session ouverte.

                        `,1),To=c('
                        ');function zo(a,t){Ue(t,!0);const r=Be(t,"admin",7),i="printer.template",d="printer.options.offset_x",p="printer.options.offset_y",y=0,z=[{what:"alignment",label:"Imprimer la mire d’alignement"},{what:"ruler",label:"Imprimer la réglette"}];let m=B(1),j=B(!0),S=B(!1),A=B(""),L=B(null),W=B(""),O=null,C=!1;const K=u(()=>t.draft.text(i)),U=u(()=>qn(e(K),e(j),e(S),e(m))),E=u(()=>t.draft.config!==null),x=u(()=>t.draft.number(d)),g=u(()=>t.draft.number(p)),D=u(()=>$e(t.draft,d)),H=u(()=>$e(t.draft,p)),V=u(F),$=u(()=>r().busy||e(W)!=="");wt(()=>{if(t.draft.config===null)return;if(t.draft.dirty){O===null&&T();return}const N={x:t.draft.number(d),y:t.draft.number(p)};if(O!==null&&O.x===N.x&&O.y===N.y)return;const X=O===null;O=N,v(L,N,!0),X||k()});async function T(){if(!C){C=!0;try{const N=await za(),X={x:ar(N.config,d),y:ar(N.config,p)};O=X,v(L,X,!0)}catch{}finally{C=!1}}}function w(N,X){const de=t.draft.number(N)+X;de=2?"s":""}`}function G(N,X){const de=qr(X);de!==null&&t.draft.set(N,de)}async function Y(N){if(!e($)){v(W,N,!0),r().actionError="",r().notice="";try{const X=await r().protect(()=>fr(N));if(X===null)return;r().notice=X.message,await r().refresh()}finally{v(W,"")}}}var ne=To(),M=s(ne);Oe(M,{title:"Aperçu de l’étiquette",note:"Le même moteur que l’impression (A2) : le décalage se voit parce qu’il est cuit dans le bitmap. L’image porte le gabarit en cours d’édition, mais le décalage ENREGISTRÉ — jamais celui que les flèches sont en train de régler.",children:(N,X)=>{var de=Co(),he=ae(de);{var fe=_e=>{var Se=ko(),st=s(Se);f(()=>o(st,e(V))),l(_e,Se)};_(he,_e=>{e(V)!==""&&_e(fe)})}var oe=n(he,2),I=s(oe),Q=s(I),re=n(Q,2);{var ee=_e=>{var Se=qo(),st=s(Se);f(()=>o(st,e(A))),l(_e,Se)};_(re,_e=>{e(A)!==""&&_e(ee)})}var ve=n(I,2),ye=s(ve),Ie=n(s(ye),2),q=s(Ie),le=n(ye,2),ge=s(le);{let _e=u(()=>!e(E)||e(x)<=y);Te(ge,{label:"← 1 dot",get disabled(){return e(_e)},onrun:()=>w(d,-1)})}var ue=n(ge,2);{let _e=u(()=>!e(E));Te(ue,{label:"1 dot →",get disabled(){return e(_e)},onrun:()=>w(d,1)})}var ie=n(le,2);{var Ne=_e=>{var Se=So(),st=s(Se);f(()=>o(st,e(D))),l(_e,Se)};_(ie,_e=>{e(D)!==""&&_e(Ne)})}var je=n(ie,2),ke=n(s(je),2),Re=s(ke),We=n(je,2),xe=s(We);{let _e=u(()=>!e(E)||e(g)<=y);Te(xe,{label:"↑ 1 dot",get disabled(){return e(_e)},onrun:()=>w(p,-1)})}var De=n(xe,2);{let _e=u(()=>!e(E));Te(De,{label:"1 dot ↓",get disabled(){return e(_e)},onrun:()=>w(p,1)})}var rt=n(We,2);{var nt=_e=>{var Se=Lo(),st=s(Se);f(()=>o(st,e(H))),l(_e,Se)};_(rt,_e=>{e(H)!==""&&_e(nt)})}var pt=n(rt,4),ct=s(pt),dt=n(pt,2),ft=s(dt),bt=n(dt,2);{var et=_e=>{var Se=Eo();l(_e,Se)};_(bt,_e=>{e(E)||_e(et)})}f((_e,Se)=>{be(Q,"src",e(U)),o(q,_e),o(Re,Se)},[()=>h(e(x)),()=>h(e(g))]),Ht("load",Q,()=>v(A,"")),Ht("error",Q,()=>{R(e(U))}),Ce("change",ct,()=>k()),Da(ct,()=>e(j),_e=>v(j,_e)),Ce("change",ft,()=>k()),Da(ft,()=>e(S),_e=>v(S,_e)),l(N,de)},$$slots:{default:!0}});var b=n(M,2);Oe(b,{title:"Gabarit et impression",children:(N,X)=>{var de=Po(),he=ae(de);{let re=u(()=>$e(t.draft,i)),ee=u(()=>!e(E));at(he,{label:"Gabarit",path:i,get value(){return e(K)},hint:"Le gabarit reproduit à l’identique s’appelle weighing_identical (A1).",get fault(){return e(re)},get disabled(){return e(ee)},onchange:ve=>{t.draft.set(i,ve),k()}})}var fe=n(he,2);{let re=u(()=>t.draft.text("printer.options.darkness")),ee=u(()=>$e(t.draft,"printer.options.darkness")),ve=u(()=>!e(E));at(fe,{label:"Noircissement",path:"printer.options.darkness",kind:"number",get value(){return e(re)},hint:"Trop bas, l’étiquette pâlit au soleil ; trop haut, elle bave et le scanner refuse.",get fault(){return e(ee)},get disabled(){return e(ve)},onchange:ye=>G("printer.options.darkness",ye)})}var oe=n(fe,2);{let re=u(()=>t.draft.text("printer.options.speed")),ee=u(()=>$e(t.draft,"printer.options.speed")),ve=u(()=>!e(E));at(oe,{label:"Vitesse",path:"printer.options.speed",kind:"number",get value(){return e(re)},get fault(){return e(ee)},get disabled(){return e(ve)},onchange:ye=>G("printer.options.speed",ye)})}var I=n(oe,2);{let re=u(()=>t.draft.text("printer.options.copies")),ee=u(()=>$e(t.draft,"printer.options.copies")),ve=u(()=>!e(E));at(I,{label:"Exemplaires",path:"printer.options.copies",kind:"number",get value(){return e(re)},hint:"Un client repart avec une étiquette : deux exemplaires se justifient, pas se devinent.",get fault(){return e(ee)},get disabled(){return e(ve)},onchange:ye=>G("printer.options.copies",ye)})}var Q=n(I,2);ze(Q,21,()=>z,re=>re.what,(re,ee)=>{{let ve=u(()=>e(W)===e(ee).what);Te(re,{get act(){return e(ee).what},get label(){return e(ee).label},protected:!0,get busy(){return e(ve)},get disabled(){return e($)},onrun:()=>{Y(e(ee).what)}})}}),l(N,de)},$$slots:{default:!0}}),l(a,ne),Me()}ot(["change"]);const Lr=[{rank:1,code:"OVERLOAD",label:"Surcharge",when:"La balance annonce elle-même OL, ou le poids brut dépasse la capacité.",severity:"Bloquant",blocking:!0,message:"La balance est en surcharge. Retirez votre article.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la capacité, réglée au garde-fou 9 sous « ${qt("limits.max_weight_g")} ».`},{rank:2,code:"MEASUREMENT_EXPIRED",label:"Poids périmé",when:"La mesure est plus vieille que la péremption, dans les deux modes de stabilité.",severity:"Bloquant",blocking:!0,message:"Poids indisponible. Patientez ou appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil à régler : le poste calcule lui-même à partir du rythme de la balance."},{rank:3,code:"BASKET_MISSING",label:"Panier absent",when:"Le poids brut tombe dans la fenêtre négative du panier : il a été soulevé.",severity:"Bloquant",blocking:!0,message:"Le panier n'est pas sur la balance. Reposez-le.",thresholds:[{path:"limits.basket_min_g",label:"Bas de la fenêtre du panier",hint:"En grammes, NÉGATIF : c’est le poids du panier que la balance a perdu."},{path:"limits.basket_max_g",label:"Haut de la fenêtre du panier",hint:"Négatif lui aussi, et plus proche de zéro que le bas."}],switchPath:"limits.basket_check_enabled",switchLabel:"Ce poste travaille avec un panier taré",note:"La règle s’active ou non, en bloc : il n’y a pas de demi-mesure à régler."},{rank:4,code:"SCALE_EMPTY",label:"Plateau vide",when:"Le poids brut ne sort pas de la bande « il n’y a rien sur le plateau ».",severity:"Bloquant — un filet, hors parcours nominal",blocking:!0,message:"Posez votre produit.",thresholds:[{path:"limits.empty_max_g",label:"Plateau considéré vide",hint:"En dessous, le poste considère qu’il n’y a rien sur le plateau."}],switchPath:"",switchLabel:"",note:"Toucher une tuile sur un plateau vide ARME la sélection au lieu d’être refusé : la règle reste évaluée pour la saisie manuelle et les chemins dérivés."},{rank:5,code:"TARE_REQUIRED",label:"Remise à zéro nécessaire",when:"Le brut est sous la bande du plateau vide, et hors de la fenêtre du panier.",severity:"Bloquant",blocking:!0,message:"La balance doit être remise à zéro.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la valeur négative de celui du garde-fou 4, « ${qt("limits.empty_max_g")} ».`},{rank:6,code:"WEIGHT_UNSTABLE",label:"Pesée instable",when:"La trame déclare la mesure instable.",severity:"Information par défaut (A3)",blocking:!1,message:"Pesée en cours…",thresholds:[],switchPath:"",switchLabel:"",note:"La sévérité suit l’exigence de stabilité : elle passe à Bloquant quand celle-ci est réglée sur « blocking ». L’impression n’est jamais bloquée par défaut."},{rank:7,code:"TARE_INVALID",label:"Emballage incohérent",when:"Une tare a été saisie, et elle atteint la pesée ou dépasse le maximum.",severity:"Bloquant",blocking:!0,message:"Le poids de l'emballage est supérieur ou égal à la pesée.",thresholds:[{path:"limits.max_tare_g",label:"Tare maximum",hint:"Une tare plus lourde que le maximum est une faute de frappe."}],switchPath:"",switchLabel:"",note:""},{rank:8,code:"WEIGHT_TOO_LOW",label:"Poids trop faible",when:"Vente au poids : le NET est strictement positif et ne dépasse pas le plancher.",severity:"Bloquant",blocking:!0,message:"La balance doit être retarée, ou l'emballage est trop lourd.",thresholds:[{path:"limits.min_weight_g",label:"Poids minimum",hint:"Une dérogation par produit existe, dans l’onglet Catalogue."}],switchPath:"",switchLabel:"",note:""},{rank:9,code:"WEIGHT_TOO_HIGH",label:"Poids trop élevé",when:"Le NET dépasse la capacité — strictement, pour que la capacité reste atteignable.",severity:"Bloquant",blocking:!0,message:"{{.Weight}} kg, ça paraît un peu lourd !",thresholds:[{path:"limits.max_weight_g",label:"Poids maximum",hint:"C’est la capacité du champ NNDDD du code-barres, pas un seuil de vraisemblance."}],switchPath:"",switchLabel:"",note:""},{rank:10,code:"UNITS_OUT_OF_RANGE",label:"Nombre d’unités hors plage",when:"Vente à l’unité : la quantité sort de la plage.",severity:"Bloquant",blocking:!0,message:"{{.Quantity}} unités, ça paraît un peu beaucoup !",thresholds:[{path:"limits.min_units",label:"Unités minimum",hint:""},{path:"limits.max_units",label:"Unités maximum",hint:""}],switchPath:"",switchLabel:"",note:""},{rank:11,code:"AMOUNT_OUT_OF_CAPACITY",label:"Montant hors capacité du code-barres",when:"La charge utile encode un PRIX, et il dépasse ce que le champ peut porter.",severity:"Bloquant",blocking:!0,message:"Prix trop élevé pour le code-barres.",thresholds:[{path:"limits.max_amount_cents",label:"Montant maximum",hint:"En centimes. Aucun préfixe du plan livré n’encode un prix : la règle est éprouvée sans qu’aucun produit puisse l’atteindre."}],switchPath:"",switchLabel:"",note:""},{rank:12,code:"ZERO_PRICE",label:"Prix nul",when:"Le montant du tarif imprimé en grand vaut zéro.",severity:"Bloquant",blocking:!0,message:"Prix nul. Appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : un produit à 0 € est une anomalie sans nuance."},{rank:13,code:"LIGHT_PRODUCT_ALLOWED",label:"Produit léger autorisé",when:"Le garde-fou 8 n’a pas déclenché grâce à la dérogation du produit.",severity:"Information",blocking:!1,message:"",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil général : c’est la dérogation par produit, listée plus bas et posée depuis l’onglet Catalogue. Rien ne s’affiche au client ; l’id du produit est journalisé."},{rank:14,code:"PRODUCT_WITHDRAWN",label:"Produit retiré",when:"Quelqu’un a décidé de ne plus proposer ce produit.",severity:"Bloquant",blocking:!0,message:"Ce produit n'est pas disponible.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : c’est une décision humaine, prise depuis l’onglet Catalogue. Aucune règle d’import ne peut la déduire."}],jo=["{{.Weight}}","{{.Quantity}}"];function Ro(a){return Lr.find(t=>t.code===a)?.label??"Garde-fou inconnu de cet écran"}function Oo(a){return a==="blocking"?"Bloquant":a==="info"?"Information":"Sévérité inconnue de cet écran"}var Ao=c('

                        Rien ne s’affiche au client : c’est une information.

                        '),No=c('

                        '),Io=c('
                        '),Do=c('

                        '),Fo=c('
                      • '),Uo=c(`

                          Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve - dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur.

                          `,1);function Mo(a,t){Ue(t,!0);function r(z,m){const j=qr(m);j!==null&&t.draft.set(z,j)}var i=Uo(),d=ae(i);ze(d,21,()=>Lr,z=>z.code,(z,m)=>{var j=Fo(),S=s(j),A=s(S),L=s(A),W=n(A,2),O=s(W),C=n(W,2),K=s(C),U=n(C,2),E=s(U),x=n(S,2),g=s(x),D=n(x,2);{var H=F=>{var h=Ao();l(F,h)},V=F=>{var h=No(),G=s(h);f(()=>o(G,`« ${e(m).message??""} »`)),l(F,h)};_(D,F=>{e(m).message===""?F(H):F(V,-1)})}var $=n(D,2);{var T=F=>{{let h=u(()=>t.draft.flag(e(m).switchPath));Na(F,{get path(){return e(m).switchPath},get label(){return e(m).switchLabel},get on(){return e(h)},onchange:G=>t.draft.set(e(m).switchPath,G)})}};_($,F=>{e(m).switchPath!==""&&F(T)})}var w=n($,2);ze(w,17,()=>e(m).thresholds,F=>F.path,(F,h)=>{var G=Io(),Y=s(G);{let ne=u(()=>t.draft.text(e(h).path));at(Y,{get label(){return e(h).label},get path(){return e(h).path},kind:"number",get value(){return e(ne)},get hint(){return e(h).hint},onchange:M=>r(e(h).path,M)})}Ce("focusout",G,ne=>Sr(ne.target,t.draft.text(e(h).path))),l(F,G)});var k=n(w,2);{var R=F=>{var h=Do(),G=s(h);f(()=>o(G,e(m).note)),l(F,h)};_(k,F=>{e(m).note!==""&&F(R)})}f(F=>{be(j,"data-code",e(m).code),o(L,e(m).rank),o(O,e(m).label),o(K,e(m).code),be(U,"data-blocking",F),o(E,e(m).severity),o(g,e(m).when)},[()=>String(e(m).blocking)]),l(z,j)});var p=n(d,4),y=s(p);f(z=>o(y,`Les marqueurs ${z??""} sont remplacés par les valeurs de la pesée au - moment où le message s’affiche.`),[()=>jo.join(" et ")]),l(a,i),Me()}ot(["focusout"]);function Wo(a){const t=a.value("pricing.tiers");return Array.isArray(t)?t.map(r=>{const i=r??{},d=i.discount_percent;return{code:String(i.code??""),label:String(i.label??""),abbrev:String(i.abbrev??""),written:d===void 0?null:String(d),discount:Bo(d)?d:null,rank:Number(i.rank??0)}}):[]}function Bo(a){return typeof a!="number"||!Number.isFinite(a)||a<0||a>100?!1:Math.abs(a*10-Math.round(a*10))<1e-9}function rr(a){return a===null?"":String(a).replace(".",",")}function Vo(a){const t=1e3-Math.round(a*10);return`${String(Math.trunc(t/100))},${String(t%100).padStart(2,"0")}`}var Go=c('

                          Aucun tarif déclaré dans la configuration lue.

                          '),Ho=c('Prix du catalogue Odoo — pas de remise'),nr=c(' '),Jo=c(' % ',1),Ko=c(' '),$o=c(`

                          CodeLibelléAbrégéRemiseOrdre

                          Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la - retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour - tous les adhérents.

                          `,1);function Yo(a,t){Ue(t,!0);const r=u(()=>Wo(t.draft)),i=u(()=>String(t.draft.value("pricing.reference_code")??""));function d(S){return S<=1?`${String(S)} tarif déclaré`:`${String(S)} tarifs déclarés`}function p(S,A){const L=xo(A);L!==null&&t.draft.set(S,L)}var y=Tt(),z=ae(y);{var m=S=>{var A=Go();l(S,A)},j=S=>{var A=$o(),L=ae(A),W=s(L),O=n(L,2),C=s(O),K=n(s(C));ze(K,21,()=>e(r),da,(U,E,x)=>{var g=Ko(),D=s(g),H=s(D),V=n(D),$=s(V);be($,"aria-label",`Libellé du tarif ${x+1}`);var T=n(V),w=s(T);be(w,"aria-label",`Abrégé du tarif ${x+1}`);var k=n(T),R=s(k);{var F=b=>{var N=Ho();l(b,N)},h=b=>{var N=nr(),X=s(N);f(()=>o(X,`${e(E).written??""} — le tarif de référence est le prix du catalogue : il - ne peut pas porter de remise, et l’enregistrement la refusera.`)),l(b,N)},G=b=>{var N=nr(),X=s(N);f(()=>o(X,`${e(E).written??""} — une remise s’écrit au dixième de point ; celle-ci se - change dans le fichier de configuration.`)),l(b,N)},Y=b=>{var N=Jo(),X=ae(N);be(X,"aria-label",`Remise du tarif ${x+1}`);var de=n(X,2),he=s(de);f((fe,oe)=>{Gt(X,fe),o(he,`un produit à 10,00 €/kg s’affiche ${oe??""} €/kg`)},[()=>rr(e(E).discount??0),()=>Vo(e(E).discount??0)]),Ce("input",X,fe=>p(`pricing.tiers.${String(x)}.discount_percent`,fe.currentTarget.value)),Ce("focusout",X,fe=>Sr(fe.currentTarget,rr(e(E).discount??0))),l(b,N)};_(R,b=>{e(E).code===e(i)&&e(E).written===null?b(F):e(E).code===e(i)?b(h,1):e(E).written!==null&&e(E).discount===null?b(G,2):b(Y,-1)})}var ne=n(k),M=s(ne);f(()=>{o(H,e(E).code),Gt($,e(E).label),Gt(w,e(E).abbrev),o(M,e(E).rank)}),Ce("input",$,b=>t.draft.set(`pricing.tiers.${String(x)}.label`,b.currentTarget.value)),Ce("input",w,b=>t.draft.set(`pricing.tiers.${String(x)}.abbrev`,b.currentTarget.value)),l(U,g)}),f(U=>o(W,`${U??""}.`),[()=>d(e(r).length)]),l(S,A)};_(z,S=>{e(r).length===0?S(m):S(j,-1)})}l(a,y),Me()}ot(["input","focusout"]);var Xo=c('

                          Aucune dérogation : la limite générale s’applique à tous les produits.

                          '),Qo=c(`

                          Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas - répondu. Les identifiants Odoo restent affichés.

                          `),Zo=c(`Produit retiré : le garde-fou 14 refuse le produit avant que cette - dérogation ait un sens.`),eu=c('
                        1. '),tu=c('

                            ',1);function au(a,t){Ue(t,!0);function r(L){return Aa(t.names[L],t.namesState,"Nom non lu")}const i=20,d=u(()=>t.waivers.filter(L=>!L.offered)),p=u(()=>t.waivers.slice(0,i)),y=u(()=>`${se(e(p).length)} ${e(p).length>1?"lignes affichées":"ligne affichée"} sur ${se(t.waivers.length)} `+(e(d).length===0?`${t.waivers.length>1?"dérogations en vigueur":"dérogation en vigueur"}.`:`${t.waivers.length>1?"dérogations enregistrées":"dérogation enregistrée"}, dont ${se(e(d).length)} sur un produit retiré, sans effet : le garde-fou 14 refuse le produit avant que le 8 ait un sens.`)+(t.waivers.length>e(p).length?" Les autres se lisent produit par produit depuis l’onglet Catalogue.":""));function z(L){return`plancher ${se(L)} g : refusé à ${se(L)} g et en dessous`}var m=Tt(),j=ae(m);{var S=L=>{var W=Xo();l(L,W)},A=L=>{var W=tu(),O=ae(W),C=s(O),K=n(O,2);{var U=x=>{var g=Qo();l(x,g)};_(K,x=>{t.namesState==="unread"&&x(U)})}var E=n(K,2);ze(E,21,()=>e(p),x=>x.product_id,(x,g)=>{var D=eu(),H=s(D),V=s(H),$=n(H,2),T=s($),w=n($,2),k=s(w),R=n(w,2),F=s(R),h=n(R,2),G=s(h),Y=n(h,2);{var ne=M=>{var b=Zo();l(M,b)};_(Y,M=>{e(g).offered||M(ne)})}f((M,b,N,X)=>{be(D,"data-withdrawn",M),o(V,b),o(T,e(g).product_id),o(k,N),o(F,e(g).reason),o(G,`${X??""}, ${e(g).decided_by??""}`)},[()=>String(!e(g).offered),()=>r(e(g).product_id),()=>z(e(g).min_weight_g??0),()=>va(e(g).decided_at)]),l(x,D)}),f(()=>o(C,e(y))),l(L,W)};_(j,L=>{t.waivers.length===0?L(S):L(A,-1)})}l(a,m),Me()}var ru=c(" ",1),nu=c(`

                            Les deux arrondis sont distincts parce qu’ils tombent à des endroits différents du - calcul : arrondir le prix au kilo puis le multiplier, ou multiplier puis arrondir, ne - donne pas le même centime sur une étiquette. Le poste applique l’arrondi commercial, - et l’écart se voit au centime près sur la même pesée.

                            `,1),su=c(`

                            Aucun verdict en ce moment : ces lignes portent sur la pesée EN COURS, et le poste - est au repos. Elles apparaissent le temps d’un cycle, quand un client pose son sac - et touche une tuile.

                            `),lu=c(' '),iu=c(' ',1),ou=c('
                          • '),uu=c('

                              ',1),cu=c(`

                              L’ordre compte : le premier verdict bloquant décide de ce que le client lit. Les - garde-fous 1 à 7 portent sur l’état de la balance, c’est-à-dire le poids brut ; les - garde-fous 8 à 14 portent sur la vente, c’est-à-dire le net. Le code en gris est - celui du journal, celui qu’on lit au téléphone.

                              Ce que les garde-fous disent de la pesée en cours

                              `,1),du=c(`

                              Le plan de numérotation n’est PAS ici, et c’est tout l’intérêt : préfixes, largeur de - la référence, largeur de la charge utile, décimales et mode de vente sont une - constante du binaire, indexée par préfixe et vérifiée au démarrage. - Un champ qui change le SENS du code lu par la caisse n’est pas un réglage, c’est un - contrat externe : il change avec une version du binaire, relue et testée, jamais - depuis l’écran d’un poste.

                              `,1),vu=c('
                              ');function pu(a,t){Ue(t,!0);const r=u(()=>t.health.state.diagnostics),i=u(()=>t.health.decisions.filter(C=>C.min_weight_g!==null));let d=B(vt({})),p=B("loading");y();async function y(){try{const C=await cr();v(d,Object.fromEntries(C.products.map(K=>[K.id,K.name])),!0),v(p,"read")}catch{v(p,"unread")}}function z(C){return Aa(e(d)[C],e(p),"Nom non lu")}const m=u(()=>`${se(e(r).length)} ${e(r).length>1?"verdicts":"verdict"} sur les quatorze garde-fous.`);var j=vu(),S=s(j);Oe(S,{title:"Grille de tarifs",note:"Un second tarif n’est pas une case à cocher : c’est une ligne de plus dans cette grille.",children:(C,K)=>{var U=ru(),E=ae(U);Yo(E,{get draft(){return t.draft}});var x=n(E,2);{let D=u(()=>t.draft.text("pricing.primary_code"));at(x,{label:"Tarif imprimé en grand",path:"pricing.primary_code",get value(){return e(D)},hint:"Le prix que le client lit sur l’étiquette (A7).",onchange:H=>t.draft.set("pricing.primary_code",H)})}var g=n(x,2);{let D=u(()=>t.draft.text("pricing.reference_code"));at(g,{label:"Tarif qui serait encodé si le code-barres portait un prix",path:"pricing.reference_code",get value(){return e(D)},hint:"Le plan livré ne porte pas de prix, mais un poids ou un nombre d’unités : la caisse retrouve le prix par la référence, dans Odoo, et ne doit jamais sous-facturer.",onchange:H=>t.draft.set("pricing.reference_code",H)})}l(C,U)},$$slots:{default:!0}});var A=n(S,2);Oe(A,{title:"Les deux arrondis",children:(C,K)=>{var U=nu(),E=ae(U);{let g=u(()=>t.draft.text("pricing.unit_price_rounding"));at(E,{label:"Arrondi du prix unitaire dérivé",path:"pricing.unit_price_rounding",get value(){return e(g)},hint:"Il s’applique au prix au kilo calculé par la remise.",onchange:D=>t.draft.set("pricing.unit_price_rounding",D)})}var x=n(E,2);{let g=u(()=>t.draft.text("pricing.amount_rounding"));at(x,{label:"Arrondi du montant",path:"pricing.amount_rounding",get value(){return e(g)},hint:"Il s’applique au montant de l’étiquette.",onchange:D=>t.draft.set("pricing.amount_rounding",D)})}l(C,U)},$$slots:{default:!0}});var L=n(A,2);Oe(L,{title:"Les quatorze garde-fous, dans l’ordre d’évaluation",note:"Le seuil se modifie ici. La sévérité ne se règle pas : elle dit si le poste refuse ou avertit, et cela ne dépend pas du magasin. Le message affiché au client n’est pas encore modifiable depuis cet écran.",children:(C,K)=>{var U=cu(),E=n(ae(U),2);Mo(E,{get draft(){return t.draft}});var x=n(E,4);{var g=H=>{var V=su();l(H,V)},D=H=>{var V=uu(),$=ae(V),T=s($),w=n($,2);ze(w,21,()=>e(r),k=>k.code,(k,R)=>{var F=ou(),h=s(F),G=s(h),Y=n(h,2),ne=s(Y),M=n(Y,2),b=s(M),N=n(M,2);{var X=fe=>{var oe=lu(),I=s(oe);f(()=>o(I,`« ${e(R).message??""} »`)),l(fe,oe)};_(N,fe=>{e(R).message!==""&&fe(X)})}var de=n(N,2);{var he=fe=>{var oe=iu(),I=ae(oe),Q=s(I),re=n(I,2),ee=s(re);f(ve=>{o(Q,ve),o(ee,e(R).product_id)},[()=>z(e(R).product_id)]),l(fe,oe)};_(de,fe=>{e(R).product_id!==""&&fe(he)})}f((fe,oe,I)=>{be(F,"data-blocking",fe),o(G,oe),o(ne,e(R).code),o(b,I)},[()=>String(e(R).blocking),()=>Ro(e(R).code),()=>Oo(e(R).severity)]),l(k,F)}),f(k=>{be($,"data-verdicts",k),o(T,e(m))},[()=>String(e(r).length)]),l(H,V)};_(x,H=>{e(r).length===0?H(g):H(D,-1)})}l(C,U)},$$slots:{default:!0}});var W=n(L,2);Oe(W,{title:"Code-barres",note:"Un seul réglage, et c’est voulu : le reste du plan de numérotation n’est pas de la configuration.",children:(C,K)=>{var U=du(),E=ae(U);{let x=u(()=>t.draft.flag("barcode.verify_reference_check_digit"));Na(E,{path:"barcode.verify_reference_check_digit",label:"Refuser une référence dont la clé de contrôle est fausse",hint:"Décoché, le poste recalcule une clé juste sur une référence fausse, en silence — et la caisse encaisse un autre article.",get on(){return e(x)},onchange:g=>t.draft.set("barcode.verify_reference_check_digit",g)})}l(C,U)},$$slots:{default:!0}});var O=n(W,2);Oe(O,{title:"Dérogations de poids minimum",note:"En lecture ici ; elles se modifient depuis l’onglet Catalogue, là où se trouve le produit.",children:(C,K)=>{au(C,{get waivers(){return e(i)},get names(){return e(d)},get namesState(){return e(p)}})},$$slots:{default:!0}}),l(a,j),Me()}var fu=c(" "),gu=c(' '),mu=c('
                              ChampEn serviceDans le fichier
                              ');function hu(a,t){Ue(t,!0);var r=mu(),i=s(r),d=n(s(i));ze(d,21,()=>t.rows,p=>p.path,(p,y)=>{const z=u(()=>qt(e(y).path));var m=gu(),j=s(m),S=s(j),A=n(S);{var L=U=>{var E=fu(),x=s(E);f(()=>o(x,e(y).path)),l(U,E)};_(A,U=>{St.showTechnicalNames&&e(z)!==e(y).path&&U(L)})}var W=n(j),O=s(W),C=n(W),K=s(C);f(()=>{be(m,"data-path",e(y).path),o(S,`${e(z)??""} `),o(O,e(y).before),o(K,e(y).after)}),l(p,m)}),l(a,r),Me()}var _u=c(" "),bu=c(' '),wu=c("
                            • "),yu=c(`

                                Recopier reste possible : les valeurs entrent dans le brouillon, où elles se - corrigent champ par champ avant l’enregistrement.

                                `);function xu(a,t){Ue(t,!0);const r=20,i=u(()=>t.faults.slice(0,r)),d=u(()=>ca(e(i).length,t.faults.length,"contrôle refuse une clé","contrôles refusent une clé"));var p=yu(),y=s(p),z=s(y),m=n(y,2);ze(m,21,()=>e(i),da,(j,S)=>{var A=wu(),L=s(A),W=s(L),O=n(L,2);{var C=x=>{var g=_u(),D=s(g);f(()=>o(D,e(S).field)),l(x,g)};_(O,x=>{St.showTechnicalNames&&x(C)})}var K=n(O),U=n(K);{var E=x=>{var g=bu(),D=s(g);f(H=>o(D,`Valeurs acceptées : ${H??""}.`),[()=>e(S).allowed.join(", ")]),l(x,g)};_(U,x=>{e(S).allowed!==void 0&&e(S).allowed.length>0&&x(E)})}f(x=>{o(W,x),o(K,` ${e(S).message??""} `)},[()=>qt(e(S).field)]),l(j,A)}),f(()=>o(z,`Ce fichier serait refusé en l’état : ${e(d)??""}`)),l(a,p),Me()}const ku=2e3,qu=300*1e3;async function Su(){const a=Date.now()+qu;for(;Date.now()setTimeout(t,ku));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var Lu=c('

                                ',1),Eu=c(`

                                Touchez de nouveau pour confirmer. Rien ne défait un redémarrage une fois - l’ordinateur parti.

                                `),Cu=c(" ",1),Pu=c('

                                '),Tu=c('
                              • '),zu=c('
                                  '),ju=c(`

                                  Trois gestes de reprise, du plus doux au plus brutal. Le premier ne coupe rien.

                                  Relire le fichier de configuration
                                  Met en service config.json tel qu’il est sur le disque, sans arrêter le - poste. À utiliser après une modification faite à la main dans le fichier.
                                  Redémarrer le poste
                                  Arrête l’application et la relance. La pesée est interrompue quelques secondes ; - l’écran client revient tout seul.
                                  Redémarrer l’ordinateur
                                  Redémarre la machine entière. Comptez une minute avant que l’écran revienne.
                                  `,1);function Ru(a,t){Ue(t,!0);let r=B(""),i=B(""),d=B(vt([])),p=B(!1),y=B(0),z=B(0),m=B(!1);const j=u(()=>e(y)>0);wt(()=>{if(!e(j))return;const O=setInterval(()=>{v(z,Math.max(0,Math.round((e(y)-Date.now())/1e3)),!0)},1e3);return()=>clearInterval(O)});async function S(){v(r,"reload-config"),v(i,""),v(d,[],!0);try{const O=await t.admin.protect(async()=>{try{return await pn()}catch(C){if(fa(C))throw C;return t.admin.report(C),v(d,t.admin.lastFaults,!0),null}});if(O===null)return;v(i,`Le fichier est en service. Empreinte ${O.config_fingerprint}.`)}finally{v(r,"")}}async function A(){v(r,"restart"),v(i,""),v(d,[],!0);try{const O=await t.admin.protect(()=>fn());if(O===null)return;v(i,O.message,!0),v(p,!0),v(i,await Su()?"Le poste est revenu.":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0)}finally{v(r,""),v(p,!1)}}async function L(){if(!e(m)){v(m,!0);return}v(r,"reboot"),v(i,""),v(d,[],!0);try{const O=await t.admin.protect(()=>gn());if(O===null)return;v(y,Date.parse(O.at),!0),v(z,O.seconds_left,!0)}finally{v(r,""),v(m,!1)}}async function W(){v(r,"cancel-reboot");try{const O=await t.admin.protect(()=>mn());if(O===null)return;v(y,0),v(i,O.message,!0)}finally{v(r,"")}}Oe(a,{title:"Maintenance",children:(O,C)=>{var K=ju(),U=n(ae(K),2),E=n(s(U),2),x=n(s(E),3);{let h=u(()=>e(r)==="reload-config");Te(x,{kind:"write",act:"reload-config",label:"Relire le fichier",protected:!0,get busy(){return e(h)},onrun:S})}var g=n(E,4),D=n(s(g));{let h=u(()=>e(r)==="restart"||e(p));Te(D,{kind:"write",act:"restart",label:"Redémarrer le poste",protected:!0,get busy(){return e(h)},onrun:A})}var H=n(g,4),V=n(s(H));{var $=h=>{var G=Lu(),Y=ae(G),ne=s(Y),M=n(Y,2);{let b=u(()=>e(r)==="cancel-reboot");Te(M,{kind:"write",act:"cancel-reboot",label:"Annuler",get busy(){return e(b)},onrun:W})}f(()=>o(ne,`L’ordinateur redémarre dans ${e(z)??""} seconde${e(z)>1?"s":""}.`)),l(h,G)},T=h=>{var G=Cu(),Y=ae(G);{var ne=b=>{var N=Eu();l(b,N)};_(Y,b=>{e(m)&&b(ne)})}var M=n(Y,2);{let b=u(()=>e(m)?"Confirmer le redémarrage":"Redémarrer l’ordinateur"),N=u(()=>e(r)==="reboot");Te(M,{kind:"destructive",act:"reboot",get label(){return e(b)},protected:!0,get busy(){return e(N)},onrun:L})}l(h,G)};_(V,h=>{e(j)?h($):h(T,-1)})}var w=n(U,2);{var k=h=>{var G=Pu(),Y=s(G);f(()=>o(Y,e(i))),l(h,G)};_(w,h=>{e(i)!==""&&h(k)})}var R=n(w,2);{var F=h=>{var G=zu();ze(G,21,()=>e(d),Y=>Y.field,(Y,ne)=>{var M=Tu(),b=s(M),N=s(b),X=n(b);f(()=>{o(N,e(ne).field),o(X,` — ${e(ne).message??""}`)}),l(Y,M)}),l(h,G)};_(R,h=>{e(d).length>0&&h(F)})}l(O,K)},$$slots:{default:!0}}),Me()}function Ou(a,t){const r=[];for(const i of[...La(a),...La(t)]){if(r.some(y=>y.path===i))continue;const d=sr(ga(a,i)),p=sr(ga(t,i));d!==p&&r.push({path:i,before:d,after:p})}return r}function ga(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}function La(a,t=""){if(a===null||typeof a!="object"||Array.isArray(a))return t===""?[]:[t];const r=[];for(const[i,d]of Object.entries(a))r.push(...La(d,t===""?i:`${t}.${i}`));return r}function sr(a){return a===void 0?"—":a===null?"vide":typeof a=="boolean"?a?"oui":"non":typeof a=="object"?JSON.stringify(a):String(a)}const Au=new Set(["modified_at","catalog.options.password"]),Nu=[{path:"station.name",name:"le nom du poste"},{path:"network.listen",name:"l’adresse d’écoute"},{path:"scale.options.port",name:"le port de la balance"},{path:"printer.options.queue",name:"la file d’impression"},{path:"printer.options.path",name:"le nœud d’impression"},{path:"printer.options.address",name:"l’adresse de l’imprimante"},{path:"catalog.options.url",name:"l’adresse du partage"},{path:"catalog.options.username",name:"le compte du partage"},{path:"catalog.images.path",name:"le chemin des images"}];function Iu(a,t){return a===null||t===null?[]:Ou(a,t).filter(r=>!Au.has(r.path))}function Du(a,t){if(a===null||t===null)return[];const r=a,i=t;return Nu.filter(d=>lr(i,d.path)&&!lr(r,d.path))}function lr(a,t){const r=ga(a,t);return r==null||r===""?!0:typeof r!="object"||Array.isArray(r)?!1:Object.keys(r).length===0}function Fu(a){return a.length<2?a.join(""):`${a.slice(0,-1).join(", ")} et ${a[a.length-1]??""}`}async function Uu(a,t){const i=await fetch(`/admin/api/config/export?hardware=${t?"1":"0"}`,{headers:{accept:"application/json"}});if(!i.ok)throw new Pt(i.status,Er(await i.text(),"L’export"));return{name:Wu(a,t),blob:await i.blob()}}async function Mu(a){const t=await fetch("/admin/api/config/import",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(a)}),r=await t.text();if(!t.ok)throw new Pt(t.status,Er(r,"L’import"));return JSON.parse(r)}function Wu(a,t){const r=t?"":"-sans-materiel";return`config-poste${String(a)}${r}-${Bu()}.json`}function Bu(){const a=new Date,t=String(a.getMonth()+1).padStart(2,"0"),r=String(a.getDate()).padStart(2,"0");return`${String(a.getFullYear())}-${t}-${r}`}function Vu(a,t){const r=URL.createObjectURL(t),i=document.createElement("a");i.href=r,i.download=a,document.body.appendChild(i),i.click(),i.remove(),setTimeout(()=>{URL.revokeObjectURL(r)},0)}function Er(a,t){try{const r=JSON.parse(a);if(typeof r?.message=="string"&&r.message!=="")return r.message}catch{}return`${t} a été refusé par le poste.`}var Gu=c('
                                  Empreinte de la configuration en service
                                  Version du binaire
                                  Répertoire de données
                                  Espace disque
                                  ',1),Hu=c('

                                  '),Ju=c(`

                                  Ce fichier n’a été comparé à rien : la configuration en service n’a pas pu être - lue. Ni « identique », ni « n champs changent » — ce que ce fichier changerait - sur ce poste reste inconnu.

                                  `),Ku=c(`

                                  Ce fichier décrit la même configuration que celle en service : il n’y a rien à - recopier. C’est ce qu’on veut lire à la fin d’un clonage. Deux champs ne sont pas - comparés : la date du dernier enregistrement, que chaque poste écrit lui-même, et - le mot de passe du catalogue, qu’aucun des deux ne porte en clair.

                                  `),$u=c('

                                  '),Yu=c(`

                                  Recopier n’applique rien : les valeurs entrent dans le brouillon, et c’est - « Enregistrer » qui les met en service.

                                  `,1),Xu=c('

                                  ',1),Qu=c(`

                                  L’export emporte encore l’empreinte du mot de passe : c’est la seule lecture que le - poste garde derrière la clé. L’import, lui, est lu PAR LE POSTE, qui écarte les deux - secrets et le numéro de poste avant de dire ce qui changerait.

                                  `,1),Zu=c('

                                  Lecture des versions enregistrées…

                                  '),ec=c(`

                                  Les versions enregistrées n’ont pas pu être lues : cette liste ne dit donc rien de - ce que ce poste garde. Ce n’est pas « aucune version ».

                                  `),tc=c('

                                  Aucune version enregistrée : ce poste n’a jamais été reconfiguré.

                                  '),ac=c('
                                • '),rc=c(`

                                    Remettre une version en service remplace la configuration du poste sur-le-champ, et - ce qui n’a pas été enregistré est perdu : c’est le seul geste de cette page qui - change le poste tout de suite, et le seul qui garde ses 72 px.

                                    `,1),nc=c('
                                    ');function sc(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=40,d=5;let p=B(vt([])),y=B("reading"),z=B(null),m=B(""),j=B(null),S=B(""),A=B(vt([])),L=B(""),W="";const O=u(()=>r().busy||e(L)!==""),C=u(()=>Iu(e(z),e(j))),K=u(()=>e(z)!==null&&e(j)!==null),U=u(()=>Du(e(z),e(j))),E=u(()=>e(C).slice(0,i)),x=u(()=>e(p).slice(0,d)),g=u(()=>ca(e(E).length,e(C).length,"champ qui change","champs qui changent")),D=u(()=>ca(e(x).length,e(p).length,"version enregistrée","versions enregistrées")),H=u(()=>e(C).length>1?`Recopier ces ${se(e(C).length)} champs dans le brouillon`:"Recopier ce champ dans le brouillon");V(),wt(()=>{const b=t.health.config_fingerprint;b!==W&&(W=b,$())});async function V(){const b=await r().load(_n);if(b===null){v(y,"unreadable");return}v(p,b,!0),v(y,"read")}async function $(){try{v(z,(await za()).config,!0),v(m,"")}catch(b){v(z,null),v(m,b instanceof Error?b.message:"Le poste n’a pas répondu.",!0)}}async function T(b){v(L,b?"export-all":"export-clone",!0),r().notice="",r().actionError="";try{const N=await r().protect(()=>Uu(t.health.station,b));if(N===null)return;Vu(N.name,N.blob),r().notice=`${N.name} est remis au navigateur : c’est lui qui l’enregistre, voyez ses téléchargements.`}finally{v(L,"")}}async function w(b){if(b==null)return;r().notice="",r().actionError="";let N;try{N=JSON.parse(await b.text())}catch{r().actionError=`${b.name} n’est pas un fichier JSON lisible.`;return}v(L,"import");try{const X=await r().protect(()=>Mu(N));if(X===null)return;v(j,X.config,!0),v(S,b.name,!0),v(A,X.faults,!0),await $(),r().notice=k(b.name)}finally{v(L,"")}}function k(b){return e(K)?e(C).length===0?`${b} décrit la même configuration que celle en service.`:`${b} est lu. Rien n’est appliqué : relisez le tableau.`:`${b} est lu, mais la configuration en service ne l’est pas : rien ne peut être comparé.`}async function R(){if(e(j)===null)return;const b=e(j),N=[...e(C)];for(const X of N)t.draft.set(X.path,ga(b,X.path));r().actionError="",r().notice="Le fichier est recopié dans le brouillon. Rien n’est appliqué avant « Enregistrer ».",await $()}async function F(b){v(L,`restore-${String(b)}`),r().notice="",r().actionError="";try{if(await r().protect(()=>bn(b))===null)return;await t.draft.load(),await V(),await $(),await r().refresh(),r().notice=`La version ${String(b)} est remise en service.`}finally{v(L,"")}}var h=nc(),G=s(h);Oe(G,{title:"Identité du poste",children:(b,N)=>{var X=Gu(),de=ae(X);{let ue=u(()=>t.draft.text("station.number")),ie=u(()=>$e(t.draft,"station.number")),Ne=u(()=>Qt(t.draft,"station.number"));at(de,{label:"Numéro du poste",path:"station.number",kind:"number",get value(){return e(ue)},get fault(){return e(ie)},get allowed(){return e(Ne)},hint:"C’est de lui que dérive le nom du fichier de catalogue attendu, flv_.csv.",onchange:je=>t.draft.set("station.number",Number(je))})}var he=n(de,2);{let ue=u(()=>t.draft.text("station.name")),ie=u(()=>$e(t.draft,"station.name")),Ne=u(()=>Qt(t.draft,"station.name"));at(he,{label:"Nom du poste",path:"station.name",get value(){return e(ue)},get fault(){return e(ie)},get allowed(){return e(Ne)},hint:"Ce que lit un bénévole : « Poste 2 — fruits ».",onchange:je=>t.draft.set("station.name",je)})}var fe=n(he,2);{let ue=u(()=>t.draft.text("station.coop")),ie=u(()=>$e(t.draft,"station.coop")),Ne=u(()=>Qt(t.draft,"station.coop"));at(fe,{label:"Coopérative",path:"station.coop",get value(){return e(ue)},get fault(){return e(ie)},get allowed(){return e(Ne)},onchange:je=>t.draft.set("station.coop",je)})}var oe=n(fe,2),I=n(s(oe),2),Q=s(I),re=n(I,4),ee=s(re),ve=n(re,4),ye=s(ve),Ie=n(ve,4),q=s(Ie);{var le=ue=>{var ie=lt("non mesuré");l(ue,ie)},ge=ue=>{var ie=lt();f((Ne,je,ke)=>o(ie,`${Ne??""} libres sur - ${je??""} — seuil d’alerte - ${ke??""} Mo`),[()=>Sa(t.health.disk.free_bytes),()=>Sa(t.health.disk.total_bytes),()=>se(t.health.disk.alert_mb)]),l(ue,ie)};_(q,ue=>{t.health.disk===null?ue(le):ue(ge,-1)})}f(()=>{o(Q,t.health.config_fingerprint),o(ee,t.health.version),o(ye,t.health.disk===null?"non publié par ce poste":t.health.disk.path)}),l(b,X)},$$slots:{default:!0}});var Y=n(G,2);Oe(Y,{title:"Exporter, importer",note:"Pour installer un autre poste : ce fichier emporte les tarifs, les garde-fous, l’étiquette, les catégories, et les réglages du matériel que les quatre postes partagent — le décalage d’étiquette, le noircissement, la vitesse, le débit de la balance. Reste ici ce qui désigne ce poste-ci ou ce magasin — le mot de passe, le code de secours, le numéro et le nom du poste, le port de la balance, la file d’impression, l’adresse du partage et son compte, le chemin des images et le réseau.",children:(b,N)=>{var X=Qu(),de=ae(X),he=s(de);{let q=u(()=>e(L)==="export-all");Te(he,{label:"Exporter tout",protected:!0,get busy(){return e(q)},get disabled(){return e(O)},onrun:()=>{T(!0)}})}var fe=n(he,2);{let q=u(()=>e(L)==="export-clone");Te(fe,{label:"Exporter sans le matériel",protected:!0,get busy(){return e(q)},get disabled(){return e(O)},onrun:()=>{T(!1)}})}var oe=n(fe,2);let I;var Q=s(oe),re=n(Q,3),ee=n(de,4);{var ve=q=>{var le=Hu(),ge=s(le);f(()=>o(ge,`La configuration en service n’a pas pu être lue : ${e(m)??""} La colonne - « En service » ne peut donc rien affirmer.`)),l(q,le)};_(ee,q=>{e(m)!==""&&q(ve)})}var ye=n(ee,2);{var Ie=q=>{var le=Xu(),ge=ae(le),ue=s(ge),ie=n(ge,2);{var Ne=xe=>{xu(xe,{get faults(){return e(A)}})};_(ie,xe=>{e(A).length>0&&xe(Ne)})}var je=n(ie,2);{var ke=xe=>{var De=Ju();l(xe,De)},Re=xe=>{var De=Ku();l(xe,De)},We=xe=>{var De=Yu(),rt=ae(De),nt=s(rt),pt=n(nt);{var ct=Se=>{var st=lt("Les autres sont dans le fichier, et « Recopier » les prend tous.");l(Se,st)};_(pt,Se=>{e(C).length>e(E).length&&Se(ct)})}var dt=n(rt,2);hu(dt,{get rows(){return e(E)}});var ft=n(dt,2);{var bt=Se=>{var st=$u(),Dt=s(st);f(zt=>o(Dt,`Ce fichier ne porte pas ${zt??""} : - l’export sans le matériel les retire, et l’import ne remet que le numéro du - poste. Les lignes correspondantes sont VIDES ci-dessus, et - « Recopier » recopie ce vide dans le brouillon.`),[()=>Fu(e(U).map(zt=>zt.name))]),l(Se,st)};_(ft,Se=>{e(U).length>0&&Se(bt)})}var et=n(ft,2),_e=s(et);Te(_e,{kind:"write",get label(){return e(H)},get disabled(){return e(O)},onrun:()=>{R()}}),f(()=>o(nt,`${e(g)??""} `)),l(xe,De)};_(je,xe=>{e(K)?e(C).length===0?xe(Re,1):xe(We,-1):xe(ke)})}f(()=>o(ue,`Fichier lu : ${e(S)??""}`)),l(q,le)};_(ye,q=>{e(j)!==null&&q(Ie)})}f(()=>{I=Nt(oe,1,"choose svelte-18wbxwu",null,I,{working:e(L)==="import",off:e(O)}),o(Q,`${e(L)==="import"?"Lecture du fichier…":"Importer un fichier"} `),re.disabled=e(O)}),Ce("change",re,q=>{w(q.currentTarget.files?.item(0))}),l(b,X)},$$slots:{default:!0}});var ne=n(Y,2);Oe(ne,{title:"Cinq versions restaurables",note:"Chaque enregistrement fait tourner les versions : la plus récente est la 1.",children:(b,N)=>{var X=Tt(),de=ae(X);{var he=Q=>{var re=Zu();l(Q,re)},fe=Q=>{var re=ec();l(Q,re)},oe=Q=>{var re=tc();l(Q,re)},I=Q=>{var re=rc(),ee=ae(re),ve=s(ee),ye=n(ee,2),Ie=s(ye);ze(Ie,21,()=>e(x),q=>q.version,(q,le)=>{var ge=ac(),ue=s(ge),ie=s(ue),Ne=n(ue,2),je=s(Ne),ke=n(Ne,2),Re=s(ke),We=n(ke,2);{let xe=u(()=>e(L)===`restore-${String(e(le).version)}`);Te(We,{kind:"destructive",label:"Remettre cette version en service",protected:!0,get busy(){return e(xe)},get disabled(){return e(O)},onrun:()=>{F(e(le).version)}})}f((xe,De)=>{o(ie,`version ${xe??""}`),o(je,De),o(Re,e(le).config_fingerprint)},[()=>se(e(le).version),()=>Lt(e(le).modified_at)]),l(q,ge)}),f(()=>o(ve,`${e(D)??""} Le poste n’en garde jamais plus de cinq.`)),l(Q,re)};_(de,Q=>{e(y)==="reading"?Q(he):e(y)==="unreadable"?Q(fe,1):e(p).length===0?Q(oe,2):Q(I,-1)})}l(b,X)},$$slots:{default:!0}});var M=n(ne,2);Ru(M,{get admin(){return r()}}),l(a,h),Me()}ot(["change"]);var lc=c('clé'),ic=c(' '),oc=c('');function Bt(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"hint",3,""),d=Be(t,"disabled",3,!1),p=Be(t,"engaged",3,!1),y=Be(t,"busy",3,!1),z=Be(t,"protected",3,!1);var m=oc();let j;var S=s(m),A=s(S),L=n(A);{var W=K=>{var U=lc();l(K,U)};_(L,K=>{z()&&K(W)})}var O=n(S,2);{var C=K=>{var U=ic(),E=s(U);f(()=>o(E,y()?"En cours…":i())),l(K,U)};_(O,K=>{i()!==""&&K(C)})}f(()=>{j=Nt(m,1,`big touch-target ${r()??""}`,"svelte-15nlt6i",j,{engaged:p(),busy:y()}),be(m,"data-kind",r()),m.disabled=d()||y(),o(A,`${t.label??""} `)}),Ce("click",m,function(...K){t.onrun?.apply(this,K)}),l(a,m)}ot(["click"]);const uc="ERR-SCL-09";var cc=c('

                                    '),dc=c('

                                    '),vc=c('

                                    '),pc=c('

                                    Déposez ici le fichier clé

                                    '),fc=c('

                                    ',1),gc=c('');function mc(a,t){Ue(t,!0);const r=Be(t,"admin",7);let i=B("");const d=u(()=>t.health.state.degraded?.code===uc),p=u(()=>t.health.printing),y=u(()=>e(p)?.on_fallback===!0);let z=B(!1),m=B("");const j=u(()=>r().busy||e(m)!==""),S=new kr;wt(()=>{S.observe(t.health)});const A=u(()=>t.health.catalog_source===null?"Ce poste ne publie pas la source de son catalogue.":"Catalogue surveillé : "+t.health.catalog_source.label),L=u(()=>t.health.catalog!==null&&(t.health.catalog.result==="rejected"||t.health.catalog.result==="failed")?t.health.catalog:null);function W(I){v(i,""),S.forget(),v(m,I,!0)}async function O(I,Q){W(I);try{await r().run(Q)}finally{v(m,"")}}async function C(){W("reload");try{const I=await r().run(en);I!==null&&S.begin(I)}finally{v(m,"")}}async function K(I,Q){W(I);try{const re=await r().load(Q);re!==null&&v(i,re.message,!0)}finally{v(m,"")}}async function U(I,Q){W(I);try{const re=await r().protect(Q);re!==null&&(r().notice=re.message,await r().refresh())}finally{v(m,"")}}async function E(I){if(v(z,!1),I!=null){W("import");try{const Q=await r().protect(()=>pr(I));if(Q===null)return;v(i,Q.result==="rejected"||Q.result==="failed"?`${I.name} : REFUSÉ${Q.reason===""?"":" — "+Q.reason}. Le catalogue en service n’a pas changé.`:`${I.name} : ${String(Q.rows_read_count)} lignes lues, ${String(Q.weighable_count)} pesables. La veille l’appliquera dans la seconde.`,!0),await r().refresh()}finally{v(m,"")}}}var x=gc(),g=s(x);{var D=I=>{var Q=cc(),re=s(Q);f(()=>o(re,e(i))),l(I,Q)};_(g,I=>{e(i)!==""&&I(D)})}var H=n(g,2);{var V=I=>{var Q=dc(),re=s(Q);f(()=>o(re,S.sentence)),l(I,Q)};_(H,I=>{S.sentence!==""&&I(V)})}var $=n(H,2);{var T=I=>{var Q=vc(),re=s(Q);f(()=>o(re,e(p).banner)),l(I,Q)};_($,I=>{e(p)!==null&&e(p).banner!==""&&I(T)})}var w=n($,2),k=s(w),R=n(w,2),F=s(R);{let I=u(()=>e(m)==="scale");Bt(F,{label:"Tester la balance",hint:"Ce que le poste a déjà observé — le port n’est pas rouvert.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{K("scale",nn)}})}var h=n(F,2);{let I=u(()=>e(m)==="printer");Bt(h,{label:"Tester l’imprimante",hint:"Ce que le superviseur a vu il y a moins d’une seconde.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{K("printer",sn)}})}var G=n(h,2);{let I=u(()=>e(m)==="label");Bt(G,{label:"Imprimer une étiquette de test",hint:"Une étiquette de démonstration sort de l’imprimante du poste.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{O("label",ln)}})}var Y=n(G,2);{let I=u(()=>e(m)==="reprint");Bt(Y,{label:"Réimprimer la dernière",hint:"La dernière étiquette imprimée sort une seconde fois.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{O("reprint",Zr)}})}var ne=n(Y,2);{let I=u(()=>e(m)==="reload");Bt(ne,{label:"Recharger le catalogue",kind:"write",hint:"La veille refait tout de suite le contrôle qu’elle fait toutes les cinq secondes.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{C()}})}var M=n(ne,2);{let I=u(()=>e(d)?"Revenir à la balance":"Basculer en saisie manuelle"),Q=u(()=>e(d)?"Le poids sera de nouveau lu sur la balance.":"Le poids se tape à la main : le poste continue de servir sans balance."),re=u(()=>e(m)==="manual");Bt(M,{get label(){return e(I)},kind:"destructive",get hint(){return e(Q)},get engaged(){return e(d)},protected:!0,get busy(){return e(re)},get disabled(){return e(j)},onrun:()=>{U("manual",()=>tn(!e(d)))}})}var b=n(M,2);{let I=u(()=>e(m)==="roll");Bt(b,{label:"J’ai changé le rouleau",kind:"write",hint:"Le compteur d’étiquettes repart à zéro. C’est le seul geste qui dise quelque chose de vrai du papier.",get busy(){return e(I)},get disabled(){return e(j)},onrun:()=>{O("roll",an)}})}var N=n(b,2);{var X=I=>{{let Q=u(()=>e(y)?"Revenir à l’imprimante du poste":"Imprimer sur l’imprimante du poste voisin"),re=u(()=>e(y)?"Les étiquettes repartiront sur l’imprimante de ce poste.":"Les étiquettes sortiront sur l’imprimante voisine, pour cette session seulement."),ee=u(()=>e(m)==="fallback");Bt(I,{get label(){return e(Q)},kind:"write",get hint(){return e(re)},get engaged(){return e(y)},get busy(){return e(ee)},get disabled(){return e(j)},onrun:()=>{O("fallback",()=>rn(!e(y)))}})}};_(N,I=>{e(p)!==null&&e(p).fallback_available&&I(X)})}var de=n(N,2),he=n(R,2);Oe(he,{title:"Importer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(I,Q)=>{var re=pc();let ee;var ve=s(re),ye=n(s(ve)),Ie=s(ye),q=n(ve,2),le=n(s(q));f(()=>{ee=Nt(re,1,"drop svelte-1yd0nzg",null,ee,{dropping:e(z)}),o(Ie,`flv_${t.health.station??""}.csv`)}),Ht("dragover",re,ge=>{ge.preventDefault(),v(z,!0)}),Ht("dragleave",re,()=>v(z,!1)),Ht("drop",re,ge=>{ge.preventDefault(),E(ge.dataTransfer?.files.item(0))}),Ce("change",le,ge=>{E(ge.currentTarget.files?.item(0))}),l(I,re)},$$slots:{default:!0}});var fe=n(he,2);{var oe=I=>{Oe(I,{title:"Le dernier fichier n’a pas pris service",children:(Q,re)=>{var ee=fc(),ve=ae(ee),ye=s(ve),Ie=n(ve,2),q=s(Ie);f((le,ge)=>{o(ye,`${le??""} — - ${(e(L).reason===""?"aucun motif enregistré.":e(L).reason)??""} - Le catalogue en service n’a pas changé.`),o(q,`Dernier essai : ${ge??""}.`)},[()=>ha(e(L).result),()=>Lt(e(L).occurred_at)]),l(Q,ee)},$$slots:{default:!0}})};_(fe,I=>{e(L)!==null&&I(oe)})}f(()=>{o(k,e(A)),be(de,"href",on)}),l(a,x),Me()}ot(["change"]);const hc={succeeded:"La dernière mise à jour a réussi.","rolled-back":"La dernière mise à jour a échoué. La version précédente a été remise et le poste fonctionne.","rolled-back-unhealthy":"La dernière mise à jour a échoué et le poste n’a pas redémarré. Appelez le support.","not-started":"La dernière mise à jour n’a pas démarré : rien n’a été remplacé. Vous pouvez réessayer."},_c=2e3,bc=300*1e3;var wc=c('

                                    '),ir=c('

                                    '),yc=c("
                                    Dernière vérification
                                    ",1),xc=c(`

                                    La mise à jour depuis cet écran n’existe que sur les postes Windows. Sur les - autres, elle se fait à la main — voir la notice d’installation.

                                    `),kc=c('Voir les nouveautés'),qc=c('

                                    Version disponible : .

                                    '),Sc=c('

                                    Ce poste est à jour.

                                    '),Lc=c("
                                    Version installée
                                    Dépôt suivi
                                    ",1),Ec=c(`

                                    Le poste va s’arrêter environ une minute. L’écran client s’éteindra puis reviendra - tout seul. Si la nouvelle version ne démarre pas, la précédente sera remise - automatiquement — mais les données enregistrées, elles, ne reviendront pas en - arrière.

                                    `),Cc=c('

                                    Mise à jour en cours. Le poste redémarre, ne le débranchez pas.

                                    '),Pc=c('
                                    ',1),Tc=c("
                                    Terminée le
                                    ",1),zc=c("
                                    Raison
                                    ",1),jc=c('

                                    Depuis
                                    Vers
                                    ',1),Rc=c(" ",1);function Oc(a,t){Ue(t,!0);let r=B(null),i=B(""),d=B(""),p=B(!1),y=B("");const z=u(()=>e(r)?.outcome??null),m=u(()=>e(r)?.supported===!0&&e(r).available),j=u(()=>e(r)?.latest??"");wt(()=>{S()});async function S(){try{v(r,await On(),!0),v(i,"")}catch(g){v(i,g instanceof Error?g.message:"Lecture impossible.",!0)}}async function A(){v(d,"check");try{const g=await t.admin.protect(()=>An());g!==null&&(v(r,g,!0),v(i,""))}finally{v(d,"")}}async function L(g){v(d,"apply"),v(y,"");try{if(await t.admin.protect(()=>Nn(g))===null)return;v(p,!0),v(y,await W()?"":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0),await S()}finally{v(d,""),v(p,!1)}}async function W(){const g=Date.now()+bc;for(;Date.now()setTimeout(D,_c));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var O=Rc(),C=ae(O);Oe(C,{title:"Version de ce poste",children:(g,D)=>{var H=Tt(),V=ae(H);{var $=w=>{var k=wc(),R=s(k);f(()=>o(R,e(i)===""?"Lecture…":e(i))),l(w,k)},T=w=>{var k=Lc(),R=ae(k);{var F=oe=>{var I=ir(),Q=s(I);f(()=>o(Q,e(i))),l(oe,I)};_(R,oe=>{e(i)!==""&&oe(F)})}var h=n(R,2),G=n(s(h),2),Y=s(G),ne=n(G,4),M=s(ne),b=n(ne,2);{var N=oe=>{var I=yc(),Q=n(ae(I),2),re=s(Q);f(ee=>o(re,ee),[()=>Lt(e(r).checked_at)]),l(oe,I)};_(b,oe=>{e(r).checked_at!==""&&oe(N)})}var X=n(h,2);{var de=oe=>{var I=xc();l(oe,I)},he=oe=>{var I=qc(),Q=n(s(I)),re=s(Q),ee=n(Q,2);{var ve=q=>{var le=lt();f(ge=>o(le,`, publiée le ${ge??""}`),[()=>Lt(e(r).published_at)]),l(q,le)};_(ee,q=>{e(r).published_at!==""&&q(ve)})}var ye=n(ee,2);{var Ie=q=>{var le=kc();f(()=>be(le,"href",e(r).html_url)),l(q,le)};_(ye,q=>{e(r).html_url!==""&&q(Ie)})}f(()=>o(re,e(r).latest)),l(oe,I)},fe=oe=>{var I=Sc();l(oe,I)};_(X,oe=>{e(r).supported?e(r).available?oe(he,1):oe(fe,-1):oe(de)})}f(()=>{o(Y,e(r).running),o(M,e(r).repository)}),l(w,k)};_(V,w=>{e(r)===null?w($):w(T,-1)})}l(g,H)},$$slots:{default:!0}});var K=n(C,2);{var U=g=>{Oe(g,{title:"Installer",children:(D,H)=>{var V=Pc(),$=ae(V),T=s($);{let M=u(()=>e(d)==="check");Te(T,{kind:"read",label:"Vérifier maintenant",protected:!0,act:"check",get busy(){return e(M)},get disabled(){return e(p)},onrun:()=>{A()}})}var w=n(T,2);{var k=M=>{{let b=u(()=>`Installer la version ${e(j)}`),N=u(()=>e(d)==="apply");Te(M,{kind:"destructive",get label(){return e(b)},protected:!0,act:"apply",get busy(){return e(N)},onrun:()=>{L(e(j))}})}};_(w,M=>{e(m)&&M(k)})}var R=n($,2);{var F=M=>{var b=Ec();l(M,b)};_(R,M=>{e(m)&&M(F)})}var h=n(R,2);{var G=M=>{var b=Cc();l(M,b)};_(h,M=>{e(p)&&M(G)})}var Y=n(h,2);{var ne=M=>{var b=ir(),N=s(b);f(()=>o(N,e(y))),l(M,b)};_(Y,M=>{e(y)!==""&&M(ne)})}l(D,V)},$$slots:{default:!0}})};_(K,g=>{e(r)!==null&&e(r).supported&&g(U)})}var E=n(K,2);{var x=g=>{Oe(g,{title:"Dernière tentative",children:(D,H)=>{var V=jc(),$=ae(V),T=s($),w=n($,2),k=n(s(w),2),R=s(k),F=n(k,4),h=s(F),G=n(F,2);{var Y=b=>{var N=Tc(),X=n(ae(N),2),de=s(X);f(he=>o(de,he),[()=>Lt(e(z).finished_at)]),l(b,N)};_(G,b=>{e(z).finished_at!==""&&b(Y)})}var ne=n(G,2);{var M=b=>{var N=zc(),X=n(ae(N),2),de=s(X);f(()=>o(de,e(z).reason)),l(b,N)};_(ne,b=>{e(z).reason!==""&&b(M)})}f(()=>{be($,"data-outcome",e(z).status),o(T,hc[e(z).status]),o(R,e(z).from===""?"—":e(z).from),o(h,e(z).to===""?"—":e(z).to)}),l(D,V)},$$slots:{default:!0}})};_(E,g=>{e(z)!==null&&g(x)})}l(a,O),Me()}var Ac=c(''),Nc=c('

                                    ',1),Ic=c('

                                    ',1),Dc=c(''),Fc=c(''),Uc=c(''),Mc=c(''),Wc=c(' '),Bc=c(''),Vc=c('

                                    Lecture de l’état du poste…

                                    '),Gc=c('

                                    '),Hc=c(" "),Jc=c("
                                  • "),Kc=c('
                                      '),$c=c('
                                      '),Yc=c('

                                      ');function Xc(a,t){Ue(t,!0);const r=new $n,i=new In(r),d=[{title:"Au quotidien",pages:[{id:"dashboard",label:"Tableau de bord"},{id:"troubleshooting",label:"Dépannage"}]},{title:"Réglages",pages:[{id:"hardware",label:"Matériel"},{id:"label",label:"Étiquette"},{id:"rules",label:"Règles"},{id:"catalog",label:"Catalogue"},{id:"journal",label:"Journal"},{id:"station",label:"Poste"},{id:"update",label:"Mise à jour"}]}],p=u(()=>d.flatMap(q=>q.pages).find(q=>q.id===r.page)?.label??""),y=u(()=>r.health===null||r.health.station_name===""?`Poste ${String(r.health?.station??"")}`:r.health.station_name),z=u(()=>(i.pending?.changed_blocks??[]).map(q=>Mn(q)).join(", ")),m=u(()=>(i.pending?.changed_blocks??[]).join(", "));Ta(()=>(r.start(),()=>r.stop()));async function j(q){r.open(q),Wa(q)&&i.config===null&&await i.load()}async function S(){await r.protect(()=>i.save())===!0&&(r.notice="La configuration est enregistrée et appliquée.")}var A=Yc(),L=s(A),W=n(s(L),2);ze(W,17,()=>d,q=>q.title,(q,le)=>{var ge=Nc(),ue=ae(ge),ie=s(ue),Ne=n(ue,2);ze(Ne,17,()=>e(le).pages,je=>je.id,(je,ke)=>{var Re=Ac();let We;var xe=s(Re);f(()=>{We=Nt(Re,1,"entry svelte-9b2mjq",null,We,{current:r.page===e(ke).id}),be(Re,"aria-current",r.page===e(ke).id?"page":void 0),o(xe,e(ke).label)}),Ce("click",Re,()=>{j(e(ke).id)}),l(je,Re)}),f(()=>o(ie,e(le).title)),l(q,ge)});var O=n(W,2),C=s(O);{var K=q=>{var le=Ic(),ge=ae(le),ue=s(ge),ie=n(ge,2),Ne=s(ie),je=n(ie,2),ke=s(je);f(()=>{o(ue,e(y)),o(Ne,`${r.health.coop??""} · version ${r.health.version??""}`),o(ke,`configuration ${r.health.config_fingerprint??""}`)}),l(q,le)};_(C,q=>{r.health!==null&&q(K)})}var U=n(C,2),E=s(U),x=n(U,2);{var g=q=>{var le=Dc();Ce("click",le,function(...ge){t.onclose?.apply(this,ge)}),l(q,le)};_(x,q=>{t.onclose!==void 0&&q(g)})}var D=n(L,2),H=s(D);{var V=q=>{var le=Fc(),ge=s(le);f(()=>o(ge,r.notice)),l(q,le)};_(H,q=>{r.notice!==""&&q(V)})}var $=n(H,2);{var T=q=>{var le=Uc(),ge=s(le);f(()=>o(ge,r.linkError)),l(q,le)};_($,q=>{r.linkError!==""&&q(T)})}var w=n($,2);{var k=q=>{var le=Mc(),ge=s(le);f(()=>o(ge,r.actionError)),l(q,le)};_(w,q=>{r.actionError!==""&&q(k)})}var R=n(w,2);{var F=q=>{var le=Bc(),ge=s(le),ue=s(ge),ie=n(ue);{var Ne=Re=>{var We=Wc(),xe=s(We);f(()=>o(xe,e(m))),l(Re,We)};_(ie,Re=>{St.showTechnicalNames&&Re(Ne)})}var je=n(ie),ke=n(ge,2);Te(ke,{kind:"write",label:"Tout fonctionne : confirmer",onrun:()=>{r.protect(()=>i.confirm())}}),f(()=>{o(ue,`Configuration appliquée mais NON CONFIRMÉE. Ce qui a changé : ${e(z)??""}. `),o(je,` Le poste reviendra tout seul à la version précédente dans - ${i.pending.seconds_left??""} secondes si personne ne confirme.`)}),l(q,le)};_(R,q=>{i.pending!==null&&q(F)})}var h=n(R,2),G=s(h),Y=s(G),ne=n(G,2);{var M=q=>{var le=Vc();l(q,le)},b=q=>{pi(q,{get health(){return r.health},onshowrows:()=>{j("catalog")},onshowupdate:()=>{j("update")}})},N=q=>{mc(q,{get admin(){return r},get health(){return r.health}})},X=q=>{Ji(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},de=q=>{zo(q,{get admin(){return r},get draft(){return i}})},he=q=>{pu(q,{get draft(){return i},get health(){return r.health}})},fe=q=>{El(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},oe=q=>{bo(q,{get admin(){return r}})},I=q=>{sc(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},Q=q=>{Oc(q,{get admin(){return r}})};_(ne,q=>{r.health===null?q(M):r.page==="dashboard"?q(b,1):r.page==="troubleshooting"?q(N,2):r.page==="hardware"?q(X,3):r.page==="label"?q(de,4):r.page==="rules"?q(he,5):r.page==="catalog"?q(fe,6):r.page==="journal"?q(oe,7):r.page==="station"?q(I,8):r.page==="update"&&q(Q,9)})}var re=n(h,2);{var ee=q=>{var le=$c(),ge=s(le);{var ue=ke=>{var Re=Gc(),We=s(Re),xe=n(We);ze(xe,16,()=>i.retired,De=>De,(De,rt)=>{{let nt=u(()=>`retirer ${rt}`);Te(De,{kind:"write",get label(){return e(nt)},onrun:()=>i.dropRetired(rt)})}}),f(De=>o(We,`Ce fichier porte des réglages que cette version du poste ne connaît plus : - ${De??""}. `),[()=>i.retired.map(De=>qt(De)).join(", ")]),l(ke,Re)};_(ge,ke=>{i.retired.length>0&&ke(ue)})}var ie=n(ge,2);{var Ne=ke=>{var Re=Kc();ze(Re,21,()=>i.faults,We=>We.field,(We,xe)=>{var De=Jc(),rt=s(De),nt=s(rt),pt=n(rt,2);{var ct=et=>{var _e=Hc(),Se=s(_e);f(()=>o(Se,e(xe).field)),l(et,_e)};_(pt,et=>{St.showTechnicalNames&&et(ct)})}var dt=n(pt),ft=n(dt);{var bt=et=>{var _e=lt();f(Se=>o(_e,`— valeurs acceptées : ${Se??""}`),[()=>e(xe).allowed.join(", ")]),l(et,_e)};_(ft,et=>{e(xe).allowed!==void 0&&e(xe).allowed.length>0&&et(bt)})}f(et=>{o(nt,et),o(dt,` ${e(xe).message??""} `)},[()=>qt(e(xe).field)]),l(We,De)}),l(ke,Re)};_(ie,ke=>{i.faults.length>0&&ke(Ne)})}var je=n(ie,2);{let ke=u(()=>i.dirty?"Enregistrer la configuration":"Aucune modification à enregistrer"),Re=u(()=>!i.dirty||r.busy);Te(je,{kind:"write",get label(){return e(ke)},get disabled(){return e(Re)},onrun:()=>{S()}})}l(q,le)},ve=u(()=>Wa(r.page)&&i.config!==null);_(re,q=>{e(ve)&&q(ee)})}var ye=n(D,2);{var Ie=q=>{as(q,{get admin(){return r}})};_(ye,q=>{r.pending!==null&&q(Ie)})}f(()=>{ea(E,St.showTechnicalNames),o(Y,e(p))}),Ce("change",E,()=>St.toggleTechnicalNames()),l(a,A),Me()}ot(["click","change"]);let oa=null;function ed(a){if(oa!==null||a.querySelector("[data-admin]")!==null)return;const t=document.createElement("div");a.appendChild(t),oa={component:Hr(Xc,{target:t,props:{onclose:Qc}}),host:t}}function Qc(){if(oa===null)return;const{component:a,host:t}=oa;oa=null,Jr(a),t.remove()}export{Qc as closeAdmin,ed as mountAdmin}; diff --git a/internal/web/dist/assets/mount-DcOlpQNH.css b/internal/web/dist/assets/mount-DcOlpQNH.css new file mode 100644 index 0000000..96e1be1 --- /dev/null +++ b/internal/web/dist/assets/mount-DcOlpQNH.css @@ -0,0 +1 @@ +.act.svelte-5tpq0o{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.read.svelte-5tpq0o{color:var(--ink);background:var(--surface);border:1px solid var(--border)}.write.svelte-5tpq0o{color:var(--surface);background:var(--action);border:1px solid var(--action)}.destructive.svelte-5tpq0o{min-height:var(--touch-min);color:var(--surface);background:var(--danger);border:1px solid var(--danger)}@media(hover:hover){.read.svelte-5tpq0o:hover:not(:disabled){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}.write.svelte-5tpq0o:hover:not(:disabled){border-color:var(--action)}.destructive.svelte-5tpq0o:hover:not(:disabled){border-color:var(--danger)}.write.svelte-5tpq0o:hover:not(:disabled),.destructive.svelte-5tpq0o:hover:not(:disabled){box-shadow:var(--shadow-2);filter:brightness(.92)}}.act.svelte-5tpq0o:disabled{opacity:.5;box-shadow:none;cursor:default}.act.busy.svelte-5tpq0o:disabled{opacity:1}.key.svelte-5tpq0o{padding:.0625rem .375rem;border-radius:var(--radius-pill);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;background:var(--bg);color:var(--ink-muted)}.write.svelte-5tpq0o .key:where(.svelte-5tpq0o),.destructive.svelte-5tpq0o .key:where(.svelte-5tpq0o){background:var(--surface);color:var(--ink)}.scrim.svelte-zyka3t{position:fixed;inset:0;z-index:95;display:flex;align-items:center;justify-content:center;padding:2rem;background:#1c1b1973}.panel.svelte-zyka3t{display:flex;flex-direction:column;gap:.75rem;width:min(34rem,100%);padding:1.75rem;background:var(--surface);border-radius:var(--radius-lg);box-shadow:var(--shadow-2)}header.svelte-zyka3t{display:flex;align-items:center;gap:.75rem}.glyph.svelte-zyka3t{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;flex:none;border-radius:var(--radius-sm);background:var(--bg);color:var(--ink-muted)}h2.svelte-zyka3t{margin:0;font-size:1.375rem;line-height:1.2}.why.svelte-zyka3t,.how.svelte-zyka3t{margin:0;color:var(--ink-muted);font-size:1rem;line-height:1.4}.field.svelte-zyka3t{display:flex;flex-direction:column;gap:.375rem;font-size:1rem;color:var(--ink-muted)}input.svelte-zyka3t{height:2.75rem;padding:0 .75rem;font:inherit;font-size:1.125rem;color:var(--ink);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)}input.svelte-zyka3t:focus-visible{outline:3px solid var(--focus);outline-offset:1px}.refusal.svelte-zyka3t{margin:0;padding:.625rem .75rem;border-left:.25rem solid var(--fault);background:var(--fault-wash);font-size:1rem}footer.svelte-zyka3t{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.25rem}.field.svelte-1oilv5s{display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0}label.svelte-1oilv5s{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem}.name.svelte-1oilv5s{font-size:1.125rem;font-weight:700}code.svelte-1oilv5s{font-size:.9375rem;color:var(--ink-muted)}input.svelte-1oilv5s,select.svelte-1oilv5s{min-height:3rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.refused.svelte-1oilv5s input:where(.svelte-1oilv5s),.refused.svelte-1oilv5s select:where(.svelte-1oilv5s){border-left:.5rem solid var(--fault)}.hint.svelte-1oilv5s,.fault.svelte-1oilv5s{margin:0;font-size:1rem;color:var(--ink-muted)}.fault.svelte-1oilv5s{padding-left:.5rem;border-left:.25rem solid var(--fault)}.panel.svelte-ww3z5u{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}h2.svelte-ww3z5u{margin:0 0 .5rem;font-size:1.5rem;font-weight:700}.note.svelte-ww3z5u{margin:0 0 .75rem;font-size:1rem;color:var(--ink-muted)}.fact.svelte-stwcs8{margin:.5rem 0;font-size:1.125rem}.muted.svelte-stwcs8{color:var(--ink-muted);font-size:1rem}.choice.svelte-stwcs8{display:flex;flex-direction:column}label.svelte-stwcs8{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}.choice.svelte-stwcs8 label:where(.svelte-stwcs8){display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin:0;font-weight:400}input.svelte-stwcs8{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.choice.svelte-stwcs8 input:where(.svelte-stwcs8){width:1.5rem;height:1.5rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0}.fact.svelte-xldam3{margin:.5rem 0;font-size:1.125rem}.muted.svelte-xldam3{color:var(--ink-muted);font-size:1rem}.scroll.svelte-xldam3{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-xldam3{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-xldam3 li:where(.svelte-xldam3){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-xldam3 li:where(.svelte-xldam3):first-child{border-top:none}.line.svelte-xldam3,.id.svelte-xldam3,.value.svelte-xldam3{color:var(--ink-muted)}.line.svelte-xldam3{flex:none;width:7rem}.what.svelte-xldam3{font-weight:700}.message.svelte-xldam3{flex:1 1 20rem}.pick.svelte-xldam3{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-xldam3:hover:not(:disabled){background:var(--surface)}}.fact.svelte-140iwqw{margin:.5rem 0;font-size:1.125rem}.muted.svelte-140iwqw{color:var(--ink-muted);font-size:1rem}.scroll.svelte-140iwqw{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-140iwqw{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-140iwqw li:where(.svelte-140iwqw){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-140iwqw li:where(.svelte-140iwqw):first-child{border-top:none}.line.svelte-140iwqw,.id.svelte-140iwqw,.value.svelte-140iwqw{color:var(--ink-muted)}.line.svelte-140iwqw{flex:none;width:7rem}.what.svelte-140iwqw{font-weight:700}.message.svelte-140iwqw{flex:1 1 20rem}.fact.svelte-on3isx{margin:.5rem 0;font-size:1.125rem}.muted.svelte-on3isx{color:var(--ink-muted);font-size:1rem}.scroll.svelte-on3isx{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}table.svelte-on3isx{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-on3isx,td.svelte-on3isx{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}.why.svelte-on3isx{white-space:normal;min-width:16rem;color:var(--ink-muted)}th.svelte-on3isx{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.inventory.svelte-tuh4jc{display:flex;flex-direction:column;gap:.5rem}.headline.svelte-tuh4jc{margin:0;font-size:1.375rem;font-weight:700}dl.svelte-tuh4jc{margin:0;display:flex;flex-direction:column;gap:.25rem}.row.svelte-tuh4jc{display:flex;align-items:baseline;gap:.75rem}dt.svelte-tuh4jc{flex:none;width:4.5rem;text-align:right;font-size:1.375rem;font-weight:700}dd.svelte-tuh4jc{margin:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;font-size:1.125rem}.label.svelte-tuh4jc{font-weight:700}.note.svelte-tuh4jc{color:var(--ink-muted)}.rows.svelte-tuh4jc{padding:0 .5rem;min-height:var(--touch-min);text-decoration:underline;color:var(--ink-muted)}.oneline.svelte-tuh4jc{margin:0;font-size:1rem;color:var(--ink-muted)}.toggle.svelte-1ozk3k{display:flex;align-items:center;gap:.75rem;min-height:2.75rem;margin:.5rem 0 0;padding:.375rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);font-weight:400;cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.toggle[data-on=true].svelte-1ozk3k{background:var(--ready-wash)}@media(hover:hover){.toggle.svelte-1ozk3k:hover{border-color:var(--ink-muted)}}.toggle.svelte-1ozk3k input:where(.svelte-1ozk3k){flex:none;width:1.5rem;height:1.5rem;min-height:0;min-width:0;padding:0;accent-color:var(--focus);font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.toggle-text.svelte-1ozk3k{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline}.toggle-label.svelte-1ozk3k{font-size:1.0625rem;font-weight:700}.hint.svelte-1ozk3k{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.pages.svelte-3gk7u7{display:flex;flex-direction:column;gap:1rem}.fact.svelte-3gk7u7{margin:.5rem 0;font-size:1.125rem}.muted.svelte-3gk7u7{color:var(--ink-muted);font-size:1rem}.actions.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.columns-label.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:1rem 0 .375rem;font-size:1.0625rem;font-weight:700}.columns.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.375rem}.column.svelte-3gk7u7{display:flex;gap:.5rem;align-items:center;min-height:2.75rem;margin:0;padding:0 .75rem;font-weight:400;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.column[data-on=true].svelte-3gk7u7{background:var(--ready-wash);border-color:var(--ink-muted);font-weight:700}@media(hover:hover){.column.svelte-3gk7u7:hover{border-color:var(--ink-muted)}}.column.svelte-3gk7u7 input:where(.svelte-3gk7u7){width:1.25rem;height:1.25rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0;accent-color:var(--focus)}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0 0 .25rem}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7):last-child{margin-bottom:0}.probes.svelte-3gk7u7{position:relative;height:0;overflow:hidden}.probe.svelte-3gk7u7{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none}.grid-probe.svelte-3gk7u7{display:grid;width:calc(100vw - var(--touch-gap) * 2);grid-auto-rows:minmax(var(--tile-height),auto);gap:var(--touch-gap);align-content:start}.calibration-probe.svelte-3gk7u7{width:var(--tile-min)}.viewport-probe.svelte-3gk7u7{height:calc(100vh - var(--banner-height) - var(--category-height) - var(--status-height) - var(--touch-gap) * 2)}.key.svelte-3gk7u7{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.scroll.svelte-3gk7u7{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-3gk7u7{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7):first-child{border-top:none}.id.svelte-3gk7u7,.value.svelte-3gk7u7{color:var(--ink-muted)}.what.svelte-3gk7u7{font-weight:700}.pick.svelte-3gk7u7{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-3gk7u7:hover:not(:disabled){background:var(--surface)}}.search.svelte-3gk7u7,label.svelte-3gk7u7{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}input.svelte-3gk7u7{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.decision.svelte-3gk7u7{margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border)}.act-block.svelte-3gk7u7{margin-top:1rem;padding:.75rem 1rem 1rem;background:var(--bg);border-radius:var(--radius)}.act-block.svelte-3gk7u7 .what:where(.svelte-3gk7u7){margin:0;font-size:1.0625rem;font-weight:700}.drop.svelte-3gk7u7{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius);transition:border-color var(--tap) var(--ease),background-color var(--tap) var(--ease)}.drop.dropping.svelte-3gk7u7{border-color:var(--focus);background:var(--waiting-wash)}.drop.working.svelte-3gk7u7{border-color:var(--waiting);background:var(--waiting-wash)}.drop.svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0;font-size:1.125rem}.choose.svelte-3gk7u7{display:inline-flex;align-items:center;margin:0;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease)}.choose.svelte-3gk7u7:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){.choose.svelte-3gk7u7:active{transform:none}}@media(hover:hover){.choose.svelte-3gk7u7:hover{filter:brightness(.92)}}.choose.svelte-3gk7u7 input:where(.svelte-3gk7u7){display:none}.light.svelte-1421wa8{display:flex;flex-direction:column;gap:.375rem;padding:.875rem 1rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--waiting);border-radius:var(--radius)}.light[data-level=ok].svelte-1421wa8{border-left-color:var(--ready)}.light[data-level=warn].svelte-1421wa8{border-left-color:var(--warning)}.light[data-level=fault].svelte-1421wa8{border-left-color:var(--fault)}header.svelte-1421wa8{display:flex;align-items:center;gap:.5rem}h3.svelte-1421wa8{margin:0;font-size:1.25rem;font-weight:700}.dot.svelte-1421wa8{width:1rem;height:1rem;border-radius:50%;background:var(--waiting);flex:none}.dot[data-level=ok].svelte-1421wa8{background:var(--ready)}.dot[data-level=warn].svelte-1421wa8{background:var(--warning)}.dot[data-level=fault].svelte-1421wa8{background:var(--fault)}.dot[data-level=unknown].svelte-1421wa8,.dot[data-level=off].svelte-1421wa8{background:transparent;box-shadow:inset 0 0 0 3px var(--waiting)}.verdict.svelte-1421wa8{margin-left:auto;font-size:1rem;font-weight:700;color:var(--ink-muted);letter-spacing:.03em}.value.svelte-1421wa8{margin:0;font-size:1.125rem}.remedy.svelte-1421wa8{margin:0;font-size:1rem;color:var(--ink-muted)}.dashboard.svelte-1w44m0y{display:flex;flex-direction:column;gap:1rem}.link.svelte-1w44m0y{padding:0;font:inherit;color:inherit;text-decoration:underline;background:none;border:none;cursor:pointer}.lights.svelte-1w44m0y{display:grid;grid-template-columns:repeat(auto-fit,minmax(20rem,1fr));grid-auto-rows:1fr;gap:var(--touch-gap)}.fact.svelte-1w44m0y{margin:0 0 .5rem;font-size:1.125rem;overflow-wrap:break-word}.remedy.svelte-1w44m0y,.count.svelte-1w44m0y{color:var(--ink-muted);font-size:1rem}.restart.svelte-1w44m0y strong:where(.svelte-1w44m0y){letter-spacing:.03em}.restart[data-verdict=missing].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--warning);background:var(--warning-wash)}.restart[data-verdict=unknown].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--waiting);background:var(--waiting-wash)}.decisions.svelte-1w44m0y,.events.svelte-1w44m0y{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.375rem}.decisions.svelte-1w44m0y li:where(.svelte-1w44m0y),.events.svelte-1w44m0y li:where(.svelte-1w44m0y){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.what.svelte-1w44m0y,.message.svelte-1w44m0y{font-weight:700}.detail.svelte-1w44m0y,.time.svelte-1w44m0y,.source.svelte-1w44m0y,.code.svelte-1w44m0y{color:var(--ink-muted)}.events.svelte-1w44m0y li[data-level=error]:where(.svelte-1w44m0y){border-left:.25rem solid var(--fault);padding-left:.5rem}.events.svelte-1w44m0y li[data-level=warn]:where(.svelte-1w44m0y){border-left:.25rem solid var(--warning);padding-left:.5rem}.identity.svelte-1w44m0y{margin:0;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.125rem}.identity.svelte-1w44m0y dt:where(.svelte-1w44m0y){color:var(--ink-muted)}.identity.svelte-1w44m0y dd:where(.svelte-1w44m0y){margin:0;font-weight:700}.head.svelte-1bhb91v{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:space-between}h2.svelte-1bhb91v{margin:0;font-size:1.5rem;font-weight:700}.standing.svelte-1bhb91v{display:inline-flex;align-items:center;gap:.5rem;height:2rem;padding:0 .75rem;border-radius:var(--radius-pill);background:var(--waiting-wash);font-size:1rem;font-weight:700}.standing[data-level=ok].svelte-1bhb91v{background:var(--ready-wash)}.standing[data-level=warn].svelte-1bhb91v{background:var(--warning-wash)}.standing[data-level=fault].svelte-1bhb91v{background:var(--fault-wash)}.dot.svelte-1bhb91v{width:.625rem;height:.625rem;border-radius:var(--radius-pill);background:var(--waiting)}.standing[data-level=ok].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--ready)}.standing[data-level=warn].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--warning)}.standing[data-level=fault].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--fault)}.pages.svelte-1p4i0xm{display:flex;flex-direction:column;gap:1rem}.panel.svelte-1p4i0xm{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1)}.fact.svelte-1p4i0xm{margin:.75rem 0 0;font-size:1.125rem}.note.svelte-1p4i0xm,.count.svelte-1p4i0xm,.waiting.svelte-1p4i0xm{margin:.25rem 0 0;font-size:1rem;color:var(--ink-muted)}.check.svelte-1p4i0xm{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin-top:.5rem;font-size:1.0625rem}.check.svelte-1p4i0xm input:where(.svelte-1p4i0xm){width:1.5rem;height:1.5rem;flex:0 0 auto}.check.svelte-1p4i0xm small:where(.svelte-1p4i0xm){display:block;font-size:1rem;color:var(--ink-muted)}.actions.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.5rem;margin:.75rem 0 0}@media(hover:hover){.pick.svelte-1p4i0xm:hover:not(:disabled),summary.svelte-1p4i0xm:hover{background:var(--bg)}}.list.svelte-1p4i0xm{margin:.25rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column}.list.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.125rem 0;border-top:1px solid var(--border-soft);font-size:1.0625rem}.list.svelte-1p4i0xm li.refused:where(.svelte-1p4i0xm){padding-left:.5rem;border-left:.25rem solid var(--fault);background:var(--fault-wash)}.pick.svelte-1p4i0xm{height:2.75rem;padding:0 .75rem;font-weight:700;text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}.use.svelte-1p4i0xm{text-decoration:none;background:var(--surface);border:1px solid var(--border)}.use.svelte-1p4i0xm:disabled{opacity:.5;cursor:default}.what.svelte-1p4i0xm{font-weight:700}.detail.svelte-1p4i0xm{color:var(--ink-muted)}.folded.svelte-1p4i0xm{margin-top:1rem;border:1px solid var(--border);border-radius:var(--radius)}summary.svelte-1p4i0xm{display:flex;align-items:center;height:2.75rem;padding:0 .75rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius);cursor:pointer;transition:background-color var(--tap) var(--ease),transform var(--tap) var(--ease)}summary.svelte-1p4i0xm:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){summary.svelte-1p4i0xm:active{transform:none}}.frames.svelte-1p4i0xm{margin-top:1rem;max-height:18rem;overflow-y:auto;background:var(--waiting-wash);border:1px solid var(--border);border-radius:var(--radius)}.frames-head.svelte-1p4i0xm{position:sticky;top:0;margin:0;padding:.5rem .75rem;background:var(--surface);border-radius:var(--radius) var(--radius) 0 0;box-shadow:var(--shadow-2);font-size:1rem;color:var(--ink-muted)}.interrupted.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:0;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);font-size:1rem}.frame-rows.svelte-1p4i0xm{margin:0;padding:.5rem .75rem;list-style:none;display:flex;flex-direction:column;gap:.25rem}.frame-rows.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:.75rem;font-size:.9375rem}.hex.svelte-1p4i0xm,.decoded.svelte-1p4i0xm{overflow-x:auto;white-space:pre}.decoded.svelte-1p4i0xm{color:var(--ink-muted)}.lines-box.svelte-hsqr6e{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:28rem}.lines.svelte-hsqr6e{margin:0;padding:0 .75rem;list-style:none}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e):first-child{border-top:none}.lines.svelte-hsqr6e li[data-level=error]:where(.svelte-hsqr6e),.lines.svelte-hsqr6e li[data-level=critical]:where(.svelte-hsqr6e){border-left:.25rem solid var(--fault);padding-left:.5rem}.lines.svelte-hsqr6e li[data-level=warn]:where(.svelte-hsqr6e){border-left:.25rem solid var(--warning);padding-left:.5rem}.when.svelte-hsqr6e,.level.svelte-hsqr6e,.from.svelte-hsqr6e,.code.svelte-hsqr6e,.detail-text.svelte-hsqr6e{color:var(--ink-muted)}.level.svelte-hsqr6e{flex:none;width:8rem}.message.svelte-hsqr6e{flex:1 1 20rem;font-weight:700}div.journal.svelte-86o8oz{display:flex;flex-direction:column;gap:1rem;max-width:none;--reading-column: 68rem}.filters.svelte-86o8oz{display:flex;flex-wrap:wrap;gap:var(--touch-gap);align-items:center;margin-bottom:.75rem;font-size:1.0625rem}.filters.svelte-86o8oz label:where(.svelte-86o8oz){font-weight:700}select.svelte-86o8oz{min-height:2.75rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.export.svelte-86o8oz{display:inline-flex;align-items:center;gap:.5rem;height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.export.svelte-86o8oz:active{transform:scale(.975)}@media(hover:hover){.export.svelte-86o8oz:hover{border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.replay.svelte-86o8oz{margin-top:.5rem}.fact.svelte-86o8oz{margin:.5rem 0;max-width:var(--reading-column);font-size:1.125rem}.muted.svelte-86o8oz{color:var(--ink-muted);font-size:1rem}.table-box.svelte-86o8oz{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:34rem}table.svelte-86o8oz{border-collapse:collapse;min-width:100%;font-size:1.0625rem}th.svelte-86o8oz,td.svelte-86o8oz{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}th.svelte-86o8oz{position:sticky;top:0;z-index:1;color:var(--ink-muted);font-size:1rem;background:var(--bg)}tbody.svelte-86o8oz tr:where(.svelte-86o8oz){transition:background-color var(--tap) var(--ease)}@media(hover:hover){tbody.svelte-86o8oz tr:where(.svelte-86o8oz):hover{background:var(--surface)}}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz){background:var(--surface);font-weight:700}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz)>td:where(.svelte-86o8oz):first-child{box-shadow:inset .1875rem 0 0 0 var(--focus)}.pick.svelte-86o8oz{height:2.75rem;padding:0 .5rem;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-86o8oz:hover:not(:disabled){background:var(--surface)}}.detail-row.svelte-86o8oz{background:var(--surface)}.detail-cell.svelte-86o8oz{padding:.75rem 1rem 1rem;white-space:normal;box-shadow:inset .1875rem 0 0 0 var(--focus)}.detail-cell.svelte-86o8oz h3:where(.svelte-86o8oz){margin:0 0 .5rem;font-size:1.25rem}.detail-cell.svelte-86o8oz dl:where(.svelte-86o8oz){display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;max-width:var(--reading-column);margin:0;font-size:1.0625rem;font-weight:400}.detail-cell.svelte-86o8oz dt:where(.svelte-86o8oz){color:var(--ink-muted)}.detail-cell.svelte-86o8oz dd:where(.svelte-86o8oz){margin:0}.detail-cell.svelte-86o8oz code:where(.svelte-86o8oz){overflow-wrap:anywhere}.sr-only.svelte-86o8oz{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.pages.svelte-llv1r8{display:flex;flex-direction:column;gap:1rem}.stale.svelte-llv1r8{margin:0 0 .75rem;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);border-radius:var(--radius-sm);font-size:1.0625rem}.preview.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:1.5rem;align-items:flex-start}.sheet.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0;padding:.75rem;background:var(--waiting-wash);border-radius:var(--radius-lg)}img.svelte-llv1r8{width:40rem;max-width:100%;image-rendering:pixelated;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-1)}.refused.svelte-llv1r8{margin:0;padding:.5rem .75rem;background:var(--fault-wash);border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);font-size:1.0625rem}.nudges.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0}.axis.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0;font-size:1.125rem}.pair.svelte-llv1r8{display:flex;gap:var(--touch-gap)}.fault.svelte-llv1r8{margin:0;padding-left:.5rem;border-left:.25rem solid var(--fault);font-size:1rem;color:var(--ink-muted)}.check.svelte-llv1r8{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;font-size:1.0625rem}.check.svelte-llv1r8 input:where(.svelte-llv1r8){width:1.5rem;height:1.5rem;flex:0 0 auto}.truncation.svelte-llv1r8,.absent.svelte-llv1r8,.waiting.svelte-llv1r8,.bound.svelte-llv1r8,.cost.svelte-llv1r8{margin:1rem 0 0;font-size:1rem;color:var(--ink-muted)}.absent.svelte-llv1r8{margin-top:.5rem}.bound.svelte-llv1r8{margin-top:.25rem}.cost.svelte-llv1r8{margin-top:.5rem}.actions.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin-top:.75rem}.fact.svelte-geg7e6{margin:.5rem 0;font-size:1.125rem}.muted.svelte-geg7e6{color:var(--ink-muted);font-size:1rem}.rules.svelte-geg7e6{margin:.75rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.75rem}.rule.svelte-geg7e6{padding:.75rem 1rem 1rem;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm)}.rule-head.svelte-geg7e6{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0}.rank.svelte-geg7e6{flex:none;min-width:1.75rem;color:var(--ink-muted);font-variant-numeric:tabular-nums;font-weight:700}.rule-label.svelte-geg7e6{font-size:1.125rem;font-weight:700}.token.svelte-geg7e6{color:var(--ink-muted);font-size:.9375rem}.severity.svelte-geg7e6{margin-left:auto;padding:.125rem .625rem;font-size:.9375rem;border-radius:var(--radius-pill);background:var(--waiting-wash)}.severity[data-blocking=true].svelte-geg7e6{background:var(--warning-wash)}.when.svelte-geg7e6{margin:.375rem 0 0;font-size:1rem}.quote.svelte-geg7e6{margin:.5rem 0 0;padding:.5rem .75rem;font-size:1.0625rem;background:var(--surface);border-radius:var(--radius-sm);box-shadow:var(--shadow-1)}.quote.silent.svelte-geg7e6{color:var(--ink-muted);font-size:1rem;box-shadow:none;background:var(--waiting-wash)}.note.svelte-geg7e6{margin:.5rem 0 0;font-size:1rem;color:var(--ink-muted)}.box.svelte-geg7e6{display:contents}.fact.svelte-9g4joo{margin:.5rem 0;font-size:1.125rem}.muted.svelte-9g4joo,.locked.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}.scroll.svelte-9g4joo{overflow-x:auto}table.svelte-9g4joo{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-9g4joo,td.svelte-9g4joo{padding:.375rem .5rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}input.svelte-9g4joo{min-height:2.75rem;width:100%;min-width:6rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.hint.svelte-9g4joo{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.fact.svelte-65t3t4{margin:.5rem 0;font-size:1.125rem}.muted.svelte-65t3t4{color:var(--ink-muted);font-size:1rem}.verdicts.svelte-65t3t4{margin:0;padding:0;list-style:none}.waivers.svelte-65t3t4{max-height:24rem;overflow-y:auto}.verdicts.svelte-65t3t4 li:where(.svelte-65t3t4){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.token.svelte-65t3t4{color:var(--ink-muted);font-size:.9375rem}.what.svelte-65t3t4,.message.svelte-65t3t4{font-weight:700}.detail.svelte-65t3t4{color:var(--ink-muted)}.dead.svelte-65t3t4{flex:1 1 20rem;padding:.125rem .625rem;font-size:1rem;background:var(--warning-wash);border-radius:var(--radius-sm)}.unread.svelte-65t3t4{margin:.5rem 0;padding:.5rem .75rem;font-size:1rem;background:var(--fault-wash);border-left:.25rem solid var(--fault);border-radius:var(--radius-sm)}.pages.svelte-hzfkjn{display:flex;flex-direction:column;gap:1rem}.fact.svelte-hzfkjn{margin:.5rem 0;font-size:1.125rem}.muted.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem}h3.svelte-hzfkjn{margin:1.5rem 0 .5rem;font-size:1.25rem}.verdicts.svelte-hzfkjn{margin:0;padding:0;list-style:none}.verdicts.svelte-hzfkjn li:where(.svelte-hzfkjn){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.verdicts.svelte-hzfkjn li[data-blocking=true]:where(.svelte-hzfkjn){border-left:.25rem solid var(--warning);background:var(--warning-wash);padding-left:.5rem}.token.svelte-hzfkjn{color:var(--ink-muted);font-size:.9375rem}.what.svelte-hzfkjn,.message.svelte-hzfkjn{font-weight:700}.detail.svelte-hzfkjn{color:var(--ink-muted)}.scroll.svelte-1fkeh3r{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}table.svelte-1fkeh3r{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-1fkeh3r,td.svelte-1fkeh3r{padding:.375rem .75rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-1fkeh3r{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.fact.svelte-8utf8s{margin:.5rem 0;font-size:1.125rem}.muted.svelte-8utf8s{color:var(--ink-muted);font-size:1rem}.faults.svelte-8utf8s{margin-top:.75rem;padding:.25rem 1rem .5rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-8utf8s ul:where(.svelte-8utf8s){margin:0;padding-left:1.25rem;font-size:1.0625rem}.allowed.svelte-8utf8s{display:block;color:var(--ink-muted);font-size:1rem}dd.svelte-mgmug7{margin:0 0 1.25rem;display:flex;flex-direction:column;align-items:flex-start;gap:.5rem}.faults.svelte-mgmug7{margin-top:.75rem;padding:.5rem 1rem .5rem 2rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-mgmug7 li:where(.svelte-mgmug7){margin-bottom:.25rem}.pages.svelte-18wbxwu{display:flex;flex-direction:column;gap:1rem}.fact.svelte-18wbxwu{margin:.5rem 0;font-size:1.125rem}.muted.svelte-18wbxwu{color:var(--ink-muted);font-size:1rem}.filename.svelte-18wbxwu{margin-top:1rem;font-weight:700}.identity.svelte-18wbxwu{margin:1rem 0 0;padding:.75rem 1rem;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.0625rem;background:var(--bg);border-radius:var(--radius)}.identity.svelte-18wbxwu dt:where(.svelte-18wbxwu){color:var(--ink-muted)}.identity.svelte-18wbxwu dd:where(.svelte-18wbxwu){margin:0;font-weight:700}.actions.svelte-18wbxwu{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.choose.svelte-18wbxwu{display:inline-flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.choose.svelte-18wbxwu:active:not(.off){transform:scale(.975)}@media(hover:hover){.choose.svelte-18wbxwu:hover:not(.off){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.choose.off.svelte-18wbxwu{opacity:.5;box-shadow:none;cursor:default}.choose.working.svelte-18wbxwu{opacity:1;border-color:var(--waiting)}.choose.svelte-18wbxwu input:where(.svelte-18wbxwu){display:none}.key.svelte-18wbxwu{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.same.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--ready);border-radius:var(--radius-sm);background:var(--ready-wash)}.warned.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);background:var(--fault-wash)}.scroll.svelte-18wbxwu{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}.rows.svelte-18wbxwu{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.5rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu):first-child{border-top:none}.what.svelte-18wbxwu{font-weight:700}.detail.svelte-18wbxwu{color:var(--ink-muted)}.big.svelte-15nlt6i{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.write.svelte-15nlt6i{color:var(--surface);background:var(--action);border-color:var(--action)}.destructive.svelte-15nlt6i{color:var(--surface);background:var(--danger);border-color:var(--danger)}.write.svelte-15nlt6i .hint:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .hint:where(.svelte-15nlt6i){color:var(--surface);opacity:.85}.write.svelte-15nlt6i .guarded:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .guarded:where(.svelte-15nlt6i){background:var(--surface);color:var(--ink)}.big.svelte-15nlt6i:disabled{opacity:.5}.big.busy.svelte-15nlt6i{opacity:1;border-color:var(--waiting);background:var(--waiting-wash)}.big.busy.write.svelte-15nlt6i{background:var(--action)}.big.busy.destructive.svelte-15nlt6i{background:var(--danger)}.guarded.svelte-15nlt6i{margin-left:.5rem;padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;vertical-align:middle}.big.engaged.svelte-15nlt6i{border-left:.5rem solid var(--warning)}.label.svelte-15nlt6i{font-size:1.375rem;font-weight:700}.hint.svelte-15nlt6i{font-size:1rem;color:var(--ink-muted)}.troubleshooting.svelte-1yd0nzg{display:flex;flex-direction:column;gap:1rem}.buttons.svelte-1yd0nzg{display:grid;grid-template-columns:repeat(auto-fit,minmax(22rem,1fr));gap:var(--touch-gap)}.big.svelte-1yd0nzg{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.label.svelte-1yd0nzg{font-size:1.375rem;font-weight:700}.hint.svelte-1yd0nzg{font-size:1rem;color:var(--ink-muted)}.report.svelte-1yd0nzg{margin:0;padding:.75rem 1rem;font-size:1.125rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--ready);border-radius:var(--radius)}.drop.svelte-1yd0nzg{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius)}.drop.dropping.svelte-1yd0nzg{border-color:var(--focus)}.drop.svelte-1yd0nzg p:where(.svelte-1yd0nzg){margin:0;font-size:1.125rem}.key.svelte-1yd0nzg{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.choose.svelte-1yd0nzg{display:inline-flex;align-items:center;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius);cursor:pointer}@media(hover:hover){.choose.svelte-1yd0nzg:hover{filter:brightness(.92)}}.choose.svelte-1yd0nzg input:where(.svelte-1yd0nzg){display:none}.fact.svelte-1yd0nzg{margin:0 0 .5rem;font-size:1.125rem}.muted.svelte-1yd0nzg{color:var(--ink-muted)}.row.svelte-o9s8nz{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem}.admin.svelte-9b2mjq{position:fixed;inset:0;z-index:90;display:grid;grid-template-columns:16rem 1fr;background:var(--bg);color:var(--ink);overflow:hidden}.rail.svelte-9b2mjq{display:flex;flex-direction:column;gap:.125rem;padding:1rem .75rem;overflow-y:auto;background:var(--surface);border-right:1px solid var(--border)}h1.svelte-9b2mjq{margin:0 .5rem 1rem;font-size:1.25rem;letter-spacing:-.01em}.group.svelte-9b2mjq{margin:1rem .5rem .375rem;color:var(--ink-muted);font-size:.8125rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase}.entry.svelte-9b2mjq{padding:0 .75rem;height:2.5rem;display:flex;align-items:center;text-align:left;font-size:1.0625rem;border-radius:var(--radius-sm);color:var(--ink-muted);transition:background-color var(--tap) var(--ease),color var(--tap) var(--ease)}@media(hover:hover){.entry.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.entry.current.svelte-9b2mjq{background:var(--bg);color:var(--ink);font-weight:700;box-shadow:inset .1875rem 0 0 0 var(--focus)}.foot.svelte-9b2mjq{margin-top:auto;padding:1rem .5rem 0;border-top:1px solid var(--border)}.station.svelte-9b2mjq{margin:0;font-size:1rem;font-weight:600}.build.svelte-9b2mjq{margin:.125rem 0 0;font-size:.8125rem;color:var(--ink-muted)}.technical.svelte-9b2mjq{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;margin-top:.75rem;color:var(--ink-muted);font-size:.9375rem}.technical.svelte-9b2mjq input:where(.svelte-9b2mjq){width:1.5rem;height:1.5rem;flex:0 0 auto}.back.svelte-9b2mjq{margin-top:.75rem;padding:0 .5rem;height:2.25rem;font-size:.9375rem;color:var(--ink-muted);border-radius:var(--radius-sm)}@media(hover:hover){.back.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.body.svelte-9b2mjq{display:flex;flex-direction:column;min-width:0;overflow:hidden}.page.svelte-9b2mjq{flex:1;min-height:0;overflow-y:auto;padding:1.5rem 2rem 3rem}.page.svelte-9b2mjq>*{max-width:68rem}.page-title.svelte-9b2mjq{margin:0 0 1.25rem;font-size:1.75rem;letter-spacing:-.01em}.waiting.svelte-9b2mjq{margin:0;font-size:1.125rem;color:var(--ink-muted)}.banner.svelte-9b2mjq{flex:none;margin:0;padding:.75rem 2rem;font-size:1.0625rem;background:var(--surface);border-bottom:1px solid var(--border)}.notice.svelte-9b2mjq{border-left:.375rem solid var(--ready);background:var(--ready-wash)}.failure.svelte-9b2mjq{border-left:.375rem solid var(--fault);background:var(--fault-wash)}.pending.svelte-9b2mjq{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;border-left:.375rem solid var(--warning);background:var(--warning-wash)}.pending.svelte-9b2mjq p:where(.svelte-9b2mjq){margin:0;flex:1 1 24rem}.save-bar.svelte-9b2mjq{flex:none;padding:.75rem 2rem;background:var(--surface);border-top:1px solid var(--border);box-shadow:var(--shadow-1)}.retired.svelte-9b2mjq{margin:0 0 .5rem;font-size:.9375rem;color:var(--ink-muted)}.faults.svelte-9b2mjq{margin:0 0 .5rem;padding-left:1.25rem;font-size:1rem} diff --git a/internal/web/dist/assets/mount-MmpjwdB2.css b/internal/web/dist/assets/mount-MmpjwdB2.css deleted file mode 100644 index 7ca2cf5..0000000 --- a/internal/web/dist/assets/mount-MmpjwdB2.css +++ /dev/null @@ -1 +0,0 @@ -.act.svelte-5tpq0o{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.read.svelte-5tpq0o{color:var(--ink);background:var(--surface);border:1px solid var(--border)}.write.svelte-5tpq0o{color:var(--surface);background:var(--action);border:1px solid var(--action)}.destructive.svelte-5tpq0o{min-height:var(--touch-min);color:var(--surface);background:var(--danger);border:1px solid var(--danger)}@media(hover:hover){.read.svelte-5tpq0o:hover:not(:disabled){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}.write.svelte-5tpq0o:hover:not(:disabled){border-color:var(--action)}.destructive.svelte-5tpq0o:hover:not(:disabled){border-color:var(--danger)}.write.svelte-5tpq0o:hover:not(:disabled),.destructive.svelte-5tpq0o:hover:not(:disabled){box-shadow:var(--shadow-2);filter:brightness(.92)}}.act.svelte-5tpq0o:disabled{opacity:.5;box-shadow:none;cursor:default}.act.busy.svelte-5tpq0o:disabled{opacity:1}.key.svelte-5tpq0o{padding:.0625rem .375rem;border-radius:var(--radius-pill);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;background:var(--bg);color:var(--ink-muted)}.write.svelte-5tpq0o .key:where(.svelte-5tpq0o),.destructive.svelte-5tpq0o .key:where(.svelte-5tpq0o){background:var(--surface);color:var(--ink)}.scrim.svelte-zyka3t{position:fixed;inset:0;z-index:95;display:flex;align-items:center;justify-content:center;padding:2rem;background:#1c1b1973}.panel.svelte-zyka3t{display:flex;flex-direction:column;gap:.75rem;width:min(34rem,100%);padding:1.75rem;background:var(--surface);border-radius:var(--radius-lg);box-shadow:var(--shadow-2)}header.svelte-zyka3t{display:flex;align-items:center;gap:.75rem}.glyph.svelte-zyka3t{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;flex:none;border-radius:var(--radius-sm);background:var(--bg);color:var(--ink-muted)}h2.svelte-zyka3t{margin:0;font-size:1.375rem;line-height:1.2}.why.svelte-zyka3t,.how.svelte-zyka3t{margin:0;color:var(--ink-muted);font-size:1rem;line-height:1.4}.field.svelte-zyka3t{display:flex;flex-direction:column;gap:.375rem;font-size:1rem;color:var(--ink-muted)}input.svelte-zyka3t{height:2.75rem;padding:0 .75rem;font:inherit;font-size:1.125rem;color:var(--ink);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)}input.svelte-zyka3t:focus-visible{outline:3px solid var(--focus);outline-offset:1px}.refusal.svelte-zyka3t{margin:0;padding:.625rem .75rem;border-left:.25rem solid var(--fault);background:var(--fault-wash);font-size:1rem}footer.svelte-zyka3t{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.25rem}.field.svelte-1oilv5s{display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0}label.svelte-1oilv5s{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem}.name.svelte-1oilv5s{font-size:1.125rem;font-weight:700}code.svelte-1oilv5s{font-size:.9375rem;color:var(--ink-muted)}input.svelte-1oilv5s,select.svelte-1oilv5s{min-height:3rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.refused.svelte-1oilv5s input:where(.svelte-1oilv5s),.refused.svelte-1oilv5s select:where(.svelte-1oilv5s){border-left:.5rem solid var(--fault)}.hint.svelte-1oilv5s,.fault.svelte-1oilv5s{margin:0;font-size:1rem;color:var(--ink-muted)}.fault.svelte-1oilv5s{padding-left:.5rem;border-left:.25rem solid var(--fault)}.panel.svelte-ww3z5u{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}h2.svelte-ww3z5u{margin:0 0 .5rem;font-size:1.5rem;font-weight:700}.note.svelte-ww3z5u{margin:0 0 .75rem;font-size:1rem;color:var(--ink-muted)}.fact.svelte-stwcs8{margin:.5rem 0;font-size:1.125rem}.muted.svelte-stwcs8{color:var(--ink-muted);font-size:1rem}.choice.svelte-stwcs8{display:flex;flex-direction:column}label.svelte-stwcs8{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}.choice.svelte-stwcs8 label:where(.svelte-stwcs8){display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin:0;font-weight:400}input.svelte-stwcs8{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.choice.svelte-stwcs8 input:where(.svelte-stwcs8){width:1.5rem;height:1.5rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0}.fact.svelte-xldam3{margin:.5rem 0;font-size:1.125rem}.muted.svelte-xldam3{color:var(--ink-muted);font-size:1rem}.scroll.svelte-xldam3{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-xldam3{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-xldam3 li:where(.svelte-xldam3){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-xldam3 li:where(.svelte-xldam3):first-child{border-top:none}.line.svelte-xldam3,.id.svelte-xldam3,.value.svelte-xldam3{color:var(--ink-muted)}.line.svelte-xldam3{flex:none;width:7rem}.what.svelte-xldam3{font-weight:700}.message.svelte-xldam3{flex:1 1 20rem}.pick.svelte-xldam3{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-xldam3:hover:not(:disabled){background:var(--surface)}}.fact.svelte-140iwqw{margin:.5rem 0;font-size:1.125rem}.muted.svelte-140iwqw{color:var(--ink-muted);font-size:1rem}.scroll.svelte-140iwqw{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-140iwqw{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-140iwqw li:where(.svelte-140iwqw){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-140iwqw li:where(.svelte-140iwqw):first-child{border-top:none}.line.svelte-140iwqw,.id.svelte-140iwqw,.value.svelte-140iwqw{color:var(--ink-muted)}.line.svelte-140iwqw{flex:none;width:7rem}.what.svelte-140iwqw{font-weight:700}.message.svelte-140iwqw{flex:1 1 20rem}.fact.svelte-on3isx{margin:.5rem 0;font-size:1.125rem}.muted.svelte-on3isx{color:var(--ink-muted);font-size:1rem}.scroll.svelte-on3isx{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}table.svelte-on3isx{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-on3isx,td.svelte-on3isx{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}.why.svelte-on3isx{white-space:normal;min-width:16rem;color:var(--ink-muted)}th.svelte-on3isx{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.inventory.svelte-tuh4jc{display:flex;flex-direction:column;gap:.5rem}.headline.svelte-tuh4jc{margin:0;font-size:1.375rem;font-weight:700}dl.svelte-tuh4jc{margin:0;display:flex;flex-direction:column;gap:.25rem}.row.svelte-tuh4jc{display:flex;align-items:baseline;gap:.75rem}dt.svelte-tuh4jc{flex:none;width:4.5rem;text-align:right;font-size:1.375rem;font-weight:700}dd.svelte-tuh4jc{margin:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;font-size:1.125rem}.label.svelte-tuh4jc{font-weight:700}.note.svelte-tuh4jc{color:var(--ink-muted)}.rows.svelte-tuh4jc{padding:0 .5rem;min-height:var(--touch-min);text-decoration:underline;color:var(--ink-muted)}.oneline.svelte-tuh4jc{margin:0;font-size:1rem;color:var(--ink-muted)}.toggle.svelte-1ozk3k{display:flex;align-items:center;gap:.75rem;min-height:2.75rem;margin:.5rem 0 0;padding:.375rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);font-weight:400;cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.toggle[data-on=true].svelte-1ozk3k{background:var(--ready-wash)}@media(hover:hover){.toggle.svelte-1ozk3k:hover{border-color:var(--ink-muted)}}.toggle.svelte-1ozk3k input:where(.svelte-1ozk3k){flex:none;width:1.5rem;height:1.5rem;min-height:0;min-width:0;padding:0;accent-color:var(--focus);font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.toggle-text.svelte-1ozk3k{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline}.toggle-label.svelte-1ozk3k{font-size:1.0625rem;font-weight:700}.hint.svelte-1ozk3k{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.pages.svelte-3gk7u7{display:flex;flex-direction:column;gap:1rem}.fact.svelte-3gk7u7{margin:.5rem 0;font-size:1.125rem}.muted.svelte-3gk7u7{color:var(--ink-muted);font-size:1rem}.actions.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.columns-label.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:1rem 0 .375rem;font-size:1.0625rem;font-weight:700}.columns.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.375rem}.column.svelte-3gk7u7{display:flex;gap:.5rem;align-items:center;min-height:2.75rem;margin:0;padding:0 .75rem;font-weight:400;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.column[data-on=true].svelte-3gk7u7{background:var(--ready-wash);border-color:var(--ink-muted);font-weight:700}@media(hover:hover){.column.svelte-3gk7u7:hover{border-color:var(--ink-muted)}}.column.svelte-3gk7u7 input:where(.svelte-3gk7u7){width:1.25rem;height:1.25rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0;accent-color:var(--focus)}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0 0 .25rem}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7):last-child{margin-bottom:0}.probes.svelte-3gk7u7{position:relative;height:0;overflow:hidden}.probe.svelte-3gk7u7{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none}.grid-probe.svelte-3gk7u7{display:grid;width:calc(100vw - var(--touch-gap) * 2);grid-auto-rows:minmax(var(--tile-height),auto);gap:var(--touch-gap);align-content:start}.calibration-probe.svelte-3gk7u7{width:var(--tile-min)}.viewport-probe.svelte-3gk7u7{height:calc(100vh - var(--banner-height) - var(--category-height) - var(--status-height) - var(--touch-gap) * 2)}.key.svelte-3gk7u7{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.scroll.svelte-3gk7u7{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-3gk7u7{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7):first-child{border-top:none}.id.svelte-3gk7u7,.value.svelte-3gk7u7{color:var(--ink-muted)}.what.svelte-3gk7u7{font-weight:700}.pick.svelte-3gk7u7{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-3gk7u7:hover:not(:disabled){background:var(--surface)}}.search.svelte-3gk7u7,label.svelte-3gk7u7{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}input.svelte-3gk7u7{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.decision.svelte-3gk7u7{margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border)}.act-block.svelte-3gk7u7{margin-top:1rem;padding:.75rem 1rem 1rem;background:var(--bg);border-radius:var(--radius)}.act-block.svelte-3gk7u7 .what:where(.svelte-3gk7u7){margin:0;font-size:1.0625rem;font-weight:700}.drop.svelte-3gk7u7{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius);transition:border-color var(--tap) var(--ease),background-color var(--tap) var(--ease)}.drop.dropping.svelte-3gk7u7{border-color:var(--focus);background:var(--waiting-wash)}.drop.working.svelte-3gk7u7{border-color:var(--waiting);background:var(--waiting-wash)}.drop.svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0;font-size:1.125rem}.choose.svelte-3gk7u7{display:inline-flex;align-items:center;margin:0;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease)}.choose.svelte-3gk7u7:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){.choose.svelte-3gk7u7:active{transform:none}}@media(hover:hover){.choose.svelte-3gk7u7:hover{filter:brightness(.92)}}.choose.svelte-3gk7u7 input:where(.svelte-3gk7u7){display:none}.light.svelte-1421wa8{display:flex;flex-direction:column;gap:.375rem;padding:.875rem 1rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--waiting);border-radius:var(--radius)}.light[data-level=ok].svelte-1421wa8{border-left-color:var(--ready)}.light[data-level=warn].svelte-1421wa8{border-left-color:var(--warning)}.light[data-level=fault].svelte-1421wa8{border-left-color:var(--fault)}header.svelte-1421wa8{display:flex;align-items:center;gap:.5rem}h3.svelte-1421wa8{margin:0;font-size:1.25rem;font-weight:700}.dot.svelte-1421wa8{width:1rem;height:1rem;border-radius:50%;background:var(--waiting);flex:none}.dot[data-level=ok].svelte-1421wa8{background:var(--ready)}.dot[data-level=warn].svelte-1421wa8{background:var(--warning)}.dot[data-level=fault].svelte-1421wa8{background:var(--fault)}.dot[data-level=unknown].svelte-1421wa8,.dot[data-level=off].svelte-1421wa8{background:transparent;box-shadow:inset 0 0 0 3px var(--waiting)}.verdict.svelte-1421wa8{margin-left:auto;font-size:1rem;font-weight:700;color:var(--ink-muted);letter-spacing:.03em}.value.svelte-1421wa8{margin:0;font-size:1.125rem}.remedy.svelte-1421wa8{margin:0;font-size:1rem;color:var(--ink-muted)}.dashboard.svelte-1w44m0y{display:flex;flex-direction:column;gap:1rem}.link.svelte-1w44m0y{padding:0;font:inherit;color:inherit;text-decoration:underline;background:none;border:none;cursor:pointer}.lights.svelte-1w44m0y{display:grid;grid-template-columns:repeat(auto-fit,minmax(20rem,1fr));grid-auto-rows:1fr;gap:var(--touch-gap)}.fact.svelte-1w44m0y{margin:0 0 .5rem;font-size:1.125rem;overflow-wrap:break-word}.remedy.svelte-1w44m0y,.count.svelte-1w44m0y{color:var(--ink-muted);font-size:1rem}.restart.svelte-1w44m0y strong:where(.svelte-1w44m0y){letter-spacing:.03em}.restart[data-verdict=missing].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--warning);background:var(--warning-wash)}.restart[data-verdict=unknown].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--waiting);background:var(--waiting-wash)}.decisions.svelte-1w44m0y,.events.svelte-1w44m0y{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.375rem}.decisions.svelte-1w44m0y li:where(.svelte-1w44m0y),.events.svelte-1w44m0y li:where(.svelte-1w44m0y){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.what.svelte-1w44m0y,.message.svelte-1w44m0y{font-weight:700}.detail.svelte-1w44m0y,.time.svelte-1w44m0y,.source.svelte-1w44m0y,.code.svelte-1w44m0y{color:var(--ink-muted)}.events.svelte-1w44m0y li[data-level=error]:where(.svelte-1w44m0y){border-left:.25rem solid var(--fault);padding-left:.5rem}.events.svelte-1w44m0y li[data-level=warn]:where(.svelte-1w44m0y){border-left:.25rem solid var(--warning);padding-left:.5rem}.identity.svelte-1w44m0y{margin:0;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.125rem}.identity.svelte-1w44m0y dt:where(.svelte-1w44m0y){color:var(--ink-muted)}.identity.svelte-1w44m0y dd:where(.svelte-1w44m0y){margin:0;font-weight:700}.head.svelte-1bhb91v{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:space-between}h2.svelte-1bhb91v{margin:0;font-size:1.5rem;font-weight:700}.standing.svelte-1bhb91v{display:inline-flex;align-items:center;gap:.5rem;height:2rem;padding:0 .75rem;border-radius:var(--radius-pill);background:var(--waiting-wash);font-size:1rem;font-weight:700}.standing[data-level=ok].svelte-1bhb91v{background:var(--ready-wash)}.standing[data-level=warn].svelte-1bhb91v{background:var(--warning-wash)}.standing[data-level=fault].svelte-1bhb91v{background:var(--fault-wash)}.dot.svelte-1bhb91v{width:.625rem;height:.625rem;border-radius:var(--radius-pill);background:var(--waiting)}.standing[data-level=ok].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--ready)}.standing[data-level=warn].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--warning)}.standing[data-level=fault].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--fault)}.pages.svelte-1p4i0xm{display:flex;flex-direction:column;gap:1rem}.panel.svelte-1p4i0xm{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1)}.fact.svelte-1p4i0xm{margin:.75rem 0 0;font-size:1.125rem}.note.svelte-1p4i0xm,.count.svelte-1p4i0xm,.waiting.svelte-1p4i0xm{margin:.25rem 0 0;font-size:1rem;color:var(--ink-muted)}.check.svelte-1p4i0xm{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin-top:.5rem;font-size:1.0625rem}.check.svelte-1p4i0xm input:where(.svelte-1p4i0xm){width:1.5rem;height:1.5rem;flex:0 0 auto}.check.svelte-1p4i0xm small:where(.svelte-1p4i0xm){display:block;font-size:1rem;color:var(--ink-muted)}.actions.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.5rem;margin:.75rem 0 0}@media(hover:hover){.pick.svelte-1p4i0xm:hover,summary.svelte-1p4i0xm:hover{background:var(--bg)}}.list.svelte-1p4i0xm{margin:.25rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column}.list.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.125rem 0;border-top:1px solid var(--border-soft);font-size:1.0625rem}.list.svelte-1p4i0xm li.refused:where(.svelte-1p4i0xm){padding-left:.5rem;border-left:.25rem solid var(--fault);background:var(--fault-wash)}.pick.svelte-1p4i0xm{height:2.75rem;padding:0 .75rem;font-weight:700;text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}.what.svelte-1p4i0xm{font-weight:700}.detail.svelte-1p4i0xm{color:var(--ink-muted)}.folded.svelte-1p4i0xm{margin-top:1rem;border:1px solid var(--border);border-radius:var(--radius)}summary.svelte-1p4i0xm{display:flex;align-items:center;height:2.75rem;padding:0 .75rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius);cursor:pointer;transition:background-color var(--tap) var(--ease),transform var(--tap) var(--ease)}summary.svelte-1p4i0xm:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){summary.svelte-1p4i0xm:active{transform:none}}.frames.svelte-1p4i0xm{margin-top:1rem;max-height:18rem;overflow-y:auto;background:var(--waiting-wash);border:1px solid var(--border);border-radius:var(--radius)}.frames-head.svelte-1p4i0xm{position:sticky;top:0;margin:0;padding:.5rem .75rem;background:var(--surface);border-radius:var(--radius) var(--radius) 0 0;box-shadow:var(--shadow-2);font-size:1rem;color:var(--ink-muted)}.interrupted.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:0;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);font-size:1rem}.frame-rows.svelte-1p4i0xm{margin:0;padding:.5rem .75rem;list-style:none;display:flex;flex-direction:column;gap:.25rem}.frame-rows.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:.75rem;font-size:.9375rem}.hex.svelte-1p4i0xm,.decoded.svelte-1p4i0xm{overflow-x:auto;white-space:pre}.decoded.svelte-1p4i0xm{color:var(--ink-muted)}.lines-box.svelte-hsqr6e{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:28rem}.lines.svelte-hsqr6e{margin:0;padding:0 .75rem;list-style:none}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e):first-child{border-top:none}.lines.svelte-hsqr6e li[data-level=error]:where(.svelte-hsqr6e),.lines.svelte-hsqr6e li[data-level=critical]:where(.svelte-hsqr6e){border-left:.25rem solid var(--fault);padding-left:.5rem}.lines.svelte-hsqr6e li[data-level=warn]:where(.svelte-hsqr6e){border-left:.25rem solid var(--warning);padding-left:.5rem}.when.svelte-hsqr6e,.level.svelte-hsqr6e,.from.svelte-hsqr6e,.code.svelte-hsqr6e,.detail-text.svelte-hsqr6e{color:var(--ink-muted)}.level.svelte-hsqr6e{flex:none;width:8rem}.message.svelte-hsqr6e{flex:1 1 20rem;font-weight:700}div.journal.svelte-86o8oz{display:flex;flex-direction:column;gap:1rem;max-width:none;--reading-column: 68rem}.filters.svelte-86o8oz{display:flex;flex-wrap:wrap;gap:var(--touch-gap);align-items:center;margin-bottom:.75rem;font-size:1.0625rem}.filters.svelte-86o8oz label:where(.svelte-86o8oz){font-weight:700}select.svelte-86o8oz{min-height:2.75rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.export.svelte-86o8oz{display:inline-flex;align-items:center;gap:.5rem;height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.export.svelte-86o8oz:active{transform:scale(.975)}@media(hover:hover){.export.svelte-86o8oz:hover{border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.replay.svelte-86o8oz{margin-top:.5rem}.fact.svelte-86o8oz{margin:.5rem 0;max-width:var(--reading-column);font-size:1.125rem}.muted.svelte-86o8oz{color:var(--ink-muted);font-size:1rem}.table-box.svelte-86o8oz{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:34rem}table.svelte-86o8oz{border-collapse:collapse;min-width:100%;font-size:1.0625rem}th.svelte-86o8oz,td.svelte-86o8oz{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}th.svelte-86o8oz{position:sticky;top:0;z-index:1;color:var(--ink-muted);font-size:1rem;background:var(--bg)}tbody.svelte-86o8oz tr:where(.svelte-86o8oz){transition:background-color var(--tap) var(--ease)}@media(hover:hover){tbody.svelte-86o8oz tr:where(.svelte-86o8oz):hover{background:var(--surface)}}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz){background:var(--surface);font-weight:700}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz)>td:where(.svelte-86o8oz):first-child{box-shadow:inset .1875rem 0 0 0 var(--focus)}.pick.svelte-86o8oz{height:2.75rem;padding:0 .5rem;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-86o8oz:hover:not(:disabled){background:var(--surface)}}.detail-row.svelte-86o8oz{background:var(--surface)}.detail-cell.svelte-86o8oz{padding:.75rem 1rem 1rem;white-space:normal;box-shadow:inset .1875rem 0 0 0 var(--focus)}.detail-cell.svelte-86o8oz h3:where(.svelte-86o8oz){margin:0 0 .5rem;font-size:1.25rem}.detail-cell.svelte-86o8oz dl:where(.svelte-86o8oz){display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;max-width:var(--reading-column);margin:0;font-size:1.0625rem;font-weight:400}.detail-cell.svelte-86o8oz dt:where(.svelte-86o8oz){color:var(--ink-muted)}.detail-cell.svelte-86o8oz dd:where(.svelte-86o8oz){margin:0}.detail-cell.svelte-86o8oz code:where(.svelte-86o8oz){overflow-wrap:anywhere}.sr-only.svelte-86o8oz{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.pages.svelte-llv1r8{display:flex;flex-direction:column;gap:1rem}.stale.svelte-llv1r8{margin:0 0 .75rem;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);border-radius:var(--radius-sm);font-size:1.0625rem}.preview.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:1.5rem;align-items:flex-start}.sheet.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0;padding:.75rem;background:var(--waiting-wash);border-radius:var(--radius-lg)}img.svelte-llv1r8{width:40rem;max-width:100%;image-rendering:pixelated;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-1)}.refused.svelte-llv1r8{margin:0;padding:.5rem .75rem;background:var(--fault-wash);border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);font-size:1.0625rem}.nudges.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0}.axis.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0;font-size:1.125rem}.pair.svelte-llv1r8{display:flex;gap:var(--touch-gap)}.fault.svelte-llv1r8{margin:0;padding-left:.5rem;border-left:.25rem solid var(--fault);font-size:1rem;color:var(--ink-muted)}.check.svelte-llv1r8{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;font-size:1.0625rem}.check.svelte-llv1r8 input:where(.svelte-llv1r8){width:1.5rem;height:1.5rem;flex:0 0 auto}.truncation.svelte-llv1r8,.absent.svelte-llv1r8,.waiting.svelte-llv1r8,.bound.svelte-llv1r8,.cost.svelte-llv1r8{margin:1rem 0 0;font-size:1rem;color:var(--ink-muted)}.absent.svelte-llv1r8{margin-top:.5rem}.bound.svelte-llv1r8{margin-top:.25rem}.cost.svelte-llv1r8{margin-top:.5rem}.actions.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin-top:.75rem}.fact.svelte-geg7e6{margin:.5rem 0;font-size:1.125rem}.muted.svelte-geg7e6{color:var(--ink-muted);font-size:1rem}.rules.svelte-geg7e6{margin:.75rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.75rem}.rule.svelte-geg7e6{padding:.75rem 1rem 1rem;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm)}.rule-head.svelte-geg7e6{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0}.rank.svelte-geg7e6{flex:none;min-width:1.75rem;color:var(--ink-muted);font-variant-numeric:tabular-nums;font-weight:700}.rule-label.svelte-geg7e6{font-size:1.125rem;font-weight:700}.token.svelte-geg7e6{color:var(--ink-muted);font-size:.9375rem}.severity.svelte-geg7e6{margin-left:auto;padding:.125rem .625rem;font-size:.9375rem;border-radius:var(--radius-pill);background:var(--waiting-wash)}.severity[data-blocking=true].svelte-geg7e6{background:var(--warning-wash)}.when.svelte-geg7e6{margin:.375rem 0 0;font-size:1rem}.quote.svelte-geg7e6{margin:.5rem 0 0;padding:.5rem .75rem;font-size:1.0625rem;background:var(--surface);border-radius:var(--radius-sm);box-shadow:var(--shadow-1)}.quote.silent.svelte-geg7e6{color:var(--ink-muted);font-size:1rem;box-shadow:none;background:var(--waiting-wash)}.note.svelte-geg7e6{margin:.5rem 0 0;font-size:1rem;color:var(--ink-muted)}.box.svelte-geg7e6{display:contents}.fact.svelte-9g4joo{margin:.5rem 0;font-size:1.125rem}.muted.svelte-9g4joo,.locked.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}.scroll.svelte-9g4joo{overflow-x:auto}table.svelte-9g4joo{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-9g4joo,td.svelte-9g4joo{padding:.375rem .5rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}input.svelte-9g4joo{min-height:2.75rem;width:100%;min-width:6rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.hint.svelte-9g4joo{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.fact.svelte-65t3t4{margin:.5rem 0;font-size:1.125rem}.muted.svelte-65t3t4{color:var(--ink-muted);font-size:1rem}.verdicts.svelte-65t3t4{margin:0;padding:0;list-style:none}.waivers.svelte-65t3t4{max-height:24rem;overflow-y:auto}.verdicts.svelte-65t3t4 li:where(.svelte-65t3t4){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.token.svelte-65t3t4{color:var(--ink-muted);font-size:.9375rem}.what.svelte-65t3t4,.message.svelte-65t3t4{font-weight:700}.detail.svelte-65t3t4{color:var(--ink-muted)}.dead.svelte-65t3t4{flex:1 1 20rem;padding:.125rem .625rem;font-size:1rem;background:var(--warning-wash);border-radius:var(--radius-sm)}.unread.svelte-65t3t4{margin:.5rem 0;padding:.5rem .75rem;font-size:1rem;background:var(--fault-wash);border-left:.25rem solid var(--fault);border-radius:var(--radius-sm)}.pages.svelte-hzfkjn{display:flex;flex-direction:column;gap:1rem}.fact.svelte-hzfkjn{margin:.5rem 0;font-size:1.125rem}.muted.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem}h3.svelte-hzfkjn{margin:1.5rem 0 .5rem;font-size:1.25rem}.verdicts.svelte-hzfkjn{margin:0;padding:0;list-style:none}.verdicts.svelte-hzfkjn li:where(.svelte-hzfkjn){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.verdicts.svelte-hzfkjn li[data-blocking=true]:where(.svelte-hzfkjn){border-left:.25rem solid var(--warning);background:var(--warning-wash);padding-left:.5rem}.token.svelte-hzfkjn{color:var(--ink-muted);font-size:.9375rem}.what.svelte-hzfkjn,.message.svelte-hzfkjn{font-weight:700}.detail.svelte-hzfkjn{color:var(--ink-muted)}.scroll.svelte-1fkeh3r{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}table.svelte-1fkeh3r{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-1fkeh3r,td.svelte-1fkeh3r{padding:.375rem .75rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-1fkeh3r{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.fact.svelte-8utf8s{margin:.5rem 0;font-size:1.125rem}.muted.svelte-8utf8s{color:var(--ink-muted);font-size:1rem}.faults.svelte-8utf8s{margin-top:.75rem;padding:.25rem 1rem .5rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-8utf8s ul:where(.svelte-8utf8s){margin:0;padding-left:1.25rem;font-size:1.0625rem}.allowed.svelte-8utf8s{display:block;color:var(--ink-muted);font-size:1rem}dd.svelte-mgmug7{margin:0 0 1.25rem;display:flex;flex-direction:column;align-items:flex-start;gap:.5rem}.faults.svelte-mgmug7{margin-top:.75rem;padding:.5rem 1rem .5rem 2rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-mgmug7 li:where(.svelte-mgmug7){margin-bottom:.25rem}.pages.svelte-18wbxwu{display:flex;flex-direction:column;gap:1rem}.fact.svelte-18wbxwu{margin:.5rem 0;font-size:1.125rem}.muted.svelte-18wbxwu{color:var(--ink-muted);font-size:1rem}.filename.svelte-18wbxwu{margin-top:1rem;font-weight:700}.identity.svelte-18wbxwu{margin:1rem 0 0;padding:.75rem 1rem;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.0625rem;background:var(--bg);border-radius:var(--radius)}.identity.svelte-18wbxwu dt:where(.svelte-18wbxwu){color:var(--ink-muted)}.identity.svelte-18wbxwu dd:where(.svelte-18wbxwu){margin:0;font-weight:700}.actions.svelte-18wbxwu{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.choose.svelte-18wbxwu{display:inline-flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.choose.svelte-18wbxwu:active:not(.off){transform:scale(.975)}@media(hover:hover){.choose.svelte-18wbxwu:hover:not(.off){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.choose.off.svelte-18wbxwu{opacity:.5;box-shadow:none;cursor:default}.choose.working.svelte-18wbxwu{opacity:1;border-color:var(--waiting)}.choose.svelte-18wbxwu input:where(.svelte-18wbxwu){display:none}.key.svelte-18wbxwu{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.same.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--ready);border-radius:var(--radius-sm);background:var(--ready-wash)}.warned.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);background:var(--fault-wash)}.scroll.svelte-18wbxwu{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}.rows.svelte-18wbxwu{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.5rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu):first-child{border-top:none}.what.svelte-18wbxwu{font-weight:700}.detail.svelte-18wbxwu{color:var(--ink-muted)}.big.svelte-15nlt6i{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.write.svelte-15nlt6i{color:var(--surface);background:var(--action);border-color:var(--action)}.destructive.svelte-15nlt6i{color:var(--surface);background:var(--danger);border-color:var(--danger)}.write.svelte-15nlt6i .hint:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .hint:where(.svelte-15nlt6i){color:var(--surface);opacity:.85}.write.svelte-15nlt6i .guarded:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .guarded:where(.svelte-15nlt6i){background:var(--surface);color:var(--ink)}.big.svelte-15nlt6i:disabled{opacity:.5}.big.busy.svelte-15nlt6i{opacity:1;border-color:var(--waiting);background:var(--waiting-wash)}.big.busy.write.svelte-15nlt6i{background:var(--action)}.big.busy.destructive.svelte-15nlt6i{background:var(--danger)}.guarded.svelte-15nlt6i{margin-left:.5rem;padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;vertical-align:middle}.big.engaged.svelte-15nlt6i{border-left:.5rem solid var(--warning)}.label.svelte-15nlt6i{font-size:1.375rem;font-weight:700}.hint.svelte-15nlt6i{font-size:1rem;color:var(--ink-muted)}.troubleshooting.svelte-1yd0nzg{display:flex;flex-direction:column;gap:1rem}.buttons.svelte-1yd0nzg{display:grid;grid-template-columns:repeat(auto-fit,minmax(22rem,1fr));gap:var(--touch-gap)}.big.svelte-1yd0nzg{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.label.svelte-1yd0nzg{font-size:1.375rem;font-weight:700}.hint.svelte-1yd0nzg{font-size:1rem;color:var(--ink-muted)}.report.svelte-1yd0nzg{margin:0;padding:.75rem 1rem;font-size:1.125rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--ready);border-radius:var(--radius)}.drop.svelte-1yd0nzg{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius)}.drop.dropping.svelte-1yd0nzg{border-color:var(--focus)}.drop.svelte-1yd0nzg p:where(.svelte-1yd0nzg){margin:0;font-size:1.125rem}.key.svelte-1yd0nzg{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.choose.svelte-1yd0nzg{display:inline-flex;align-items:center;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius);cursor:pointer}@media(hover:hover){.choose.svelte-1yd0nzg:hover{filter:brightness(.92)}}.choose.svelte-1yd0nzg input:where(.svelte-1yd0nzg){display:none}.fact.svelte-1yd0nzg{margin:0 0 .5rem;font-size:1.125rem}.muted.svelte-1yd0nzg{color:var(--ink-muted)}.row.svelte-o9s8nz{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem}.admin.svelte-9b2mjq{position:fixed;inset:0;z-index:90;display:grid;grid-template-columns:16rem 1fr;background:var(--bg);color:var(--ink);overflow:hidden}.rail.svelte-9b2mjq{display:flex;flex-direction:column;gap:.125rem;padding:1rem .75rem;overflow-y:auto;background:var(--surface);border-right:1px solid var(--border)}h1.svelte-9b2mjq{margin:0 .5rem 1rem;font-size:1.25rem;letter-spacing:-.01em}.group.svelte-9b2mjq{margin:1rem .5rem .375rem;color:var(--ink-muted);font-size:.8125rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase}.entry.svelte-9b2mjq{padding:0 .75rem;height:2.5rem;display:flex;align-items:center;text-align:left;font-size:1.0625rem;border-radius:var(--radius-sm);color:var(--ink-muted);transition:background-color var(--tap) var(--ease),color var(--tap) var(--ease)}@media(hover:hover){.entry.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.entry.current.svelte-9b2mjq{background:var(--bg);color:var(--ink);font-weight:700;box-shadow:inset .1875rem 0 0 0 var(--focus)}.foot.svelte-9b2mjq{margin-top:auto;padding:1rem .5rem 0;border-top:1px solid var(--border)}.station.svelte-9b2mjq{margin:0;font-size:1rem;font-weight:600}.build.svelte-9b2mjq{margin:.125rem 0 0;font-size:.8125rem;color:var(--ink-muted)}.technical.svelte-9b2mjq{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;margin-top:.75rem;color:var(--ink-muted);font-size:.9375rem}.technical.svelte-9b2mjq input:where(.svelte-9b2mjq){width:1.5rem;height:1.5rem;flex:0 0 auto}.back.svelte-9b2mjq{margin-top:.75rem;padding:0 .5rem;height:2.25rem;font-size:.9375rem;color:var(--ink-muted);border-radius:var(--radius-sm)}@media(hover:hover){.back.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.body.svelte-9b2mjq{display:flex;flex-direction:column;min-width:0;overflow:hidden}.page.svelte-9b2mjq{flex:1;min-height:0;overflow-y:auto;padding:1.5rem 2rem 3rem}.page.svelte-9b2mjq>*{max-width:68rem}.page-title.svelte-9b2mjq{margin:0 0 1.25rem;font-size:1.75rem;letter-spacing:-.01em}.waiting.svelte-9b2mjq{margin:0;font-size:1.125rem;color:var(--ink-muted)}.banner.svelte-9b2mjq{flex:none;margin:0;padding:.75rem 2rem;font-size:1.0625rem;background:var(--surface);border-bottom:1px solid var(--border)}.notice.svelte-9b2mjq{border-left:.375rem solid var(--ready);background:var(--ready-wash)}.failure.svelte-9b2mjq{border-left:.375rem solid var(--fault);background:var(--fault-wash)}.pending.svelte-9b2mjq{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;border-left:.375rem solid var(--warning);background:var(--warning-wash)}.pending.svelte-9b2mjq p:where(.svelte-9b2mjq){margin:0;flex:1 1 24rem}.save-bar.svelte-9b2mjq{flex:none;padding:.75rem 2rem;background:var(--surface);border-top:1px solid var(--border);box-shadow:var(--shadow-1)}.retired.svelte-9b2mjq{margin:0 0 .5rem;font-size:.9375rem;color:var(--ink-muted)}.faults.svelte-9b2mjq{margin:0 0 .5rem;padding-left:1.25rem;font-size:1rem} diff --git a/internal/web/dist/assets/mount-VqoFW5_m.js b/internal/web/dist/assets/mount-VqoFW5_m.js new file mode 100644 index 0000000..5aa882a --- /dev/null +++ b/internal/web/dist/assets/mount-VqoFW5_m.js @@ -0,0 +1,103 @@ +import{Y as Dr,Z as Fr,_ as Ur,$ as Aa,a0 as Vt,e as Mr,a1 as mr,a2 as Wr,a3 as Br,a4 as Vr,u as _a,a5 as Oa,a6 as Gr,a7 as hr,x as _t,A as H,h as e,z as v,H as dt,d as it,a as Be,s as n,i as h,t as f,l as Dt,j as _e,m as o,b as Ce,c as l,o as s,g as c,p as Ue,I as Hr,f as Me,n as u,q as te,a8 as Ba,r as Re,G as Gt,a9 as ea,aa as zt,B as pa,ab as nt,ac as Qt,ad as Ta,C as Jr,J as _r,y as ia,T as Kr,ae as Jt,F as $r,E as Yr,D as Xr,O as Qr,K as Zr,X as en,af as tn}from"./app-BaRSXv80.js";function br(a){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function an(a,t,...r){var i=new Ur(a);Dr(()=>{const d=t()??null;i.ensure(d,d&&(p=>d(p,...r)))},Fr)}function Na(a,t,r=!1){if(a.multiple){if(t==null)return;if(!Wr(t))return Br();for(var i of a.options)i.selected=t.includes(oa(i));return}for(i of a.options){var d=oa(i);if(Vr(d,t)){i.selected=!0;return}}(!r||t!==void 0)&&(a.selectedIndex=-1)}function wr(a){var t=new MutationObserver(()=>{"__value"in a&&Na(a,a.__value)});t.observe(a,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),mr(()=>{t.disconnect()})}function rn(a,t,r=t){var i=new WeakSet,d=!0;Aa(a,"change",p=>{var x=p?"[selected]":":checked",A;if(a.multiple)A=[].map.call(a.querySelectorAll(x),oa);else{var _=a.querySelector(x)??a.querySelector("option:not([disabled])");A=_&&oa(_)}r(A),a.__value=A,Vt!==null&&i.add(Vt)}),Mr(()=>{var p=t();if(a===document.activeElement){var x=Vt;if(i.has(x))return}if(Na(a,p,d),d&&p===void 0){var A=a.querySelector(":checked");A!==null&&(p=oa(A),r(p))}a.__value=p,d=!1}),wr(a)}function oa(a){return"__value"in a?a.__value:a.value}function ua(a,t,r=t){var i=new WeakSet;Aa(a,"input",async d=>{var p=d?a.defaultValue:a.value;if(p=La(a)?Ea(p):p,r(p),Vt!==null&&i.add(Vt),await Gr(),p!==(p=t())){var x=a.selectionStart,A=a.selectionEnd,_=a.value.length;if(a.value=p??"",A!==null){var P=a.value.length;x===A&&A===_&&P>_?(a.selectionStart=P,a.selectionEnd=P):(a.selectionStart=x,a.selectionEnd=Math.min(A,P))}}}),_a(t)==null&&a.value&&(r(La(a)?Ea(a.value):a.value),Vt!==null&&i.add(Vt)),Oa(()=>{var d=t();if(a===document.activeElement){var p=Vt;if(i.has(p))return}La(a)&&d===Ea(a.value)||a.type==="date"&&!d&&!a.value||d!==a.value&&(a.value=d??"")})}function Va(a,t,r=t){Aa(a,"change",i=>{var d=i?a.defaultChecked:a.checked;r(d)}),_a(t)==null&&r(a.checked),Oa(()=>{var i=t();a.checked=!!i})}function La(a){var t=a.type;return t==="number"||t==="range"}function Ea(a){return a===""?null:+a}function Ga(a,t,r,i,d){var p=()=>{i(r[a])};r.addEventListener(t,p),d?Oa(()=>{r[a]=d()}):p(),(r===document.body||r===window||r===document)&&mr(()=>{r.removeEventListener(t,p)})}function Ia(a){hr===null&&br(),_t(()=>{const t=_a(a);if(typeof t=="function")return t})}function nn(a){hr===null&&br(),Ia(()=>()=>_a(a))}const sn="ERR-CFG-02",Ha=4;class Tt extends Error{constructor(t,r,i="",d=[]){super(r),this.status=t,this.code=i,this.faults=d,this.name="AdminError"}status;code;faults;get needsPassword(){return this.status===401}get needsFirstPassword(){return this.status===409&&this.code===sn}get needsCredentials(){return this.needsPassword||this.needsFirstPassword}}function ma(a){return a instanceof Tt&&a.needsCredentials}function ln(){return Ft("/admin/api/health")}function on(){return Ge("/admin/api/troubleshooting/reprint",{})}function un(){return Ge("/admin/api/troubleshooting/reload-catalog",{})}function cn(a){return Ge("/admin/api/troubleshooting/manual-entry",{on:a})}function dn(){return Ge("/admin/api/troubleshooting/roll-changed",{})}function vn(a){return Ge("/admin/api/troubleshooting/fallback-printer",{on:a})}function pn(){return Ge("/admin/api/troubleshooting/test-scale",{})}function fn(){return Ge("/admin/api/troubleshooting/test-printer",{})}function gn(){return Ge("/admin/api/troubleshooting/test-label",{})}async function yr(a){const t=new FormData;t.append("file",a,a.name);const r=await fetch("/admin/api/catalog/import",{method:"POST",body:t});return Ua(r,"POST /admin/api/catalog/import")}const mn="/admin/api/diagnostic.zip";function hn(a){return Ge("/admin/api/session",{password:a})}async function _n(){await fetch("/admin/api/session",{method:"DELETE"})}function bn(a,t){return Ge("/admin/api/session/recovery",{code:a,password:t})}function Da(){return Ft("/admin/api/config")}function wn(a){return Fa("PUT","/admin/api/config",a)}function yn(){return Ge("/admin/api/config/reload",{})}function xn(){return Ge("/admin/api/restart",{})}function kn(){return Ge("/admin/api/reboot",{})}function Sn(){return Fa("DELETE","/admin/api/reboot",{})}function qn(){return Ge("/admin/api/config/confirm",{})}async function Ln(){return(await Ft("/admin/api/config/versions")).versions}function En(a){return Ge("/admin/api/config/restore",{version:a})}async function Cn(){return(await Ft("/admin/api/ports")).ports}async function Pn(){return(await Ft("/admin/api/printers")).printers}async function Tn(){return(await Ge("/admin/api/printers/discover",{})).printers}function zn(a){return Ge("/admin/api/scale/detect",{port:a})}async function Ja(a,t){return(await Ge("/admin/api/scale/capture",{port:a,seconds:t})).frames}function xr(a){return Ge("/admin/api/printer/test?what="+a,{})}function Rn(a,t,r,i){const d=new URLSearchParams({template:a,demo:t?"1":"0",dual:r?"1":"0"});return d.set("t",String(i)),"/admin/api/label/preview.png?"+d.toString()}async function jn(a){const t=new URLSearchParams(a);return(await Ft("/admin/api/journal?"+t)).weighings}function An(a){return"/admin/api/journal/export.csv?"+new URLSearchParams(a).toString()}async function On(a){const t=new URLSearchParams(a);return(await Ft("/admin/api/technical?"+t)).entries}function Nn(a){const t=a===void 0?"":"?id="+String(a);return Ft("/admin/api/imports"+t)}function In(){return Ge("/admin/api/catalog/reload",{})}function Dn(){return Ge("/admin/api/catalog/forget-quarantine",{})}function Ka(a,t){return Ge("/admin/api/products/"+encodeURIComponent(a)+"/decision",t)}function Fn(a){return Ge("/admin/api/replay",{frame:a})}async function Ft(a){const t=await fetch(a,{headers:{accept:"application/json"}});return Ua(t,"GET "+a)}function Ge(a,t){return Fa("POST",a,t)}async function Fa(a,t,r){const i=await fetch(t,{method:a,headers:{"content-type":"application/json"},body:JSON.stringify(r)});return Ua(i,a+" "+t)}async function Ua(a,t){const r=await a.text();if(!a.ok){const i=kr(r);throw new Tt(a.status,Mn(r,t,a.status)+Un(a),i?.code??"",i?.faults??[])}if(r!=="")return JSON.parse(r)}function Un(a){if(a.status!==429)return"";const t=Number(a.headers.get("Retry-After")??"");if(!Number.isFinite(t)||t<=0)return"";const r=Math.ceil(t/60);return r>1?` Réessayez dans ${String(r)} minutes.`:" Réessayez dans une minute."}function Mn(a,t,r){const i=kr(a);return i?.message!==void 0&&i.message!==""?i.message:`${t} a répondu ${String(r)}.`}function kr(a){try{return JSON.parse(a)}catch{return null}}function Wn(){return Ft("/admin/api/update")}function Bn(){return Ge("/admin/api/update/check",{})}function Vn(a){return Ge("/admin/api/update/apply",{version:a})}class Gn{constructor(t){this.admin=t}admin;#t=H(null);get config(){return e(this.#t)}set config(t){v(this.#t,t,!0)}#r=H("");get fingerprint(){return e(this.#r)}set fingerprint(t){v(this.#r,t,!0)}#a=H(dt([]));get retired(){return e(this.#a)}set retired(t){v(this.#a,t,!0)}#e=H(null);get pending(){return e(this.#e)}set pending(t){v(this.#e,t,!0)}#n=H(dt([]));get faults(){return e(this.#n)}set faults(t){v(this.#n,t,!0)}#l=H(!1);get dirty(){return e(this.#l)}set dirty(t){v(this.#l,t,!0)}async load(){const t=await this.admin.load(()=>Da());t!==null&&(this.config=t.config,this.fingerprint=t.config_fingerprint,this.retired=t.retired_keys??[],this.pending=t.pending_confirmation,this.faults=[],this.dirty=!1)}value(t){let r=this.config;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}text(t){const r=this.value(t);return r==null?"":String(r)}number(t){const r=this.value(t);return typeof r=="number"?r:Number(r??0)}flag(t){return this.value(t)===!0}set(t,r){if(this.config===null)return;const i=t.split("."),d=i.pop();if(d===void 0)return;let p=this.config;for(const x of i){const A=p[x];(A===null||typeof A!="object")&&(p[x]={}),p=p[x]}p[d]=r,this.dirty=!0}unset(t){const r=t.split("."),i=r.pop();if(i===void 0||this.config===null)return!1;let d=this.config;for(const p of r){if(d===null||typeof d!="object")return!1;d=d[p]}return d===null||typeof d!="object"?!1:(delete d[i],this.dirty=!0,!0)}async save(){if(this.config===null)return!1;this.faults=[];const t=this.config;let r;try{r=await wn(t)}catch(i){if(ma(i))throw i;return this.admin.report(i),this.faults=Hn(this.admin),!1}return this.config=r.config,this.fingerprint=r.config_fingerprint,this.retired=r.retired_keys??[],this.pending=r.pending_confirmation,this.dirty=!1,!0}async confirm(){try{await qn()}catch(t){if(ma(t))throw t;this.admin.report(t);return}this.admin.notice="La configuration est confirmée.",this.pending=null,await this.admin.refresh()}dropRetired(t){if(t.includes("[")){this.admin.actionError=`« ${t} » est logée dans un tableau : cet écran ne sait pas la retirer de là. Modifiez le fichier de configuration lui-même.`;return}this.unset(t)&&(this.retired=this.retired.filter(r=>r!==t))}}function Hn(a){return a.lastFaults}const Jn={"station.number":"Numéro du poste","station.name":"Nom du poste","station.coop":"Nom de la coopérative","network.listen":"Adresse d’écoute","network.admin_on_lan":"Administration accessible depuis le réseau","ui.language":"Langue","ui.sound":"Son","ui.idle_timeout_s":"Retour à l’accueil après (secondes)","ui.reprint_window_s":"Réimpression possible pendant (secondes)","ui.show_grid_prices":"Afficher les prix sur les tuiles","ui.show_by_unit_products":"Afficher les produits vendus à l’unité","ui.grid_columns":"Colonnes de la grille","ui.min_products_for_chip":"Articles minimum pour afficher une catégorie","scale.type":"Protocole de la balance","scale.present":"Ce poste a une balance","scale.manual_entry_allowed":"Saisie manuelle autorisée","scale.degrade_after_s":"Passer en mode dégradé après (secondes)","scale.options.port":"Port série","scale.options.baud":"Vitesse (bauds)","scale.options.bits":"Bits de données","scale.options.parity":"Parité","scale.options.stop":"Bits d’arrêt","scale.options.backoff_min_ms":"Attente minimale avant réessai (ms)","scale.options.backoff_max_ms":"Attente maximale avant réessai (ms)","printer.type":"Pilote d’impression","printer.template":"Gabarit d’étiquette","printer.options.transport":"Transport","printer.options.queue":"File d’impression","printer.options.path":"Fichier de périphérique","printer.options.address":"Adresse réseau","printer.options.darkness":"Noircissement","printer.options.speed":"Vitesse d’impression","printer.options.offset_x":"Décalage horizontal (dots)","printer.options.offset_y":"Décalage vertical (dots)","printer.options.invert_bits":"Inverser les points","printer.options.copies":"Exemplaires","printer.options.roll_capacity":"Étiquettes par rouleau","pricing.amount_rounding":"Arrondi du montant","pricing.unit_price_rounding":"Arrondi du prix au kilo","pricing.primary_code":"Tarif principal","pricing.reference_code":"Tarif de référence","barcode.verify_reference_check_digit":"Vérifier la clé de contrôle","limits.empty_max_g":"Plateau considéré vide en dessous de (g)","limits.basket_check_enabled":"Vérifier la présence du panier","limits.basket_min_g":"Poids du panier, borne basse (g)","limits.basket_max_g":"Poids du panier, borne haute (g)","limits.min_weight_g":"Poids minimum accepté","limits.max_weight_g":"Poids maximum accepté","limits.max_tare_g":"Tare maximale","limits.min_units":"Unités minimum","limits.max_units":"Unités maximum","limits.max_amount_cents":"Montant maximum (centimes)","stability.mode":"Exigence de stabilité","stability.min_duration_ms":"Durée de stabilité (ms)","stability.tolerance_g":"Tolérance de stabilité (g)","stability.timeout_ms":"Délai d’attente de stabilité (ms)","stability.on_timeout":"Au bout du délai","stability.min_latch_rate":"Taux d’accroche minimal","stability.latch_rate_window_ms":"Fenêtre de mesure du taux (ms)","stability.expiry_floor_ms":"Péremption, plancher (ms)","stability.expiry_ceiling_ms":"Péremption, plafond (ms)","stability.expiry_factor":"Péremption, facteur","catalog.type":"Où le poste va chercher le catalogue","catalog.options.directory":"Répertoire surveillé","catalog.options.url":"Adresse du serveur","catalog.options.username":"Compte","catalog.options.password":"Mot de passe","catalog.options.separator":"Séparateur du CSV","catalog.options.poll_interval_s":"Vérifier toutes les (secondes)","catalog.options.stable_polls":"Vérifications avant lecture","catalog.options.max_file_size_mb":"Taille maximale du fichier (Mo)","catalog.options.max_image_size_kb":"Taille maximale d’une image (Ko)","catalog.options.min_readable_ratio":"Part minimale de lignes lisibles","catalog.options.max_weighable_drop":"Baisse maximale des produits pesables","catalog.options.max_archives":"Archives conservées","catalog.options.archive_days":"Archives conservées (jours)","catalog.options.failures_before_reject":"Échecs avant mise en quarantaine","catalog.images.source":"Origine des photos","catalog.images.path":"Répertoire des photos","catalog.fallback_category":"Rayon par défaut","journal.max_rows":"Pesées conservées","journal.max_days":"Pesées conservées (jours)","journal.max_technical":"Événements techniques conservés","admin.session_minutes":"Durée d’une session (minutes)","admin.attempts_per_minute":"Tentatives par minute","maintenance.weekly_integrity_check":"Contrôle d’intégrité hebdomadaire","maintenance.disk_alert_mb":"Alerte disque en dessous de (Mo)"};function St(a){return Jn[a]??a}const Kn={station:"l’identité du poste",network:"le réseau",ui:"l’écran client",scale:"la balance",printer:"l’imprimante",pricing:"les tarifs",barcode:"le code-barres",limits:"les garde-fous",stability:"la stabilité",catalog:"le catalogue",journal:"le journal",maintenance:"la maintenance"};function $n(a){return Kn[a]??a}const Yn={scale:"balance",printer:"imprimante",catalog:"catalogue",ui:"écran",config:"configuration",http:"réseau",system:"système"};function Sr(a){return Yn[a]??"origine inconnue"}const za="openscale.admin.preferences",qr="technical";class Xn{#t=H(dt(Qn()));get showTechnicalNames(){return e(this.#t)}set showTechnicalNames(t){v(this.#t,t,!0)}toggleTechnicalNames(){this.showTechnicalNames=!this.showTechnicalNames,Zn(this.showTechnicalNames)}}function Qn(){try{return globalThis.localStorage?.getItem(za)===qr}catch{return!1}}function Zn(a){try{a?globalThis.localStorage?.setItem(za,qr):globalThis.localStorage?.removeItem(za)}catch{}}const qt=new Xn,es=3e3,ts=["hardware","label","rules","catalog","journal","station","update"];function $a(a){return ts.includes(a)}function as(a){return a instanceof Tt&&a.needsCredentials}class rs{constructor(t=es){this.periodMs=t}periodMs;#t=H(null);get health(){return e(this.#t)}set health(t){v(this.#t,t,!0)}#r=H("dashboard");get page(){return e(this.#r)}set page(t){v(this.#r,t,!0)}#a=H(!1);get expert(){return e(this.#a)}set expert(t){v(this.#a,t,!0)}#e=H("");get linkError(){return e(this.#e)}set linkError(t){v(this.#e,t,!0)}#n=H("");get actionError(){return e(this.#n)}set actionError(t){v(this.#n,t,!0)}#l=H(!1);get needsFirstPassword(){return e(this.#l)}set needsFirstPassword(t){v(this.#l,t,!0)}#d=H("");get notice(){return e(this.#d)}set notice(t){v(this.#d,t,!0)}#v=H(!1);get busy(){return e(this.#v)}set busy(t){v(this.#v,t,!0)}#p=H(dt([]));get lastFaults(){return e(this.#p)}set lastFaults(t){v(this.#p,t,!0)}#f=H(null);get pending(){return e(this.#f)}set pending(t){v(this.#f,t,!0)}#i=null;#o=null;start(){this.refresh(),this.#i=setInterval(()=>{this.refresh()},this.periodMs)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null}async refresh(){try{this.health=await ln(),this.linkError=""}catch(t){this.linkError=Ya(t)}}#u(){this.busy=!0,this.notice="",this.actionError="",this.needsFirstPassword=!1}#s(t){this.actionError=Ya(t),this.needsFirstPassword=t instanceof Tt&&t.needsFirstPassword,this.#m(t)}async run(t){this.#u();let r=null;try{r=await t(),this.notice=r.message}catch(i){this.#s(i)}finally{this.busy=!1}return await this.refresh(),r}async login(t){this.#u();try{return await hn(t),this.expert=!0,this.notice="Session d’administration ouverte.",!0}catch(r){return this.expert=!1,this.#s(r),!1}finally{this.busy=!1}}async recover(t,r){this.#u();try{const i=await bn(t,r);return this.expert=!0,this.notice=i.warning??"Le mot de passe est remplacé et la session est ouverte.",!0}catch(i){return this.#s(i),!1}finally{this.busy=!1}}async protect(t){try{return await t()}catch(r){if(!as(r))return this.#s(r),null;if(!await this.#g(r))return null;try{return await t()}catch(i){return this.#s(i),null}}}#g(t){return this.pending={kind:t.needsFirstPassword?"first-password":"password",message:t.message},new Promise(r=>{this.#o=r})}async answerPassword(t){await this.login(t)&&this.#c(!0)}async answerRecovery(t,r){await this.recover(t,r)&&this.#c(!0)}cancelPassword(){this.#c(!1)}#c(t){this.pending=null,this.notice="";const r=this.#o;this.#o=null,r?.(t)}async logout(){await _n(),this.expert=!1,this.page="dashboard",this.notice="Session d’administration fermée."}open(t){this.actionError="",this.notice="",this.needsFirstPassword=!1,this.page=t}async load(t){try{const r=await t();return this.actionError="",this.lastFaults=[],r}catch(r){return this.lastFaults=r instanceof Tt?r.faults:[],this.#s(r),null}}report(t){this.lastFaults=t instanceof Tt?t.faults:[],this.#s(t)}#m(t){t instanceof Tt&&t.needsPassword&&(this.expert=!1)}}function Ya(a){return a instanceof Tt?a.message:a instanceof Error?"Le poste n’a pas répondu : "+a.message:"Le poste n’a pas répondu."}var ns=c('clé'),ss=c('');function ze(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"busy",3,!1),d=Be(t,"disabled",3,!1),p=Be(t,"protected",3,!1),x=Be(t,"act",3,void 0);var A=ss();let _;var P=s(A),w=n(P);{var N=L=>{var V=ns();l(L,V)};h(w,L=>{p()&&L(N)})}f(()=>{_=Dt(A,1,`act ${r()??""}`,"svelte-5tpq0o",_,{"touch-target":r()==="destructive",busy:i()}),_e(A,"data-kind",r()),_e(A,"data-act",x()),A.disabled=d()||i(),o(P,`${(i()?"En cours…":t.label)??""} `)}),Ce("click",A,function(...L){t.onrun?.apply(this,L)}),l(a,A)}it(["click"]);var ls=c(`

                                      Le code de secours a été tiré à l’installation de ce poste et imprimé + sur sa fiche, rangée dans le classeur du magasin. Il n’existe nulle part ailleurs.

                                      `,1),is=c(`

                                      Oublié ? Le code de secours de la fiche d’installation en repose un. + Un responsable peut aussi le faire en ligne de commande, avec openscale config password.

                                      `,1),os=c('

                                      '),us=c('');function cs(a,t){Ue(t,!0);const r=u(()=>t.admin.pending?.kind==="first-password");let i=H(""),d=H("");const p=u(()=>e(r)?e(d).trim().length>0&&[...e(i)].length>=Ha:e(i).length>0);async function x(){!e(p)||t.admin.busy||(e(r)?await t.admin.answerRecovery(e(d),e(i)):await t.admin.answerPassword(e(i)),v(i,""),v(d,""))}var A=us(),_=s(A),P=s(_),w=s(P),N=s(w);Hr(N,{name:"settings",size:"1.5rem"});var L=n(w,2),V=s(L),B=n(P,2),R=s(B),U=n(B,2);{var F=W=>{var T=ls(),S=n(te(T),2),k=n(s(S),2);Ba(k);var O=n(S,2),M=s(O),C=s(M),X=n(M,2);f(()=>o(C,`Nouveau mot de passe — ${Ha} caractères au moins`)),Ce("keydown",k,Z=>Z.key==="Enter"&&void x()),ua(k,()=>e(d),Z=>v(d,Z)),Ce("keydown",X,Z=>Z.key==="Enter"&&void x()),ua(X,()=>e(i),Z=>v(i,Z)),l(W,T)},E=W=>{var T=is(),S=te(T),k=n(s(S),2);Ba(k),Ce("keydown",k,O=>O.key==="Enter"&&void x()),ua(k,()=>e(i),O=>v(i,O)),l(W,T)};h(U,W=>{e(r)?W(F):W(E,-1)})}var y=n(U,2);{var m=W=>{var T=os(),S=s(T);f(()=>o(S,t.admin.actionError)),l(W,T)};h(y,W=>{t.admin.actionError!==""&&W(m)})}var D=n(y,2),J=s(D);ze(J,{label:"Annuler",onrun:()=>t.admin.cancelPassword()});var K=n(J,2);{let W=u(()=>e(r)?"Poser ce mot de passe":"Continuer"),T=u(()=>!e(p)||t.admin.busy);ze(K,{kind:"write",get label(){return e(W)},get disabled(){return e(T)},onrun:()=>{x()}})}f(()=>{o(V,e(r)?"Ce poste n’a pas encore de mot de passe":"Mot de passe d’administration"),o(R,t.admin.pending?.message??"")}),l(a,A),Me()}it(["keydown"]);var ds=c(' '),vs=c(""),ps=c(''),fs=c(''),gs=c(""),ms=c(""),hs=c('

                                      '),_s=c(" "),bs=c('

                                      '),ws=c('
                                      ');function Qe(a,t){Ue(t,!0);const r=Be(t,"hint",3,""),i=Be(t,"kind",3,"text"),d=Be(t,"disabled",3,!1),p=Be(t,"fault",3,""),x=Be(t,"allowed",19,()=>[]),A=Be(t,"choices",19,()=>[]),_=u(()=>`field-${t.path.replace(/\./gu,"-")}`);var P=ws();let w;var N=s(P),L=s(N),V=s(L),B=n(L,2);{var R=T=>{var S=ds(),k=s(S);f(()=>o(k,t.path)),l(T,S)};h(B,T=>{qt.showTechnicalNames&&T(R)})}var U=n(N,2);{var F=T=>{var S=ps();Re(S,21,A,O=>O.value,(O,M)=>{var C=vs(),X=s(C),Z={};f(()=>{o(X,e(M).label),Z!==(Z=e(M).value)&&(C.value=(C.__value=e(M).value)??"")}),l(O,C)});var k;wr(S),f(()=>{_e(S,"id",e(_)),S.disabled=d(),k!==(k=t.value)&&(S.value=(S.__value=t.value)??"",Na(S,t.value))}),Ce("change",S,O=>t.onchange(O.currentTarget.value)),l(T,S)},E=T=>{var S=fs();f(()=>{_e(S,"id",e(_)),_e(S,"type",i()),Gt(S,t.value),S.disabled=d(),_e(S,"list",x().length>0?e(_)+"-allowed":void 0)}),Ce("input",S,k=>t.onchange(k.currentTarget.value)),l(T,S)};h(U,T=>{A().length>0?T(F):T(E,-1)})}var y=n(U,2);{var m=T=>{var S=ms();Re(S,20,x,k=>k,(k,O)=>{var M=gs(),C={};f(()=>{C!==(C=O)&&(M.value=(M.__value=O)??"")}),l(k,M)}),f(()=>_e(S,"id",e(_)+"-allowed")),l(T,S)};h(y,T=>{x().length>0&&T(m)})}var D=n(y,2);{var J=T=>{var S=hs(),k=s(S);f(()=>o(k,r())),l(T,S)};h(D,T=>{r()!==""&&T(J)})}var K=n(D,2);{var W=T=>{var S=bs(),k=s(S),O=n(k);{var M=C=>{var X=_s(),Z=s(X);f(le=>o(Z,`Valeurs acceptées : ${le??""}.`),[()=>x().join(", ")]),l(C,X)};h(O,C=>{x().length>0&&C(M)})}f(()=>o(k,`${p()??""} `)),l(T,S)};h(K,T=>{p()!==""&&T(W)})}f(()=>{w=Dt(P,1,"field svelte-1oilv5s",null,w,{refused:p()!==""}),_e(N,"for",e(_)),o(V,t.label)}),l(a,P),Me()}it(["change","input"]);var ys=c('

                                      '),xs=c('

                                      ');function je(a,t){const r=Be(t,"note",3,"");var i=xs(),d=s(i),p=s(d),x=n(d,2);{var A=P=>{var w=ys(),N=s(w);f(()=>o(N,r())),l(P,w)};h(x,P=>{r()!==""&&P(A)})}var _=n(x,2);an(_,()=>t.children),f(()=>o(p,t.title)),l(a,i)}function Ke(a,t){return a.faults.find(r=>r.field===t)?.message??""}function Ht(a,t){return a.faults.find(r=>r.field===t)?.allowed??[]}var ks=c('

                                      Lecture des réglages du poste…

                                      '),Ss=c(`

                                      Le poste y cherche le fichier , et le supprime + une fois lu : c’est ce qui dit au producteur que la livraison est prise.

                                      `,1),qs=c(`

                                      Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus + possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours + du jour de la mise en service.

                                      `,1),Ls=c(`

                                      Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira + chercher aucun catalogue.

                                      `),Es=c('
                                      ',1);function Cs(a,t){Ue(t,!0);const r={local_drop:["catalog.options.directory"],webdav:["catalog.options.url","catalog.options.username","catalog.options.password"]},i=u(()=>t.draft.text("catalog.type"));function d(p){t.draft.set("catalog.type",p);for(const[x,A]of Object.entries(r))if(x!==p)for(const _ of A)t.draft.unset(_)}je(a,{title:"Où le poste va chercher le catalogue",children:(p,x)=>{var A=Es(),_=te(A),P=s(_),w=s(P),N=n(P,2),L=s(N),V=n(_,2);{var B=E=>{var y=ks();l(E,y)},R=E=>{var y=Ss(),m=te(y);{let W=u(()=>St("catalog.options.directory")),T=u(()=>t.draft.text("catalog.options.directory")),S=u(()=>Ke(t.draft,"catalog.options.directory"));Qe(m,{get label(){return e(W)},path:"catalog.options.directory",get value(){return e(T)},hint:"Laissez vide pour le répertoire du poste, celui que le service crée lui-même. Un répertoire nommé ici doit exister : le poste ne le crée pas.",get fault(){return e(S)},onchange:k=>t.draft.set("catalog.options.directory",k)})}var D=n(m,2),J=n(s(D)),K=s(J);f(()=>o(K,`flv_${t.station??""}.csv`)),l(E,y)},U=E=>{var y=qs(),m=te(y);{let K=u(()=>St("catalog.options.url")),W=u(()=>t.draft.text("catalog.options.url")),T=u(()=>Ke(t.draft,"catalog.options.url"));Qe(m,{get label(){return e(K)},path:"catalog.options.url",get value(){return e(W)},get fault(){return e(T)},onchange:S=>t.draft.set("catalog.options.url",S)})}var D=n(m,2);{let K=u(()=>St("catalog.options.username")),W=u(()=>t.draft.text("catalog.options.username")),T=u(()=>Ke(t.draft,"catalog.options.username"));Qe(D,{get label(){return e(K)},path:"catalog.options.username",get value(){return e(W)},get fault(){return e(T)},onchange:S=>t.draft.set("catalog.options.username",S)})}var J=n(D,2);{let K=u(()=>St("catalog.options.password")),W=u(()=>Ke(t.draft,"catalog.options.password"));Qe(J,{get label(){return e(K)},path:"catalog.options.password",kind:"password",value:"",hint:"Laissez vide : le mot de passe actuel est conservé.",get fault(){return e(W)},onchange:T=>t.draft.set("catalog.options.password",T)})}l(E,y)},F=E=>{var y=Ls();l(E,y)};h(V,E=>{t.draft.config===null?E(B):e(i)==="local_drop"?E(R,1):e(i)==="webdav"?E(U,2):E(F,-1)})}f(()=>{ea(w,e(i)==="local_drop"),ea(L,e(i)==="webdav")}),Ce("change",w,()=>d("local_drop")),Ce("change",L,()=>d("webdav")),l(p,A)},$$slots:{default:!0}}),Me()}it(["change"]);const Xa=1e3;function fa(a){const t=Ma(a);return t===null?"":[Zt(t.getDate()),Zt(t.getMonth()+1),String(t.getFullYear())].join("/")}function Lt(a){const t=Ma(a);return t===null?"":`${fa(a)} à ${Zt(t.getHours())}:${Zt(t.getMinutes())}`}function Ps(a){const t=Ma(a);return t===null?"":[Zt(t.getHours()),Zt(t.getMinutes()),Zt(t.getSeconds())].join(":")}function Ra(a){const t=a/1e6;return t<1e3?`${Math.round(t)} Mo`:`${Lr(t/1e3)} Go`}function ne(a){const t=String(Math.abs(Math.trunc(a))),r=[];for(let i=t.length;i>0;i-=3)r.unshift(t.slice(Math.max(0,i-3),i));return(a<0?"-":"")+r.join(" ")}function da(a){return a0}function Rs(a){const t=a.motive!=="";return{canWithdraw:t||!ga(!1,a.waiverInForce),canOfferAgain:t||!ga(!0,a.waiverInForce),canSaveWaiver:zs(a.typedWaiver)&&(t||!ga(a.offeredInForce,a.typedWaiver)),canDropWaiver:t||!ga(a.offeredInForce,null)}}function Er(a){const t=[];return a.offered||t.push("retiré de la grille"),a.min_weight_g!==null&&t.push(`peut peser à partir de ${ne(a.min_weight_g)} g`),t.length===0?"aucune restriction":t.join(" · ")}function va(a,t,r,i){const d=t>1?i:r;return a>=t?`${ne(t)} ${d}.`:`${ne(a)} lignes affichées sur ${ne(t)} ${d}.`}function js(a,t){return t>a?`${ne(a)} produits affichés sur ${ne(t)} trouvés — précisez votre recherche.`:`${ne(t)} ${t>1?"produits trouvés":"produit trouvé"}.`}function As(a){return`${ne(a)} ${a>1?"imports affichés":"import affiché"} : le poste n’en publie jamais plus de vingt.`}function Ca(a,t,r,i){const d=`${ne(r)} ${r>1?t:a}`;return r<=i?d+".":`${d} — seules les ${ne(i)} premières lignes sont affichées.`}function Os(a,t){const r=`${ne(a)} ${a>1?"produits retirés":"produit retiré"}`;return t===""?r+".":`${r} depuis l’import du ${Lt(t)}.`}var Ns=c('

                                      Aucune décision locale : la grille est celle du fichier.

                                      '),Is=c('
                                    • '),Ds=c('

                                        ',1);function Fs(a,t){Ue(t,!0);const r=20,i=u(()=>t.decisions.slice(0,r));je(a,{title:"Décisions en vigueur",note:"Ce qu’un humain a décidé, produit par produit. C’est ici que se reprend un produit retiré de la grille.",children:(d,p)=>{var x=zt(),A=te(x);{var _=w=>{var N=Ns();l(w,N)},P=w=>{var N=Ds(),L=te(N),V=s(L),B=n(L,2),R=s(B);Re(R,21,()=>e(i),U=>U.product_id,(U,F)=>{var E=Is(),y=s(E),m=n(y,2),D=s(m),J=n(m,2),K=s(J),W=n(J,2),T=s(W),S=n(W,2),k=s(S),O=n(S,2),M=s(O);f((C,X,Z)=>{o(D,C),o(K,e(F).product_id),o(T,X),o(k,e(F).reason),o(M,Z)},[()=>t.nameOf(e(F).product_id),()=>Er(e(F)),()=>fa(e(F).decided_at)]),Ce("click",y,()=>t.onchoose(e(F).product_id)),l(U,E)}),f(U=>o(V,U),[()=>va(e(i).length,t.decisions.length,"décision en vigueur","décisions en vigueur")]),l(w,N)};h(A,w=>{t.decisions.length===0?w(_):w(P,-1)})}l(d,x)},$$slots:{default:!0}}),Me()}it(["click"]);function Us(a){return a==="loading"?"Lecture des signalements du dernier import…":"Les signalements du dernier import n’ont pas pu être lus : cet écran ne sait pas ce qu’ils disent."}function Ms(a){return a==="loading"?"Lecture de l’historique des imports…":"L’historique des imports n’a pas pu être lu : cet écran ne sait pas ce qu’il contient."}function Wa(a,t,r){return a!==void 0?a:t==="loading"?"Lecture du nom…":t==="unread"?r:"Produit absent du catalogue en service"}var Qa=c('

                                        '),Ws=c(' '),Bs=c('
                                      • '),Vs=c('

                                          ',1);function Pa(a,t){Ue(t,!0);const r=Be(t,"remedy",3,""),i=50,d=u(()=>t.findings.slice(0,i)),p=u(()=>t.findings.length>e(d).length);je(a,{get title(){return t.title},get note(){return t.note},children:(x,A)=>{var _=zt(),P=te(_);{var w=V=>{var B=Qa(),R=s(B);f(U=>{_e(B,"data-unread",t.list),o(R,U)},[()=>Us(t.state)]),l(V,B)},N=V=>{var B=Qa(),R=s(B);f(()=>o(R,t.none)),l(V,B)},L=V=>{var B=Vs(),R=te(B),U=s(R),F=n(U);{var E=D=>{var J=nt();f(()=>o(J,r())),l(D,J)};h(F,D=>{e(p)&&r()!==""&&D(E)})}var y=n(R,2),m=s(y);Re(m,21,()=>e(d),pa,(D,J)=>{var K=Bs(),W=s(K),T=s(W),S=n(W,2);{var k=j=>{var b=Ws(),z=s(b);f(()=>o(z,e(J).product_name)),l(j,b)};h(S,j=>{e(J).product_name!==""&&j(k)})}var O=n(S,2),M=s(O),C=n(O,2),X=s(C),Z=n(C,2),le=s(Z);f(j=>{o(T,`ligne ${j??""}`),o(M,e(J).product_id),o(X,e(J).value),o(le,e(J).message)},[()=>ne(e(J).csv_line)]),l(D,K)}),f(D=>{_e(R,"data-tally",t.list),o(U,`${D??""} `),_e(y,"data-rows",t.list)},[()=>va(e(d).length,t.findings.length,t.singular,t.plural)]),l(V,B)};h(P,V=>{t.state!=="read"?V(w):t.findings.length===0?V(N,1):V(L,-1)})}l(x,_)},$$slots:{default:!0}}),Me()}function Cr(a,t){const r=a.rows_read_count,i=a.images_decoded_count,d=r-i,p=[{count:ht(a.weighable_count),label:"pesables",note:`(${ht(i)} avec photo, ${ht(d)} sans)`,link:""},{count:ht(a.not_weighable_count),label:"non pesables",note:Xs(t),link:""}];if(a.anomalies_count>0&&p.push({count:ht(a.anomalies_count),label:"anomalies",note:"à corriger dans Odoo",link:Za(a.anomalies_count)}),a.unit_mismatches_count>0){const x=a.unit_mismatches_count;p.push({count:"+ "+ht(x),label:x===1?"unité divergente":"unités divergentes",note:"pesable, unité à corriger",link:Za(x)})}return{headline:`Catalogue du ${fa(a.occurred_at)} — ${ht(r)} produits reçus`,lines:p,oneLine:Gs(a)}}function Gs(a){const t=[`${ht(a.rows_read_count)} reçus`,`${ht(a.weighable_count)} pesables`,`${ht(a.not_weighable_count)} non pesables`,`${ht(a.anomalies_count)} anomalies`],r=a.unit_mismatches_count;return r>0&&t.push(`${ht(r)} ${r===1?"unité divergente":"unités divergentes"}`),t.join(" · ")}const Hs={applied:"appliqué",unchanged:"identique au précédent",rejected:"refusé",failed:"échec"},Js={local_drop:"dépôt local",webdav:"WebDAV",manual:"déposé sur l’écran"};function ba(a){return Hs[a]??"résultat inconnu"}function Pr(a){return Js[a]??"source inconnue"}function Ks(a,t){const r=[a.file_name===""?"Le dernier fichier":a.file_name,ba(a.result)],i=Lt(a.occurred_at);i!==""&&r.push("le "+i),r.push("via "+Pr(a.source));const d=a.reason===""?"":" "+a.reason;return`${r.join(" ")} — ${Cr(a,t).oneLine}.${d}`}function $s(a){return a===""?"Aucun nouvel import enregistré à cet instant, et ce poste ne publie pas ce qu’il surveille.":"Aucun nouvel import enregistré à cet instant. Le poste surveille "+a+" : le fichier n’y était pas, ou il n’a pas encore fini d’arriver."}function Ys(a){return"Ce poste n’a pas de journal : il ne pourra rien dire de l’issue de cette relecture."+(a===""?"":" Le poste surveille "+a+".")}function Za(a){return a===1?"voir la ligne":`voir les ${ht(a)} lignes`}function Xs(a){return a.map(t=>`${Qs(t)} (${ht(t.count)})`).join(", ")}function Qs(a){switch(a.code){case"PREPACKAGED_PRODUCT":return"préemballés";case"INTERNAL_CODE_NOT_WEIGHABLE":return a.value===""?"code interne":"code interne "+a.value;case"NO_BARCODE":return"sans code-barres";default:return a.code}}function ht(a){return String(a)}var Zs=c('

                                          '),el=c('

                                          Aucun import dans l’historique.

                                          '),tl=c(' '),al=c('

                                          QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
                                          ',1);function rl(a,t){Ue(t,!0);function r(i){const d=ba(i.result);return i.code===""?d:`${d} (${i.code})`}je(a,{title:"Vingt derniers imports",children:(i,d)=>{var p=zt(),x=te(p);{var A=w=>{var N=Zs(),L=s(N);f(V=>o(L,V),[()=>Ms(t.state)]),l(w,N)},_=w=>{var N=el();l(w,N)},P=w=>{var N=al(),L=te(N),V=s(L),B=n(L,2),R=s(B),U=n(s(R));Re(U,21,()=>t.imports,F=>F.id,(F,E)=>{var y=tl(),m=s(y),D=s(m),J=n(m),K=s(J),W=n(J),T=s(W),S=n(W),k=s(S),O=n(S),M=s(O),C=n(O),X=s(C),Z=n(C),le=s(Z),j=n(Z),b=s(j),z=n(j),$=s(z),se=n(z),oe=s(se);f((ce,ie,I,Q,ae,ee,de,xe)=>{o(D,ce),o(K,e(E).file_name),o(T,ie),o(k,I),o(M,e(E).reason),o(X,Q),o(le,ae),o(b,ee),o($,de),o(oe,xe)},[()=>Lt(e(E).occurred_at),()=>Pr(e(E).source),()=>r(e(E)),()=>ne(e(E).rows_read_count),()=>ne(e(E).weighable_count),()=>ne(e(E).not_weighable_count),()=>ne(e(E).anomalies_count),()=>ne(e(E).products_withdrawn_count)]),l(F,y)}),f(F=>o(V,F),[()=>As(t.imports.length)]),l(w,N)};h(x,w=>{t.state!=="read"?w(A):t.imports.length===0?w(_,1):w(P,-1)})}l(i,p)},$$slots:{default:!0}}),Me()}var nl=c(' '),sl=c(''),ll=c('
                                          '),il=c('

                                          ');function Tr(a,t){Ue(t,!0);const r=u(()=>Cr(t.record,t.motives));var i=il(),d=s(i),p=s(d),x=n(d,2);Re(x,21,()=>e(r).lines,P=>P.label,(P,w)=>{var N=ll(),L=s(N),V=s(L),B=n(L,2),R=s(B),U=s(R),F=n(R,2);{var E=D=>{var J=nl(),K=s(J);f(()=>o(K,e(w).note)),l(D,J)};h(F,D=>{e(w).note!==""&&D(E)})}var y=n(F,2);{var m=D=>{var J=sl(),K=s(J);f(()=>o(K,e(w).link)),Ce("click",J,()=>t.onshowrows(e(w).label==="anomalies"?"anomalies":"units")),l(D,J)};h(y,D=>{e(w).link!==""&&t.onshowrows!==void 0&&D(m)})}f(()=>{_e(N,"data-row",e(w).label),o(V,e(w).count),o(U,e(w).label)}),l(P,N)});var A=n(x,2),_=s(A);f(()=>{o(p,e(r).headline),o(_,e(r).oneLine)}),l(a,i),Me()}it(["click"]);var ol=c(" "),ul=c(' '),cl=c('');function wa(a,t){Ue(t,!0);const r=Be(t,"hint",3,"");var i=cl(),d=s(i),p=n(d,2),x=s(p),A=s(x),_=n(x,2);{var P=L=>{var V=ol(),B=s(V);f(()=>o(B,t.path)),l(L,V)};h(_,L=>{qt.showTechnicalNames&&L(P)})}var w=n(_,2);{var N=L=>{var V=ul(),B=s(V);f(()=>o(B,r())),l(L,V)};h(w,L=>{r()!==""&&L(N)})}f(L=>{_e(i,"data-flag",t.path),_e(i,"data-on",L),ea(d,t.on),o(A,t.label)},[()=>String(t.on)]),Ce("change",d,L=>t.onchange(L.currentTarget.checked)),l(a,i),Me()}it(["change"]);function er(a){const t=`${ne(a.columns)} ${a.columns>1?"colonnes":"colonne"}`,r=`${ne(a.rows)} ${a.rows>1?"rangées":"rangée"}`;return`${t} × ${r}`}function dl(a){const t=a.layout,r=[];if(a.columns===Qt)return r.push(t===null?"Automatique : la grille suit la largeur de l’écran. Un écran plus large en montre davantage sans qu’on y revienne.":`Automatique : ${er(t)} sur cet écran. Un écran plus large en montrera davantage sans qu’on y revienne.`),r;if(t===null)return r.push(`${ne(a.columns)} ${a.columns>1?"colonnes":"colonne"} sur tous les écrans. Cet écran ne sait pas dire combien de rangées cela fait ici.`),r;const i=t.columns*t.rows;if(r.push(`${er(t)} — ${ne(i)} ${i>1?"tuiles":"tuile"} d’un coup, sur cet écran (${String(a.viewport.width)} × ${String(a.viewport.height)}).`),a.tileCount>0){const p=Math.ceil(a.tileCount/i);r.push(a.tileCount>1?`Les ${ne(a.tileCount)} tuiles de la grille tiennent en ${ne(p)} ${p>1?"écrans":"écran"}.`:"La seule tuile de la grille tient en un écran.")}const d=a.floor;if(d!==null&&d.names>0){const p=d.names>1;r.push(`${ne(d.names)} ${p?"noms":"nom"} sur ${ne(a.tileCount)} ${p?"atteignent":"atteint"} le plancher de ${ne(Ta)} px : `+(d.rows>1?`leurs ${ne(d.rows)} rangées peuvent être plus hautes que les autres.`:"leur rangée peut être plus haute que les autres."))}return r}function vl(a,t,r){if(a==="loading")return"Lecture du catalogue en service…";if(a==="unread")return"Le catalogue en service n’a pas pu être lu : cet écran ne sait pas combien de produits se vendent à l’unité.";if(t===0)return"Aucun produit vendu à l’unité dans le catalogue en service.";const i=t>1,d=i?"produits vendus à l’unité sont":"produit vendu à l’unité est",p=r?`${i?"montrés":"montré"} dans la grille de ce poste`:`${i?"masqués":"masqué"} sur ce poste`;return`${ne(t)} ${d} ${p}.`}function pl(a){return["localhost","127.0.0.1"].includes(a)?"":"Cet écran n’est pas celui du poste : ce compte vaut pour l’écran que vous lisez."}const fl=-1,gl=10;class zr{#t=H("");get sentence(){return e(this.#t)}set sentence(t){v(this.#t,t,!0)}#r=0;#a="";#e=0;begin(t){this.sentence="",this.#r=t.last_import_id,this.#a=t.watched,this.#e=gl}forget(){this.sentence="",this.#e=0}observe(t){if(this.#e===0)return;if(t.counters.journal_rows_count===fl){this.#n(Ys(this.#a));return}const r=t.catalog;if(r!==null&&r.id!==this.#r){this.#n(Ks(r,t.catalog_motives));return}this.#e-=1,this.#e===0&&(this.sentence=$s(this.#a))}#n(t){this.sentence=t,this.#e=0}}var ml=c('

                                          Aucun import enregistré sur ce poste.

                                          '),hl=c('

                                          '),_l=c(`

                                          « Oublier la quarantaine » fait relire un fichier que le poste avait écarté : c’est le + seul geste de cette page qui puisse remettre en service un catalogue refusé.

                                          `,1),tr=c(""),bl=c(''),wl=c('

                                          '),yl=c('

                                          '),xl=c('
                                          '),kl=c(`

                                          Un produit masqué reste vendable : la caisse lit toujours son code-barres, et une + étiquette déjà imprimée reste valable. Ce réglage ne fait que retirer sa tuile.

                                          Colonnes de la grille

                                          Se tromper ne coûte rien d’autre que de revenir ici : le réglage ne change ni le + fichier reçu, ni les étiquettes déjà imprimées.

                                          En dessous de ce nombre, la catégorie n’a pas de puce dans la barre du bas. Ses + produits restent dans « Tout » et à la recherche : ce réglage ne retire aucune + tuile. Pour ne plus montrer une catégorie du tout, c’est sa case « visible » qu’il + faut décocher.

                                          `,1),Sl=c('

                                          '),ql=c(`

                                          Déposez ici le fichier clé

                                          Ce dépôt remplace toute la grille par le fichier apporté : il change ce que le poste + vend, et le mot de passe est donc demandé au moment du dépôt.

                                          `,1),Ll=c('

                                          Aucun import enregistré : rien n’a encore pu être retiré.

                                          '),El=c('

                                          Aucun produit retiré par le dernier import.

                                          '),Cl=c(`

                                          Ils restent enregistrés avec leur historique : une étiquette déjà collée reste + lisible en caisse, et un produit qui revient dans un prochain fichier retrouve sa + tuile. Ce poste n’en publie encore que le nombre — leurs noms se lisent dans Odoo, + en comparant avec l’export précédent.

                                          `,1),Pl=c('
                                        • '),Tl=c(`

                                            Un produit retiré de la grille ne se trouve plus ici : il se reprend dans + « Décisions en vigueur », plus bas.

                                            `,1),zl=c(`

                                            Sans motif, le poste refuse la décision. Le seul acte qui s’en passe est celui qui + EFFACE la décision en vigueur : proposer de nouveau un produit qui ne porte aucune + dérogation.

                                            `),Rl=c(`

                                            Produit choisi :

                                            Ce produit est-il proposé dans la grille ?

                                            La dérogation s’enregistre seule : elle ne remet pas dans la grille un produit + qui en a été retiré, et retirer un produit n’efface pas sa dérogation.

                                            `),jl=c(' ',1),Al=c('
                                            ');function Ol(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=20,d=["NO_BARCODE","PREPACKAGED_PRODUCT","INTERNAL_CODE_NOT_WEIGHABLE"],p="ui.grid_columns",x=[3,4,5,6,7,8,9,10,11,12],A="ui.min_products_for_chip";let _=H(dt([])),P=H(dt([])),w=H(null);const N=u(()=>e(w)?.products??[]),L=u(()=>e(w)?.presentation??{}),V=u(()=>e(w)?.pricing?.primary_code??"");let B=H(""),R=H(""),U=H(""),F=H(""),E=H(!1),y=H(""),m=H(""),D=H("loading"),J=H("loading");const K=u(()=>r().busy||e(m)!==""),W=u(()=>e(P).filter(Y=>Y.issue==="anomaly")),T=u(()=>e(P).filter(Y=>d.includes(Y.code))),S=u(()=>e(P).filter(Y=>Y.code==="UNIT_MISMATCH")),k=u(()=>new Map(e(N).map(Y=>[Y.id,Y.name]))),O=u(()=>e(N).filter(Y=>Y.mode==="by_unit").length),M=u(()=>vl(e(J),e(O),t.draft.flag("ui.show_by_unit_products"))),C=u(()=>t.draft.number(p)),X=u(()=>t.draft.number(A)),Z=u(()=>j("ui.show_by_unit_products",e(L).show_by_unit_products??!1)?e(N):e(N).filter(Y=>Y.mode!=="by_unit")),le=u(()=>e(Z)[0]===void 0?null:{...e(Z)[0],name:"·",image_url:"",prices:e(Z)[0].prices??[]});function j(Y,ge){return t.draft.value(Y)===void 0?ge:t.draft.flag(Y)}let b=H(null),z=H(null),$=H(null),se=H(null),oe=H(null),ce=H(0),ie=H(null);_t(()=>{const Y=()=>{v(ce,e(ce)+1)};return window.addEventListener("resize",Y),()=>window.removeEventListener("resize",Y)}),_t(()=>{const Y=e(C);e(ce),v(ie,ee(Y),!0)});const I=u(()=>{const Y=e(ie);if(Y===null||Y.contentWidthPx<=0||Y.nameBoxPx<=0||e(Z).length===0)return null;const ge=xe();if(ge===null)return null;const be=$r(ge.family,ge.weight);if(be===null)return null;const Pe=Yr(Y.tileScale),$e=new Set;let He=0;for(const[et,Ie]of e(Z).entries())Xr(Ie.name,Y.contentWidthPx,be,Y.nameBoxPx,Pe)>Ta||(He+=1,$e.add(Math.floor(et/Y.columns)));return{names:He,rows:$e.size}}),Q=u(()=>dl({columns:e(C),layout:e(ie),tileCount:e(Z).length,floor:e(I),viewport:{width:window.innerWidth,height:window.innerHeight}})),ae=u(()=>e(ie)===null?"":pl(window.location.hostname));function ee(Y){const ge=e(b),be=e(z),Pe=e($),$e=e(se),He=e(oe);if(ge===null||be===null||$e===null||He===null||Pe===null)return null;be.style.gridTemplateColumns=Jr(Y);const et=de(be),Ie=et[0];if(Ie===void 0)return null;const Ae=$e.clientWidth,Ye=Y===Qt||Ae<=0?1:Ie/Ae;ge.style.setProperty("--tile-scale",String(Ye));const Ve=Number.parseFloat(getComputedStyle(be).rowGap),tt=He.clientHeight,lt=Pe.offsetHeight,Le=Pe.querySelector(".name-box");return!Number.isFinite(Ve)||tt<=0||lt<=0||Le===null?null:{columns:et.length,rows:Math.max(1,Math.floor((tt+Ve)/(lt+Ve))),contentWidthPx:Le.clientWidth,nameBoxPx:Le.clientHeight,tileScale:Ye}}function de(Y){const ge=getComputedStyle(Y).gridTemplateColumns.split(/\s+/u).filter(Pe=>Pe!=="");if(ge.length===0)return[];const be=ge.map(Pe=>Pe.endsWith("px")?Number.parseFloat(Pe):Number.NaN);return be.every(Pe=>Number.isFinite(Pe)&&Pe>0)?be:[]}function xe(){const Y=e($)?.querySelector(".name")??null;if(Y===null)return null;const ge=getComputedStyle(Y),be=Number.parseInt(ge.fontWeight,10);return ge.fontFamily===""||!Number.isFinite(be)?null:{family:ge.fontFamily,weight:be}}const Ne=u(()=>e(B)===""?[]:Qr(e(N),Zr,e(B))),q=u(()=>e(Ne).slice(0,i)),ue=u(()=>js(e(q).length,e(Ne).length)),pe=u(()=>[...t.health.decisions].sort((Y,ge)=>ge.decided_at.localeCompare(Y.decided_at))),me=u(()=>e(R)===""?null:e(pe).find(Y=>Y.product_id===e(R))??null),fe=u(()=>e(me)===null?!0:e(me).offered),ke=u(()=>e(me)?.min_weight_g??null),Se=u(()=>Ts(e(F))),we=u(()=>e(U).trim()),ye=u(()=>Rs({motive:e(we),offeredInForce:e(fe),waiverInForce:e(ke),typedWaiver:e(Se)})),De=u(()=>t.health.catalog?.products_withdrawn_count??0),qe=u(()=>e(_).find(Y=>t.health.catalog!==null&&Y.idOs(e(De),e(qe)?.occurred_at??""));let at;_t(()=>{const Y=t.health.catalog?.id??null;Y!==at&&(at=Y,vt())});const rt=new zr;_t(()=>{rt.observe(t.health)});async function vt(){const Y=t.health.catalog_findings_id,ge=await r().load(()=>Nn(Y===0?void 0:Y));ge===null?v(D,"unread"):(v(_,ge.imports,!0),v(P,ge.findings,!0),v(D,"read"));const be=await r().load(()=>_r());if(be===null){v(J,"unread");return}v(w,be,!0),v(J,"read")}async function ot(Y,ge){v(m,Y,!0),r().actionError="",r().notice="",rt.forget();try{const be=await r().protect(ge);return be===null?null:(r().notice=be.message,await r().refresh(),be)}finally{v(m,"")}}async function ct(){const Y=await ot("reload",In);Y!==null&&rt.begin(Y)}function pt(Y){return Wa(e(k).get(Y),e(J),"Nom inconnu : le catalogue n’a pas pu être lu")}function gt(Y){v(R,Y,!0),v(U,"");const ge=e(pe).find(be=>be.product_id===Y)?.min_weight_g??null;v(F,ge===null?"":String(ge),!0)}async function Ze(Y){if(e(R)==="")return;const ge=e(R),be=e(ke),Pe=e(we);await ot("offered",()=>Ka(ge,{offered:Y,min_weight_g:be,reason:Pe}))!==null&&v(U,"")}async function he(Y){if(e(R)==="")return;const ge=e(R),be=e(fe),Pe=e(we);await ot(Y===null?"waiver-off":"waiver",()=>Ka(ge,{offered:be,min_weight_g:Y,reason:Pe}))!==null&&v(U,"")}async function Ee(Y){if(v(E,!1),Y!=null){v(y,""),r().actionError="",r().notice="",v(m,"import");try{const ge=await r().protect(()=>yr(Y));if(ge===null)return;v(y,`${Y.name} : ${ne(ge.rows_read_count)} lignes lues, ${ne(ge.weighable_count)} pesables. `+(ge.reason===""?"Le fichier est déposé ; son résultat s’inscrira dans l’historique des imports.":ge.reason)),await r().refresh()}finally{v(m,"")}}}function st(Y){Y.preventDefault(),v(E,!1);const ge=Y.dataTransfer?.files.item(0);if(e(K)){v(y,`${ge?.name??"Ce fichier"} n’a pas été déposé : un acte est déjà en cours sur cette page. Réessayez quand il aura répondu.`);return}Ee(ge)}function Kt(Y){const ge=Y.files?.item(0);Y.value="",Ee(ge)}var Et=Al(),Ct=s(Et);Cs(Ct,{get draft(){return t.draft},get station(){return t.health.station}});var Rt=n(Ct,2);je(Rt,{title:"Dernier import",children:(Y,ge)=>{var be=_l(),Pe=te(be);{var $e=Le=>{var Xe=ml();l(Le,Xe)},He=Le=>{Tr(Le,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives}})};h(Pe,Le=>{t.health.catalog===null?Le($e):Le(He,-1)})}var et=n(Pe,2),Ie=s(et),Ae=n(et,2);{var Ye=Le=>{var Xe=hl(),Je=s(Xe);f(()=>o(Je,rt.sentence)),l(Le,Xe)};h(Ae,Le=>{rt.sentence!==""&&Le(Ye)})}var Ve=n(Ae,2),tt=s(Ve);{let Le=u(()=>e(m)==="reload");ze(tt,{act:"reload",kind:"write",label:"Recharger le catalogue",protected:!0,get busy(){return e(Le)},get disabled(){return e(K)},onrun:()=>{ct()}})}var lt=n(tt,2);{let Le=u(()=>e(m)==="quarantine");ze(lt,{act:"quarantine",kind:"destructive",label:"Oublier la quarantaine",protected:!0,get busy(){return e(Le)},get disabled(){return e(K)},onrun:()=>{ot("quarantine",Dn)}})}f(()=>o(Ie,t.health.catalog_source===null?"Aucune source de catalogue publiée par ce poste.":"Source : "+t.health.catalog_source.label)),l(Y,be)},$$slots:{default:!0}});var jt=n(Rt,2);je(jt,{title:"Ce que la grille montre",note:"Un réglage d’affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser.",children:(Y,ge)=>{var be=kl(),Pe=te(be);{let g=u(()=>t.draft.flag("ui.show_by_unit_products"));wa(Pe,{path:"ui.show_by_unit_products",label:"Afficher les produits vendus à l’unité",hint:"Décoché, leurs tuiles quittent la grille et la recherche ne les retrouve plus. Ce que le poste perd : une tuile vendue à l’unité imprime une étiquette sans jamais lire la balance, et c’est le seul geste que ce réglage retire.",get on(){return e(g)},onchange:G=>t.draft.set("ui.show_by_unit_products",G)})}var $e=n(Pe,2),He=s($e),et=n($e,4),Ie=n(s(et));{var Ae=g=>{var G=tr();G.textContent="ui.grid_columns",l(g,G)};h(Ie,g=>{qt.showTechnicalNames&&g(Ae)})}var Ye=n(et,2),Ve=s(Ye),tt=s(Ve),lt=n(Ve,2);Re(lt,16,()=>x,g=>g,(g,G)=>{var ve=bl(),re=s(ve),Te=n(re);f((Oe,We)=>{_e(ve,"data-columns",G),_e(ve,"data-on",Oe),Gt(re,G),ea(re,e(C)===G),o(Te,` ${We??""}`)},[()=>String(e(C)===G),()=>ne(G)]),Ce("change",re,()=>t.draft.set(p,G)),l(g,ve)});var Le=n(Ye,2),Xe=s(Le);Re(Xe,17,()=>e(Q),pa,(g,G)=>{var ve=wl(),re=s(ve);f(()=>o(re,e(G))),l(g,ve)});var Je=n(Xe,2);{var ut=g=>{var G=yl(),ve=s(G);f(()=>o(ve,e(ae))),l(g,G)};h(Je,g=>{e(ae)!==""&&g(ut)})}var ft=n(Le,4),wt=n(s(ft),2);{var Pt=g=>{var G=tr();G.textContent="ui.min_products_for_chip",l(g,G)};h(wt,g=>{qt.showTechnicalNames&&g(Pt)})}var yt=n(ft,2),At=n(yt,4),xt=s(At),Xt=s(xt);{var na=g=>{var G=xl(),ve=s(G);{let re=u(()=>j("ui.show_grid_prices",e(L).show_grid_prices??!0));Kr(ve,{get product(){return e(le)},get nameSizePx(){return Ta},get primaryCode(){return e(V)},get showPrice(){return e(re)},onpick:()=>{}})}ia(G,re=>v($,re),()=>e($)),l(g,G)};h(Xt,g=>{e(le)!==null&&g(na)})}ia(xt,g=>v(z,g),()=>e(z));var It=n(xt,2);ia(It,g=>v(se,g),()=>e(se));var la=n(It,2);ia(la,g=>v(oe,g),()=>e(oe)),ia(At,g=>v(b,g),()=>e(b)),f(g=>{o(He,e(M)),_e(Ve,"data-columns",Qt),_e(Ve,"data-on",g),Gt(tt,Qt),ea(tt,e(C)===Qt),Gt(yt,e(X))},[()=>String(e(C)===Qt)]),Ce("change",tt,()=>t.draft.set(p,Qt)),Ce("input",yt,g=>t.draft.set(A,Number(g.currentTarget.value))),l(Y,be)},$$slots:{default:!0}});var Ot=n(jt,2);je(Ot,{title:"Déposer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(Y,ge)=>{var be=ql(),Pe=te(be);{var $e=Le=>{var Xe=Sl(),Je=s(Xe);f(()=>o(Je,e(y))),l(Le,Xe)};h(Pe,Le=>{e(y)!==""&&Le($e)})}var He=n(Pe,2);let et;var Ie=s(He),Ae=n(s(Ie)),Ye=s(Ae),Ve=n(Ie,2),tt=s(Ve),lt=n(tt);f(()=>{et=Dt(He,1,"drop svelte-3gk7u7",null,et,{dropping:e(E),working:e(m)==="import"}),o(Ye,`flv_${t.health.station??""}.csv`),o(tt,`${e(m)==="import"?"Import en cours…":"Choisir un fichier"} `),lt.disabled=e(K)}),Jt("dragover",He,Le=>{Le.preventDefault(),!e(K)&&v(E,!0)}),Jt("dragleave",He,()=>v(E,!1)),Jt("drop",He,st),Ce("change",lt,Le=>Kt(Le.currentTarget)),l(Y,be)},$$slots:{default:!0}});var $t=n(Ot,2);Pa($t,{title:"Anomalies à corriger dans Odoo",note:"Chaque ligne porte le nom du produit, son numéro dans le CSV, son motif et la valeur fautive.",list:"anomalies",get state(){return e(D)},get findings(){return e(W)},singular:"anomalie",plural:"anomalies",none:"Aucune anomalie sur le dernier import.",remedy:"Corrigez celles-ci dans Odoo : l’import suivant ne signalera que ce qui reste."});var Yt=n($t,2);Pa(Yt,{title:"Unités divergentes",note:"Le produit reste proposé : le code-barres fait foi, seul le libellé du prix est faux.",list:"mismatches",get state(){return e(D)},get findings(){return e(S)},singular:"unité divergente",plural:"unités divergentes",none:"Aucune unité divergente sur le dernier import."});var Nt=n(Yt,2);Pa(Nt,{title:"Produits non pesables",note:"Un inventaire, pas une liste d’erreurs : ces produits portent déjà leur code-barres et n’ont aucune raison d’être pesés.",list:"not-weighable",get state(){return e(D)},get findings(){return e(T)},singular:"produit non pesable",plural:"produits non pesables",none:"Aucun produit non pesable sur le dernier import."});var ta=n(Nt,2);je(ta,{title:"Produits retirés depuis l’import précédent",note:"Un produit absent du nouveau fichier est marqué retiré à sa date, jamais supprimé.",children:(Y,ge)=>{var be=zt(),Pe=te(be);{var $e=Ie=>{var Ae=Ll();l(Ie,Ae)},He=Ie=>{var Ae=El();l(Ie,Ae)},et=Ie=>{var Ae=Cl(),Ye=te(Ae),Ve=s(Ye);f(()=>o(Ve,e(Fe))),l(Ie,Ae)};h(Pe,Ie=>{t.health.catalog===null?Ie($e):e(De)===0?Ie(He,1):Ie(et,-1)})}l(Y,be)},$$slots:{default:!0}});var Ut=n(ta,2);je(Ut,{title:"Décider d’un produit",note:"Retirer un produit et l’autoriser à peser moins sont deux décisions séparées : l’une n’efface pas l’autre.",children:(Y,ge)=>{var be=jl(),Pe=n(te(be),2),$e=n(Pe,2);{var He=Ae=>{var Ye=Tl(),Ve=te(Ye),tt=s(Ve),lt=n(Ve,2),Le=s(lt);Re(Le,21,()=>e(q),Xe=>Xe.id,(Xe,Je)=>{var ut=Pl(),ft=s(ut),wt=s(ft),Pt=n(ft,2),yt=s(Pt),At=n(Pt,2),xt=s(At);f(()=>{o(wt,e(Je).name),o(yt,e(Je).id),o(xt,`${e(Je).unit_price_text??""}${e(Je).price_suffix??""}`)}),Ce("click",ft,()=>gt(e(Je).id)),l(Xe,ut)}),f(()=>o(tt,e(ue))),l(Ae,Ye)};h($e,Ae=>{e(B)!==""&&Ae(He)})}var et=n($e,2);{var Ie=Ae=>{var Ye=Rl(),Ve=s(Ye),tt=n(s(Ve)),lt=s(tt),Le=n(tt),Xe=n(Ve,2),Je=s(Xe),ut=n(Xe,4),ft=n(ut,2);{var wt=re=>{var Te=zl();l(re,Te)};h(ft,re=>{e(we)===""&&re(wt)})}var Pt=n(ft,2),yt=n(s(Pt),2),At=s(yt);{var xt=re=>{{let Te=u(()=>e(m)==="offered"),Oe=u(()=>e(K)||!e(ye).canWithdraw);ze(re,{act:"offered",kind:"destructive",label:"Ne plus proposer ce produit",protected:!0,get busy(){return e(Te)},get disabled(){return e(Oe)},onrun:()=>{Ze(!1)}})}},Xt=re=>{{let Te=u(()=>e(m)==="offered"),Oe=u(()=>e(K)||!e(ye).canOfferAgain);ze(re,{act:"offered",kind:"write",label:"Le proposer de nouveau",protected:!0,get busy(){return e(Te)},get disabled(){return e(Oe)},onrun:()=>{Ze(!0)}})}};h(At,re=>{e(fe)?re(xt):re(Xt,-1)})}var na=n(Pt,2),It=n(s(na),2),la=n(It,2),g=s(la);{let re=u(()=>e(m)==="waiver"),Te=u(()=>e(K)||!e(ye).canSaveWaiver);ze(g,{act:"waiver",kind:"write",label:"Enregistrer la dérogation",protected:!0,get busy(){return e(re)},get disabled(){return e(Te)},onrun:()=>{he(e(Se))}})}var G=n(g,2);{var ve=re=>{{let Te=u(()=>e(m)==="waiver-off"),Oe=u(()=>e(K)||!e(ye).canDropWaiver);ze(re,{act:"waiver-off",kind:"write",label:"Retirer la dérogation",protected:!0,get busy(){return e(Te)},get disabled(){return e(Oe)},onrun:()=>{he(null)}})}};h(G,re=>{e(ke)!==null&&re(ve)})}f((re,Te)=>{_e(Ye,"data-decision",e(R)),o(lt,re),o(Le,` (${e(R)??""})`),o(Je,`En vigueur : ${Te??""}`),Gt(It,e(F))},[()=>pt(e(R)),()=>e(me)===null?"aucune décision — ce produit suit les règles générales":Er(e(me))]),ua(ut,()=>e(U),re=>v(U,re)),Ce("input",It,re=>v(F,re.currentTarget.value,!0)),l(Ae,Ye)};h(et,Ae=>{e(R)!==""&&Ae(Ie)})}ua(Pe,()=>e(B),Ae=>v(B,Ae)),l(Y,be)},$$slots:{default:!0}});var aa=n(Ut,2);Fs(aa,{get decisions(){return e(pe)},nameOf:pt,onchoose:gt});var ra=n(aa,2);rl(ra,{get imports(){return e(_)},get state(){return e(D)}}),l(a,Et),Me()}it(["change","input","click"]);function Nl(a){switch(a){case"ok":return"OK";case"warn":return"À SURVEILLER";case"fault":return"EN PANNE";case"off":return"SANS OBJET";default:return"INCONNU"}}var Il=c('

                                            '),Dl=c('

                                            ');function Fl(a,t){Ue(t,!0);var r=Dl(),i=s(r),d=s(i),p=n(d,2),x=s(p),A=n(p,2),_=s(A),P=n(i,2),w=s(P),N=n(P,2);{var L=V=>{var B=Il(),R=s(B);f(()=>o(R,t.light.remedy)),l(V,B)};h(N,V=>{t.light.remedy!==""&&V(L)})}f(V=>{_e(r,"data-light",t.light.id),_e(r,"data-level",t.light.level),_e(d,"data-level",t.light.level),o(x,t.light.label),o(_,V),o(w,t.light.value)},[()=>Nl(t.light.level)]),l(a,r),Me()}function Ul(a){return[Ml(a),Wl(a),Vl(a),Gl(a),Jl(a),Kl(a)]}function Ml(a){const t=a.state.scale,r=da(t.median_ms);return a.scale_present?t.connected?t.too_slow?{id:"scale",label:"Balance",level:"warn",value:`une mesure toutes les ${r}, plus lent que la péremption`,remedy:"À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante. Vérifiez le câble et l’adaptateur USB, puis la cadence sur la page Matériel."}:{id:"scale",label:"Balance",level:"ok",value:$l(t.provisional,`une mesure toutes les ${r}`),remedy:""}:{id:"scale",label:"Balance",level:"fault",value:"elle ne répond plus",remedy:"Vérifiez le câble et l’alimentation de la balance, puis touchez « Tester la balance ». En attendant, « Basculer en saisie manuelle » permet de continuer à servir."}:{id:"scale",label:"Balance",level:"off",value:"ce poste est déclaré sans balance",remedy:""}}function Wl(a){const t=a.state.printer;switch(t.health){case"faulted":return{id:"printer",label:"Imprimante",level:"fault",value:t.detail===""?"elle ne peut pas imprimer":t.detail,remedy:Bl(a)};case"consumable":return{id:"printer",label:"Imprimante",level:"warn",value:"elle imprime, mais le rouleau arrive en fin de vie",remedy:"Changez le rouleau quand vous passez derrière le comptoir, puis touchez « J’ai changé le rouleau ». Le poste continue de servir en attendant."};case"ready":return{id:"printer",label:"Imprimante",level:"ok",value:"elle répond et n’a rien à signaler",remedy:""};default:return{id:"printer",label:"Imprimante",level:"unknown",value:"elle prend les étiquettes et ne dit rien en retour",remedy:"C’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne. Pour savoir si elle imprime, touchez « Imprimer une étiquette de test »."}}}function Bl(a){const t="Regardez le capot, le rouleau et le câble, puis touchez « Tester l’imprimante ».";return a.printing?.fallback_available===!0?t+" Le poste peut servir en attendant : « Imprimer sur l’imprimante du poste voisin ».":t}function Vl(a){const t=a.roll;return t===null?{id:"roll",label:"Rouleau",level:"unknown",value:"aucun compteur d’étiquettes sur ce poste",remedy:`Sans imprimante construite, il n’y a pas de rouleau à compter : vérifiez le « ${St("printer.type")} » et ses réglages sur la page Matériel.`}:t.known?t.level==="warn"?{id:"roll",label:"Rouleau",level:"warn",value:t.message,remedy:"Changez le rouleau, puis touchez « J’ai changé le rouleau »."}:{id:"roll",label:"Rouleau",level:"ok",value:t.message,remedy:""}:{id:"roll",label:"Rouleau",level:"unknown",value:t.message,remedy:"Touchez « J’ai changé le rouleau » en mettant un rouleau neuf : c’est le seul geste qui dise quelque chose de vrai du papier."}}function Gl(a){const t=a.catalog_source?.label??"";if(a.state.catalog_count===0)return{id:"catalog",label:"Catalogue",level:"fault",value:"aucun produit dans la grille",remedy:Hl(t)};const r=a.catalog;return r!==null&&r.result!=="applied"&&r.result!=="unchanged"?{id:"catalog",label:"Catalogue",level:"warn",value:`dernier fichier refusé : ${r.reason===""?r.result:r.reason}`,remedy:"La grille tourne toujours sur le catalogue précédent. Corrigez le fichier dans Odoo, ou touchez « Oublier la quarantaine » puis redéposez-le."}:{id:"catalog",label:"Catalogue",level:"ok",value:`${ne(a.state.catalog_count)} produits dans la grille`,remedy:""}}function Hl(a){return"Déposez le fichier du catalogue là où le poste le guette, ou utilisez « Importer un catalogue » ci-contre pour le glisser depuis une clé USB."+(a===""?"":` Ce poste surveille : ${a}.`)}function Jl(a){const t=a.disk;if(t===null)return{id:"disk",label:"Disque",level:"unknown",value:"la place libre n’a pas pu être mesurée",remedy:"Téléchargez le fichier de diagnostic : il porte ce que le poste a pu lire du volume, et c’est la pièce qu’un support demandera."};const r=Ra(t.free_bytes),i=`seuil ${ne(t.alert_mb)} Mo`;return t.free_bytes===0?{id:"disk",label:"Disque",level:"fault",value:`plus un octet libre sur ${t.path}`,remedy:"Le journal ne peut plus rien écrire, alors que les étiquettes continuent de sortir. Faites de la place sur le disque, puis rechargez cette page."}:t.alert_mb>0&&t.free_bytes0?{id:"journal",label:"Journal",level:"fault",value:`${ne(t)} pesées imprimées mais non enregistrées`,remedy:"Les étiquettes sortent, les ventes sont bonnes, c’est la trace qui manque. Téléchargez le fichier de diagnostic et prévenez le support : ne redémarrez rien."}:a.counters.journal_rows_count<0?{id:"journal",label:"Journal",level:"unknown",value:"ce poste n’a pas de journal ouvert",remedy:"Le poste pèse et imprime quand même. Téléchargez le fichier de diagnostic : il dit pourquoi la base n’a pas pu être ouverte."}:{id:"journal",label:"Journal",level:"ok",value:`${ne(a.counters.journal_rows_count)} pesées enregistrées`,remedy:""}}function $l(a,t){return a?t+" (cadence encore provisoire)":t}function Yl(a){return a===null||!a.known?"unknown":a.configured?"ok":"missing"}var Xl=c("Une mesure toutes les ",1),Ql=c('

                                            '),Zl=c("Source : ",1),ei=c(`

                                            Aucun import enregistré sur ce poste : le catalogue n’est jamais arrivé, ou le + journal ne le porte pas.

                                            `),ti=c('

                                            ',1),ai=c('

                                            ',1),ri=c('

                                            Aucune décision locale : le catalogue est proposé tel qu’il arrive.

                                            '),ni=c('

                                            '),si=c(' '),li=c('
                                          • '),ii=c('
                                              ',1),oi=c(`INCONNU — la question n’a pas pu être posée à ce système. Ce + n’est pas « non configuré » : personne ne sait encore.`,1),ui=c('OK ',1),ci=c('NON CONFIGURÉ ',1),di=c('

                                              '),vi=c('

                                              ',1),pi=c('

                                              Rien à signaler depuis le démarrage du poste.

                                              '),fi=c(' '),gi=c('
                                            • '),mi=c('
                                                '),hi=c(''),_i=c('

                                                '),bi=c('
                                                Numéro de poste
                                                Nom
                                                Coopérative
                                                Version
                                                Empreinte de configuration
                                                ',1),wi=c('
                                                ');function yi(a,t){Ue(t,!0);const r=u(()=>t.health.new_version),i=u(()=>Ul(t.health)),d=u(()=>t.health.state.scale),p=u(()=>t.health.unattended_restart),x=u(()=>t.health.decisions),A=5,_=u(()=>e(x).slice(0,A));var P=wi(),w=s(P);Re(w,21,()=>e(i),F=>F.id,(F,E)=>{Fl(F,{get light(){return e(E)}})});var N=n(w,2);je(N,{title:"Cadence de la balance",children:(F,E)=>{var y=Ql(),m=s(y);{var D=T=>{var S=nt("Ce poste est déclaré sans balance : le poids est saisi à la main.");l(T,S)},J=T=>{var S=nt(`La balance ne répond pas : aucune cadence n’est mesurable tant qu’aucune trame + n’arrive.`);l(T,S)},K=T=>{var S=nt(`Aucun intervalle n’a encore été mesuré : la cadence apparaîtra dès les premières + trames.`);l(T,S)},W=T=>{var S=Xl(),k=n(te(S)),O=s(k),M=n(k),C=n(M);{var X=j=>{var b=nt("C’est encore une valeur d’attente : moins de huit intervalles ont été observés.");l(j,b)};h(C,j=>{e(d).provisional&&j(X)})}var Z=n(C,2);{var le=j=>{var b=nt("À cette cadence, un poids serait périmé avant l’arrivée de la mesure suivante.");l(j,b)};h(Z,j=>{e(d).too_slow&&j(le)})}f((j,b)=>{o(O,j),o(M,`, médiane + observée sur ${b??""} intervalles. `)},[()=>da(e(d).median_ms),()=>ne(e(d).observations_count)]),l(T,S)};h(m,T=>{t.health.scale_present?e(d).connected?e(d).observations_count===0?T(K,2):T(W,-1):T(J,1):T(D)})}l(F,y)},$$slots:{default:!0}});var L=n(N,2);je(L,{title:"Catalogue",children:(F,E)=>{var y=ai(),m=te(y),D=s(m);{var J=k=>{var O=nt("Aucune source de catalogue n’est publiée par ce poste.");l(k,O)},K=k=>{var O=Zl(),M=n(te(O)),C=s(M);f(()=>o(C,t.health.catalog_source.label)),l(k,O)};h(D,k=>{t.health.catalog_source===null?k(J):k(K,-1)})}var W=n(m,2);{var T=k=>{var O=ei();l(k,O)},S=k=>{var O=ti(),M=te(O),C=s(M),X=n(M,2);Tr(X,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives},get onshowrows(){return t.onshowrows}}),f((Z,le)=>o(C,`Dernier essai : ${Z??""} — + ${le??""}${t.health.catalog.file_name===""?"":" ("+t.health.catalog.file_name+")"}${t.health.catalog.reason===""?"":" : "+t.health.catalog.reason}`),[()=>Lt(t.health.catalog.occurred_at),()=>ba(t.health.catalog.result)]),l(k,O)};h(W,k=>{t.health.catalog===null?k(T):k(S,-1)})}l(F,y)},$$slots:{default:!0}});var V=n(L,2);je(V,{title:"Décisions locales en vigueur",note:"Ce qu’un humain a décidé de ce catalogue, avec son motif et sa date.",children:(F,E)=>{var y=zt(),m=te(y);{var D=K=>{var W=ri();l(K,W)},J=K=>{var W=ii(),T=te(W);{var S=O=>{var M=ni(),C=s(M);f(X=>o(C,`${X??""} décisions en vigueur — les + 5 plus récentes ci-dessous. La liste entière est sur la page + Catalogue.`),[()=>ne(e(x).length)]),l(O,M)};h(T,O=>{e(x).length>A&&O(S)})}var k=n(T,2);Re(k,21,()=>e(_),O=>O.product_id,(O,M)=>{var C=li(),X=s(C),Z=s(X),le=n(X,2);{var j=oe=>{var ce=si(),ie=s(ce);f(I=>o(ie,`peut peser à partir de ${I??""} g`),[()=>ne(e(M).min_weight_g)]),l(oe,ce)};h(le,oe=>{e(M).min_weight_g!==null&&oe(j)})}var b=n(le,2),z=s(b),$=n(b,2),se=s($);f(oe=>{o(Z,`${e(M).offered?"Dérogation de poids":"Produit retiré"} — ${e(M).product_id??""}`),o(z,e(M).reason),o(se,`${oe??""}, ${e(M).decided_by??""}`)},[()=>fa(e(M).decided_at)]),l(O,C)}),l(K,W)};h(m,K=>{e(x).length===0?K(D):K(J,-1)})}l(F,y)},$$slots:{default:!0}});var B=n(V,2);je(B,{title:"Redémarrage sans intervention",children:(F,E)=>{var y=vi(),m=te(y),D=s(m);{var J=k=>{var O=oi();l(k,O)},K=k=>{var O=ui(),M=n(te(O));f(()=>o(M,` — après une coupure de courant, ce poste revient seul sur + l’écran client. ${e(p).detail??""}`)),l(k,O)},W=k=>{var O=ci(),M=n(te(O));f(()=>o(M,` — ${e(p).detail??""}`)),l(k,O)};h(D,k=>{e(p)===null||!e(p).known?k(J):e(p).configured?k(K,1):k(W,-1)})}var T=n(m,2);{var S=k=>{var O=di(),M=s(O);f(()=>o(M,e(p).remedy)),l(k,O)};h(T,k=>{e(p)!==null&&e(p).known&&!e(p).configured&&e(p).remedy!==""&&k(S)})}f(k=>_e(m,"data-verdict",k),[()=>Yl(e(p))]),l(F,y)},$$slots:{default:!0}});var R=n(B,2);je(R,{title:"Dix derniers événements",children:(F,E)=>{var y=zt(),m=te(y);{var D=K=>{var W=pi();l(K,W)},J=K=>{var W=mi();Re(W,21,()=>t.health.events,T=>T.id,(T,S)=>{var k=gi(),O=s(k),M=s(O),C=n(O,2),X=s(C),Z=n(C,2);{var le=z=>{var $=fi(),se=s($);f(()=>o(se,e(S).code)),l(z,$)};h(Z,z=>{e(S).code!==""&&qt.showTechnicalNames&&z(le)})}var j=n(Z,2),b=s(j);f((z,$)=>{_e(k,"data-level",e(S).level),o(M,z),o(X,$),o(b,e(S).message)},[()=>Ps(e(S).occurred_at),()=>Sr(e(S).source)]),l(T,k)}),l(K,W)};h(m,K=>{t.health.events.length===0?K(D):K(J,-1)})}l(F,y)},$$slots:{default:!0}});var U=n(R,2);je(U,{title:"Ce poste",children:(F,E)=>{var y=bi(),m=te(y),D=n(s(m),2),J=s(D),K=n(D,4),W=s(K),T=n(K,4),S=s(T),k=n(T,4),O=s(k),M=n(k,4),C=s(M),X=n(m,2);{var Z=le=>{var j=_i(),b=s(j),z=n(b);{var $=se=>{var oe=hi();Ce("click",oe,function(...ce){t.onshowupdate?.apply(this,ce)}),l(se,oe)};h(z,se=>{t.onshowupdate!==void 0&&se($)})}f(()=>{_e(j,"data-update-available",e(r)),o(b,`Version ${e(r)??""} disponible. `)}),l(le,j)};h(X,le=>{e(r)!==""&&le(Z)})}f(()=>{o(J,t.health.station),o(W,t.health.station_name===""?"non renseigné":t.health.station_name),o(S,t.health.coop),o(O,t.health.version),o(C,t.health.config_fingerprint)}),l(F,y)},$$slots:{default:!0}}),l(a,P),Me()}it(["click"]);var xi=c('

                                                ');function ar(a,t){Ue(t,!0);var r=xi(),i=s(r),d=s(i),p=n(i,2),x=n(s(p),1,!0);f(()=>{o(d,t.title),_e(p,"data-standing",t.name),_e(p,"data-level",t.standing.level),o(x,t.standing.word)}),l(a,r),Me()}const ki={0:"NUL",2:"STX",3:"ETX",4:"EOT",5:"ENQ",6:"ACK",9:"TAB",10:"LF",13:"CR",21:"NAK",27:"ESC",127:"DEL"};function Si(a){return[...new TextEncoder().encode(a)].map(t=>t.toString(16).toUpperCase().padStart(2,"0")).join(" ")}function qi(a){let t="";for(const r of a){const i=r.codePointAt(0)??0;if(i>=32&&i!==127){t+=r;continue}t+=`⟨${ki[i]??i.toString(16).toUpperCase().padStart(2,"0")}⟩`}return t}function Li(a){const t=Ei(a);return t===""?rr(a):`${rr(a)} ${t}`}function rr(a){return a.configRead?a.declaredWithoutScale?"Ce poste est déclaré sans balance : aucun port n’est écouté.":a.port===""?"Aucun port n’est indiqué : choisissez-en un dans la liste ci-dessus pour écouter les trames.":a.listed?a.portKnown?a.halt!==""?`L’écoute de ${a.port} est arrêtée.`:a.acting!==""?`L’écoute de ${a.port} est suspendue le temps de l’acte en cours.`:`Écoute de ${a.port}.`:`${a.port} n’est pas visible depuis ce poste : rien n’est écouté en continu.`:a.acting==="ports"?`Énumération des ports en cours : l’écoute de ${a.port} démarre dès qu’il est vu.`:`Les ports de ce poste n’ont pas été énumérés : « Lister les ports » dira si ${a.port} existe.`:"Lecture de la configuration en cours : le port à écouter n’est pas encore connu."}function Ei(a){return a.framesShown===0?"Aucune trame reçue pour l’instant.":a.framesShown===1?`Une seule trame reçue — ${ne(a.framesKept)} au plus sont gardées.`:`Les ${ne(a.framesShown)} dernières trames — ${ne(a.framesKept)} au plus, la plus récente en bas.`}function Ci(a){return!a.configRead||a.declaredWithoutScale||a.port===""?"":a.halt!==""?"Reprendre l’écoute":a.listed&&!a.portKnown?"Écouter ce port une fois":""}function Pi(a){return a.acting!=="detect"?"Détecter automatiquement":a.listening?"Détection : le port se libère…":a.toScan===0?"Détection : énumération des ports…":`Détection : port ${ne(a.scanned)} sur ${ne(a.toScan)}…`}function Ti(a,t){return a?t.connected?t.too_slow?{level:"warn",word:"Trop lente",detail:nr(t)+" À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante."}:{level:"ok",word:"Connectée",detail:"Elle répond. "+nr(t)}:{level:"fault",word:"Sans réponse",detail:"Elle ne répond plus. Vérifiez le câble et l’alimentation, puis « Tester la balance » sur la page Dépannage."}:{level:"off",word:"Sans balance",detail:"Ce poste est déclaré sans balance : le feu est éteint et le poids se saisit à la main."}}function nr(a){return a.observations_count===0?"Aucun intervalle n’a encore été mesuré : la cadence sera connue dès les premières trames.":`Une mesure toutes les ${da(a.median_ms)} sur ${ne(a.observations_count)} intervalles`+(a.provisional?", cadence encore provisoire.":".")}const zi={ready:{level:"ok",word:"Prête",detail:"Elle répond et n’a rien à signaler."},consumable:{level:"warn",word:"Rouleau en fin de vie",detail:"Elle imprime, mais le rouleau arrive en fin de vie."},faulted:{level:"fault",word:"En panne",detail:"Elle ne peut pas imprimer."},unknown:{level:"unknown",word:"Silencieuse",detail:"Elle prend les étiquettes et ne dit rien en retour : c’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne."}};function Ri(a){const t=zi[a.health]??{level:"unknown",word:"État inconnu",detail:"Le poste a répondu un état que cet écran ne sait pas nommer."};return{...t,detail:a.detail===""?t.detail:a.detail}}function ji(a){const t=a.observed_at===""?"Jamais observée depuis le démarrage":`Observée le ${Lt(a.observed_at)}`,r=a.pending_jobs_count;return`${t}, ${ne(r)} ${r>1?"travaux":"travail"} en attente.`}const Ai=["queue","path","address"],Oi="queue",Ni={queue:"Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.",path:"Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.",address:"L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque."};function Ii(a,t){return t!==""&&!a.some(r=>r.id===t)}function Di(a,t){return a.length===0?[]:[...Ii(a,t)?[{value:t,label:`${t} — inconnu de ce poste`}]:[],...a.map(r=>({value:r.id,label:r.label}))]}function Fi(a,t){return a.find(r=>r.id===t)?.key??Oi}function Ui(a,t,r){const i=[...new Set(a.filter(x=>x.key!==r).map(x=>Mi(t,x.key)))].filter(x=>x!==""),d=a.filter(x=>x.key!==r).length,p=`${ne(d)} ${d>1?"destinations ne sont pas proposées":"destination n’est pas proposée"}`;return i.length===0?`${p} : aucun transport de ce poste ne les lit.`:`${p} : choisissez « ${i.join(" » ou « ")} » pour les voir.`}function Mi(a,t){return a.find(r=>r.key===t)?.label??""}var Wi=c(''),Bi=c(`

                                                Lecture de la configuration en cours… tant qu’elle n’est pas arrivée, cette page ne + déclare rien de ce poste.

                                                `),sr=c('
                                              • '),Vi=c('
                                                  '),Gi=c('

                                                  ',1),Hi=c(''),Ji=c('
                                                • '),Ki=c('

                                                    ',1),$i=c('

                                                    '),Yi=c('
                                                  • '),Xi=c('
                                                      '),Qi=c('

                                                      '),Zi=c('

                                                        ',1),eo=c('

                                                        '),to=c('

                                                        L’état et la cadence viennent de ce que le poste observe vraiment, jamais d’un réglage.

                                                        Réglages série de la balance

                                                        Réglages de l’imprimante
                                                        ');function ao(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=20,d=3,p=250,x=50,A=d*1e3+p+1e3,_=12;let P=H(dt([])),w=H(dt([])),N=H(dt([])),L=H(dt([])),V=H(""),B=H(!1),R=H(""),U=H(0),F=H(0),E=H(!1),y=H(null),m=H(!1),D=H(!1),J=!1;const K=u(()=>t.health.state.scale),W=u(()=>t.health.state.printer),T=u(()=>t.draft.config!==null),S=u(()=>e(T)&&!t.draft.flag("scale.present")),k=u(()=>t.draft.text("scale.options.port")),O=u(()=>e(y)!==null&&e(y).port===e(k)?e(y).reason:""),M=u(()=>e(V)===e(k)?e(L):[]),C=u(()=>t.health.printer_transports),X=u(()=>t.draft.text("printer.options.transport")),Z=u(()=>Di(e(C),e(X))),le=u(()=>Fi(e(C),e(X))),j=u(()=>"printer.options."+e(le)),b=u(()=>e(w).filter(g=>g.key===e(le))),z=u(()=>e(w).length-e(b).length),$=u(()=>Ti(t.health.scale_present,e(K))),se=u(()=>Ri(e(W))),oe=u(()=>({configRead:e(T),declaredWithoutScale:e(S),port:e(k),listed:e(B),portKnown:de(e(k)),halt:e(O),acting:e(R),framesShown:e(M).length,framesKept:i})),ce=u(()=>Li(e(oe))),ie=u(()=>Ci(e(oe))),I=u(()=>Pi({acting:e(R),listening:e(E),scanned:e(U),toScan:e(F)})),Q=u(()=>Ke(t.draft,"scale.type")!==""||Ke(t.draft,"scale.options.port")!==""),ae=u(()=>Ke(t.draft,"printer.type")!==""||Ke(t.draft,"printer.options.transport")!==""||Ai.some(g=>Ke(t.draft,"printer.options."+g)!==""));_t(()=>{e(Q)&&v(m,!0)}),_t(()=>{e(ae)&&v(D,!0)}),nn(()=>{J=!0}),Ia(()=>{me("ports",fe)}),_t(()=>{e(E)||!ee(e(k))||xe(e(k))});function ee(g){return!J&&e(T)&&!e(S)&&g!==""&&g===e(k)&&de(g)&&e(O)===""&&e(R)===""}function de(g){return e(P).some(G=>G.name===g)}async function xe(g){v(E,!0);try{for(;ee(g);){try{q(g,await Ja(g,d))}catch(G){v(y,{port:g,reason:rt(G)},!0);return}await ue()}}finally{v(E,!1)}}async function Ne(){const g=e(k),G=await r().protect(()=>Ja(g,d));G!==null&&(q(g,G),v(y,null))}function q(g,G){if(J||G.length===0)return;const ve=g===e(V)?e(L):[];v(V,g,!0),v(L,[...ve,...G].slice(-i),!0)}function ue(g=p){return new Promise(G=>setTimeout(G,g))}async function pe(){const g=Date.now()+A;for(;e(E)&&Date.now()xr(g));G!==null&&(r().notice=G.message,await r().refresh())}async function ye(){v(U,0),v(F,0),e(B)||await fe();const g=e(P);if(g.length===0){v(N,[],!0);return}await r().protect(()=>De(g))}async function De(g){v(N,[],!0),v(F,g.length,!0);for(const[G,ve]of g.entries()){v(U,G+1);try{const re=await zn(ve.name);v(N,[...e(N),{port:ve.name,message:re.message,driver:re.driver,validFrames:re.valid_frames_count,refused:!1}],!0)}catch(re){if(ma(re))throw re;v(N,[...e(N),{port:ve.name,message:rt(re),driver:"",validFrames:0,refused:!0}],!0)}}}function qe(g){return g.driver!==""&&g.validFrames>0}function Fe(g){t.draft.set("scale.present",!0),t.draft.set("scale.type",g.driver),t.draft.set("scale.options.port",g.port)}function at(g,G){const ve=Ht(t.draft,g);return ve.length>0?ve:G.slice(0,_)}function rt(g){return g instanceof Tt?g.message:g instanceof Error?"Le poste n’a pas répondu : "+g.message:"Le poste n’a pas répondu."}const vt=[{what:"label",name:"étiquette"},{what:"alignment",name:"alignement"},{what:"ruler",name:"réglette"}],ot=u(()=>vt.filter(g=>t.health.printer_self_tests.includes(g.what)));var ct=to(),pt=s(ct),gt=s(pt);ar(gt,{title:"Balance",name:"scale",get standing(){return e($)}});var Ze=n(gt,2),he=s(Ze),Ee=n(Ze,4);{var st=g=>{var G=Wi(),ve=s(G);f(()=>ea(ve,e(S))),Ce("change",ve,re=>t.draft.set("scale.present",!re.currentTarget.checked)),l(g,G)},Kt=g=>{var G=Bi();l(g,G)};h(Ee,g=>{e(T)?g(st):g(Kt,-1)})}var Et=n(Ee,2),Ct=s(Et);{let g=u(()=>e(R)==="ports"),G=u(()=>e(R)!=="");ze(Ct,{label:"Lister les ports",get busy(){return e(g)},get disabled(){return e(G)},onrun:()=>{me("ports",fe)}})}var Rt=n(Ct,2);{let g=u(()=>e(R)!=="");ze(Rt,{act:"detect",get label(){return e(I)},protected:!0,get disabled(){return e(g)},onrun:()=>{me("detect",ye)}})}var jt=n(Et,2);{var Ot=g=>{var G=Gi(),ve=te(G),re=s(ve),Te=n(ve,2);{var Oe=We=>{var bt=Vi();Re(bt,21,()=>e(P).slice(0,_),kt=>kt.name,(kt,mt)=>{var Mt=sr(),Wt=s(Mt),ya=s(Wt),xa=n(Wt,2),ka=s(xa);f(()=>{o(ya,e(mt).name),o(ka,`${(e(mt).description===""?"aucune description USB":e(mt).description)??""} + ${e(mt).vid===""?"":` — VID ${e(mt).vid} PID ${e(mt).pid}`}`)}),Ce("click",Wt,()=>t.draft.set("scale.options.port",e(mt).name)),l(kt,Mt)}),l(We,bt)};h(Te,We=>{e(P).length>0&&We(Oe)})}f(We=>o(re,We),[()=>e(P).length===0?"Aucun port série n’est visible depuis ce poste.":Ca("port détecté","ports détectés",e(P).length,_)]),l(g,G)};h(jt,g=>{e(B)&&g(Ot)})}var $t=n(jt,2);{var Yt=g=>{var G=Ki(),ve=te(G),re=s(ve),Te=n(ve,2);Re(Te,21,()=>e(N).slice(0,_),Oe=>Oe.port,(Oe,We)=>{var bt=Ji();let kt;var mt=s(bt),Mt=s(mt),Wt=n(mt,2),ya=s(Wt),xa=n(Wt,2);{var ka=Sa=>{var qa=Hi();f(()=>qa.disabled=!e(T)),Ce("click",qa,()=>Fe(e(We))),l(Sa,qa)},Ir=u(()=>qe(e(We)));h(xa,Sa=>{e(Ir)&&Sa(ka)})}f(()=>{kt=Dt(bt,1,"svelte-1p4i0xm",null,kt,{refused:e(We).refused}),o(Mt,e(We).port),o(ya,e(We).message)}),l(Oe,bt)}),f(Oe=>o(re,Oe),[()=>Ca("port interrogé","ports interrogés",e(N).length,_)]),l(g,G)};h($t,g=>{e(N).length>0&&g(Yt)})}var Nt=n($t,2),ta=n(s(Nt),2),Ut=s(ta);{let g=u(()=>t.draft.text("scale.type")),G=u(()=>Ke(t.draft,"scale.type")),ve=u(()=>Ht(t.draft,"scale.type")),re=u(()=>!e(T));Qe(Ut,{label:"Protocole",path:"scale.type",get value(){return e(g)},hint:"Les valeurs acceptées apparaissent ici si l’enregistrement est refusé.",get fault(){return e(G)},get allowed(){return e(ve)},get disabled(){return e(re)},onchange:Te=>t.draft.set("scale.type",Te)})}var aa=n(Ut,2);{let g=u(()=>t.draft.text("scale.options.port")),G=u(()=>Ke(t.draft,"scale.options.port")),ve=u(()=>at("scale.options.port",e(P).map(Te=>Te.name))),re=u(()=>!e(T));Qe(aa,{label:"Port série",path:"scale.options.port",get value(){return e(g)},hint:"Choisissez-le dans la liste détectée ci-dessus plutôt que de le taper : l’écoute permanente ne suit que des ports détectés.",get fault(){return e(G)},get allowed(){return e(ve)},get disabled(){return e(re)},onchange:Te=>t.draft.set("scale.options.port",Te)})}var ra=n(Nt,2),Y=s(ra),ge=s(Y),be=n(Y,2);{var Pe=g=>{var G=$i(),ve=s(G),re=n(ve);{var Te=Oe=>{{let We=u(()=>e(R)==="listen"),bt=u(()=>e(R)!=="");ze(Oe,{act:"listen",get label(){return e(ie)},protected:!0,get busy(){return e(We)},get disabled(){return e(bt)},onrun:()=>{me("listen",Ne)}})}};h(re,Oe=>{e(ie)!==""&&Oe(Te)})}f(()=>o(ve,`${e(O)??""} `)),l(g,G)};h(be,g=>{(e(O)!==""||e(ie)!=="")&&g(Pe)})}var $e=n(be,2);{var He=g=>{var G=Xi();Re(G,21,()=>e(M),pa,(ve,re)=>{var Te=Yi(),Oe=s(Te),We=s(Oe),bt=n(Oe,2),kt=s(bt);f((mt,Mt)=>{o(We,mt),o(kt,Mt)},[()=>Si(e(re)),()=>qi(e(re))]),l(ve,Te)}),l(g,G)};h($e,g=>{e(M).length>0&&g(He)})}var et=n(pt,2),Ie=s(et);ar(Ie,{title:"Imprimante",name:"printer",get standing(){return e(se)}});var Ae=n(Ie,2),Ye=s(Ae),Ve=n(Ae,2),tt=s(Ve),lt=n(Ve,2),Le=s(lt);{let g=u(()=>e(R)==="printers"),G=u(()=>e(R)!=="");ze(Le,{label:"Lister les files",get busy(){return e(g)},get disabled(){return e(G)},onrun:()=>{me("printers",ke)}})}var Xe=n(Le,2);{let g=u(()=>e(R)==="discover"),G=u(()=>e(R)!=="");ze(Xe,{label:"Rechercher l’imprimante",protected:!0,get busy(){return e(g)},get disabled(){return e(G)},onrun:()=>{me("discover",Se)}})}var Je=n(Xe,2);Re(Je,17,()=>e(ot),g=>g.what,(g,G)=>{{let ve=u(()=>`Auto-test : ${e(G).name}${e(R)===e(G).what?" — en cours…":""}`),re=u(()=>e(R)!=="");ze(g,{get act(){return e(G).what},get label(){return e(ve)},protected:!0,get disabled(){return e(re)},onrun:()=>{me(e(G).what,()=>we(e(G).what))}})}});var ut=n(lt,2);{var ft=g=>{var G=Qi(),ve=s(G);f(()=>o(ve,e(ot).length===0?"Le driver d’impression en service n’imprime aucun auto-test.":"Les autres auto-tests ne sont pas proposés : le driver d’impression en service ne les imprime pas.")),l(g,G)};h(ut,g=>{e(ot).length{var G=Zi(),ve=te(G),re=s(ve),Te=n(ve,2);Re(Te,21,()=>e(b).slice(0,_),Oe=>Oe.name,(Oe,We)=>{var bt=sr(),kt=s(bt),mt=s(kt),Mt=n(kt,2),Wt=s(Mt);f(()=>{o(mt,e(We).name),o(Wt,`${e(We).detail??""}${e(We).default?" — file par défaut du système":""}`)}),Ce("click",kt,()=>t.draft.set(e(j),e(We).name)),l(Oe,bt)}),f(Oe=>o(re,Oe),[()=>Ca("destination","destinations",e(b).length,_)]),l(g,G)};h(wt,g=>{e(b).length>0&&g(Pt)})}var yt=n(wt,2);{var At=g=>{var G=eo(),ve=s(G);f(re=>o(ve,re),[()=>Ui(e(w),e(C),e(le))]),l(g,G)};h(yt,g=>{e(z)>0&&g(At)})}var xt=n(yt,2),Xt=n(s(xt),2),na=s(Xt);{let g=u(()=>t.draft.text("printer.type")),G=u(()=>Ke(t.draft,"printer.type")),ve=u(()=>Ht(t.draft,"printer.type")),re=u(()=>!e(T));Qe(na,{label:"Driver",path:"printer.type",get value(){return e(g)},hint:"Gardez le driver raster : c’est celui que les postes en service utilisent.",get fault(){return e(G)},get allowed(){return e(ve)},get disabled(){return e(re)},onchange:Te=>t.draft.set("printer.type",Te)})}var It=n(na,2);{let g=u(()=>Ke(t.draft,"printer.options.transport")),G=u(()=>Ht(t.draft,"printer.options.transport")),ve=u(()=>!e(T));Qe(It,{label:"Transport",path:"printer.options.transport",get value(){return e(X)},hint:"Local par défaut : une file Windows ou un nœud d’impression de ce poste.",get fault(){return e(g)},get allowed(){return e(G)},get choices(){return e(Z)},get disabled(){return e(ve)},onchange:re=>t.draft.set("printer.options.transport",re)})}var la=n(It,2);{let g=u(()=>St(e(j))),G=u(()=>t.draft.text(e(j))),ve=u(()=>Ni[e(le)]??""),re=u(()=>Ke(t.draft,e(j))),Te=u(()=>at(e(j),e(b).map(We=>We.name))),Oe=u(()=>!e(T));Qe(la,{get label(){return e(g)},get path(){return e(j)},get value(){return e(G)},get hint(){return e(ve)},get fault(){return e(re)},get allowed(){return e(Te)},get disabled(){return e(Oe)},onchange:We=>t.draft.set(e(j),We)})}f(g=>{o(he,e($).detail),o(ge,e(ce)),o(Ye,e(se).detail),o(tt,g)},[()=>ji(e(W))]),Ga("open","toggle",Nt,g=>v(m,g),()=>e(m)),Ga("open","toggle",xt,g=>v(D,g),()=>e(D)),l(a,ct),Me()}it(["change","click"]);const lr={sent:"envoyée à l’imprimante",rejected:"refusée",failed:"en échec",reprint:"réimpression"},ir=[{value:"",label:"toutes"},{value:"sent",label:"envoyées à l’imprimante"},{value:"rejected",label:"refusées"},{value:"failed",label:"en échec"},{value:"reprint",label:"réimpressions"}],ro={scale:"balance",manual:"saisie manuelle",replay:"trame rejouée"},no={stable:"stable",unstable:"instable",unknown:"non déclarée par la balance",not_applicable:"sans objet — saisie manuelle"},so={by_weight:"au poids",by_unit:"à l’unité"},lo={debug:"mise au point",info:"information",warn:"avertissement",error:"erreur",critical:"critique"};function sa(a,t,r){return a[t]??r}var io=c(' '),oo=c(' '),uo=c('
                                                      • '),co=c('
                                                          ');function vo(a,t){Ue(t,!0);var r=co(),i=s(r);Re(i,21,()=>t.lines,d=>d.id,(d,p)=>{var x=uo(),A=s(x),_=s(A),P=n(A,2),w=s(P),N=n(P,2),L=s(N),V=n(N,2);{var B=y=>{var m=io(),D=s(m);f(()=>o(D,e(p).code)),l(y,m)};h(V,y=>{e(p).code!==""&&qt.showTechnicalNames&&y(B)})}var R=n(V,2),U=s(R),F=n(R,2);{var E=y=>{var m=oo(),D=s(m);f(()=>o(D,e(p).detail)),l(y,m)};h(F,y=>{e(p).detail!==""&&y(E)})}f((y,m,D)=>{_e(x,"data-level",e(p).level),o(_,y),o(w,m),o(L,D),o(U,e(p).message)},[()=>Lt(e(p).occurred_at),()=>sa(lo,e(p).level,"niveau inconnu"),()=>Sr(e(p).source)]),l(d,x)}),l(a,r),Me()}var po=c(""),fo=c('L’export sera proposé quand le journal aura répondu.'),go=c('L’export n’est pas proposé : ce poste n’a pas répondu à la lecture du journal.'),mo=c('Exporter en CSV'),ho=c('

                                                          '),_o=c('

                                                          '),bo=c(`

                                                          Aucune trame brute n’a été enregistrée pour cette pesée : il n’y a + rien à rejouer.

                                                          `),wo=c(`

                                                          La trame repart dans le décodeur du poste EN SERVICE : le poids + affiché au client change, et rien ne le remet comme il était. C’est + ce qui fait d’un refus inexpliqué un test permanent, sans + déplacement au magasin et sans balance.

                                                          `,1),yo=c('

                                                          Produit
                                                          Référence
                                                          Vente
                                                          Brut / tare / net
                                                          Stabilité
                                                          Origine du poids
                                                          Résultat
                                                          Trame brute
                                                          '),xo=c(' ',1),ko=c('

                                                          QuandProduitNetCode-barresRésultatDuréeDétail
                                                          ',1),So=c('
                                                          ',1),qo=c('

                                                          '),Lo=c('

                                                          ',1),Eo=c('
                                                          ');function Co(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=200,d=5e3,p=50,x=500,A=7;let _=H(dt([])),P=H(dt([])),w=H(""),N=H(null),L=H(!1),V=H(!1),B=H("loading"),R=H("loading"),U=H(""),F=H("");const E=u(()=>({limit:String(i),...e(w)===""?{}:{result:e(w)}})),y=u(()=>({...e(E),limit:String(d)})),m=u(()=>e(_).find(C=>C.id===e(N))??null),D=u(()=>ir.find(C=>C.value===e(w))?.label??""),J=u(()=>S(e(_).length,i,"pesée","pesées",`L’export CSV en emporte jusqu’à ${ne(d)}.`)),K=u(()=>S(e(P).length,p,"ligne","lignes",`Le fichier de diagnostic emporte les ${ne(x)} dernières.`));W();async function W(){v(N,null),v(V,!0),v(B,"loading"),v(R,"loading"),v(_,[],!0),v(P,[],!0),v(U,""),v(F,"");try{const C=await r().load(()=>jn(e(E)));v(B,C===null?"unread":"read",!0),v(U,C===null?r().actionError:"",!0),v(_,C??[],!0);const X=await r().load(()=>On({limit:String(p)}));v(R,X===null?"unread":"read",!0),v(F,X===null?r().actionError:"",!0),v(P,X??[],!0)}finally{v(V,!1),r().actionError=""}}async function T(C){r().notice="",r().actionError="",v(L,!0);try{const X=await r().protect(()=>Fn(C));X!==null&&(r().notice=X.message)}finally{v(L,!1)}}function S(C,X,Z,le,j){const b=C>1?le:Z;return C{var Z=So(),le=te(Z),j=n(s(le),2);Re(j,21,()=>ir,ee=>ee.value,(ee,de)=>{var xe=po(),Ne=s(xe),q={};f(()=>{o(Ne,e(de).label),q!==(q=e(de).value)&&(xe.value=(xe.__value=e(de).value)??"")}),l(ee,xe)});var b=n(j,2);ze(b,{label:"Rafraîchir",get busy(){return e(V)},onrun:()=>{W()}});var z=n(b,2);{var $=ee=>{var de=fo();l(ee,de)},se=ee=>{var de=go();l(ee,de)},oe=ee=>{var de=mo();f(xe=>_e(de,"href",xe),[()=>An(e(y))]),l(ee,de)};h(z,ee=>{e(B)==="loading"?ee($):e(B)==="unread"?ee(se,1):ee(oe,-1)})}var ce=n(le,2);{var ie=ee=>{var de=ho(),xe=s(de);f(Ne=>o(xe,`L’export emporte le même filtre que le tableau, mais pas son plafond : il descend + jusqu’à ${Ne??""} pesées, en point-virgule et en UTF-8 — il s’ouvre + tel quel dans le tableur d’un Windows français. Il ne demande aucun mot de passe : la + lecture du journal n’en demande pas non plus, et le fichier de diagnostic emporte déjà + les deux cents dernières pesées.`),[()=>ne(d)]),l(ee,de)};h(ce,ee=>{e(B)==="read"&&ee(ie)})}var I=n(ce,2);{var Q=ee=>{var de=_o(),xe=s(de);{var Ne=me=>{var fe=nt("Lecture du journal…");l(me,fe)},q=me=>{var fe=nt();f(()=>o(fe,`Le journal n’a pas pu être lu : ${e(U)??""} Ce n’est pas « aucune pesée ».`)),l(me,fe)},ue=me=>{var fe=nt("Le journal ne contient aucune pesée.");l(me,fe)},pe=me=>{var fe=nt();f(()=>o(fe,`Aucune pesée ne correspond au filtre « ${e(D)??""} ».`)),l(me,fe)};h(xe,me=>{e(B)==="loading"?me(Ne):e(B)==="unread"?me(q,1):e(w)===""?me(ue,2):me(pe,-1)})}l(ee,de)},ae=ee=>{var de=ko(),xe=te(de),Ne=s(xe),q=n(xe,2),ue=s(q),pe=n(s(ue));Re(pe,21,()=>e(_),me=>me.id,(me,fe)=>{var ke=xo(),Se=te(ke);let we;var ye=s(Se),De=s(ye),qe=n(ye),Fe=s(qe),at=n(qe),rt=s(at),vt=n(at),ot=s(vt),ct=n(vt),pt=s(ct),gt=n(ct),Ze=s(gt),he=n(gt),Ee=s(he),st=s(Ee),Kt=n(Se,2);{var Et=Ct=>{var Rt=yo(),jt=s(Rt);_e(jt,"colspan",A);var Ot=s(jt),$t=s(Ot),Yt=n(Ot,2),Nt=n(s(Yt),2),ta=s(Nt),Ut=n(Nt,4),aa=s(Ut),ra=n(Ut,4),Y=s(ra),ge=n(ra,4),be=s(ge),Pe=n(ge,4),$e=s(Pe),He=n(Pe,4),et=s(He),Ie=n(He,4),Ae=s(Ie),Ye=n(Ie,4),Ve=s(Ye),tt=s(Ve),lt=n(Yt,2);{var Le=Je=>{var ut=bo();l(Je,ut)},Xe=Je=>{var ut=wo(),ft=te(ut),wt=s(ft);ze(wt,{kind:"destructive",label:"Rejouer cette trame",protected:!0,get busy(){return e(L)},onrun:()=>{T(e(m).frame)}}),l(Je,ut)};h(lt,Je=>{e(m).frame===""?Je(Le):Je(Xe,-1)})}f((Je,ut,ft,wt,Pt,yt,At,xt,Xt)=>{_e(Rt,"data-detail",e(fe).id),o($t,`Pesée ${e(m).id??""}`),o(ta,`${e(m).product_name??""} (${e(m).product_id??""})`),o(aa,e(m).reference),o(Y,`${Je??""} — + ${ut??""} + ${e(m).quantity>1?"unités":"unité"}`),o(be,`${ft??""} g / ${wt??""} g / + ${Pt??""} g`),o($e,`${yt??""} — cadence + médiane ${At??""}`),o(et,xt),o(Ae,`${Xt??""}${e(m).detail===""?"":` — ${e(m).detail}`}`),o(tt,e(m).frame===""?"aucune trame enregistrée":e(m).frame)},[()=>sa(so,e(m).mode,"mode de vente inconnu"),()=>ne(e(m).quantity),()=>ne(e(m).gross_g),()=>ne(e(m).tare_g),()=>ne(e(m).net_g),()=>sa(no,e(m).stability,"stabilité inconnue"),()=>da(e(m).rate_ms),()=>sa(ro,e(m).source,"origine inconnue"),()=>sa(lr,e(m).result,"résultat inconnu")]),l(Ct,Rt)};h(Kt,Ct=>{e(N)===e(fe).id&&e(m)!==null&&Ct(Et)})}f((Ct,Rt,jt,Ot)=>{we=Dt(Se,1,"svelte-86o8oz",null,we,{open:e(N)===e(fe).id}),o(De,Ct),o(Fe,e(fe).product_name),o(rt,`${Rt??""} g`),o(ot,e(fe).barcode),o(pt,jt),o(Ze,Ot),_e(Ee,"aria-expanded",e(N)===e(fe).id),o(st,e(N)===e(fe).id?"fermer":"détail")},[()=>Lt(e(fe).occurred_at),()=>ne(e(fe).net_g),()=>sa(lr,e(fe).result,"résultat inconnu"),()=>da(e(fe).duration_ms)]),Ce("click",Ee,()=>v(N,e(N)===e(fe).id?null:e(fe).id,!0)),l(me,ke)}),f(()=>o(Ne,e(J))),l(ee,de)};h(I,ee=>{e(_).length===0?ee(Q):ee(ae,-1)})}Ce("change",j,()=>{W()}),rn(j,()=>e(w),ee=>v(w,ee)),l(C,Z)},$$slots:{default:!0}});var M=n(O,2);je(M,{title:"Journal technique",children:(C,X)=>{var Z=zt(),le=te(Z);{var j=z=>{var $=qo(),se=s($);{var oe=I=>{var Q=nt("Lecture du journal technique…");l(I,Q)},ce=I=>{var Q=nt();f(()=>o(Q,`Le journal technique n’a pas pu être lu : ${e(F)??""} Ce n’est pas « aucune + ligne ».`)),l(I,Q)},ie=I=>{var Q=nt("Aucune ligne technique.");l(I,Q)};h(se,I=>{e(R)==="loading"?I(oe):e(R)==="unread"?I(ce,1):I(ie,-1)})}l(z,$)},b=z=>{var $=Lo(),se=te($),oe=s(se),ce=n(se,2);vo(ce,{get lines(){return e(P)}}),f(()=>o(oe,e(K))),l(z,$)};h(le,z=>{e(P).length===0?z(j):z(b,-1)})}l(C,Z)},$$slots:{default:!0}}),l(a,k),Me()}it(["change","click"]);const or="L’aperçu n’a pas pu être rendu par le poste.",Po="Le poste a rendu l’aperçu, mais le navigateur ne l’a pas affiché.";async function To(a){try{const t=await fetch(a,{headers:{accept:"application/json"}});if(t.ok)return Po;const r=JSON.parse(await t.text());return typeof r.message=="string"&&r.message!==""?r.message:or}catch{return or}}function ur(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return 0;r=r[i]}return typeof r=="number"?r:0}function Rr(a){const t=Number(a);return a.trim()===""||Number.isNaN(t)?null:t}function zo(a){const t=a.trim().replace(",",".");if(!/^\d{1,3}(\.\d)?$/u.test(t))return null;const r=Number(t);return r>100?null:r}function jr(a,t){!(a instanceof HTMLInputElement)||a.value===t||(a.value=t)}var Ro=c('

                                                          '),jo=c('

                                                          '),Ao=c('

                                                          '),Oo=c('

                                                          '),No=c('

                                                          Lecture de la configuration en cours… les flèches attendent qu’elle soit arrivée.

                                                          '),Io=c(`
                                                          Aperçu de l’étiquette telle qu’elle serait imprimée

                                                          Décalage horizontal

                                                          Décalage vertical

                                                          Le décalage ne descend pas sous zéro : le poste refuse un décalage négatif quel que + soit le gabarit. Le maximum, lui, dépend de la géométrie du gabarit ; il est annoncé + ici si l’enregistrement le dépasse.

                                                          Le symbole code-barres est volontairement tronqué : un symbole conforme n’entre pas + sur 40 × 25 mm avec les cinq champs texte. Ce n’est pas un défaut de rendu et il n’y + a rien à corriger.

                                                          Le détail chiffré du symbole — largeur de module, modules rendus, modules attendus — + n’est pas servi par ce poste. Cet écran n’affiche pas un chiffre qu’il aurait deviné.

                                                          `,1),Do=c(`

                                                          Chaque appui sort une étiquette pour de bon : le mot de passe est demandé au moment + d’imprimer, et l’impression repart d’elle-même une fois la session ouverte.

                                                          `,1),Fo=c('
                                                          ');function Uo(a,t){Ue(t,!0);const r=Be(t,"admin",7),i="printer.template",d="printer.options.offset_x",p="printer.options.offset_y",x=0,A=[{what:"alignment",label:"Imprimer la mire d’alignement"},{what:"ruler",label:"Imprimer la réglette"}];let _=H(1),P=H(!0),w=H(!1),N=H(""),L=H(null),V=H(""),B=null,R=!1;const U=u(()=>t.draft.text(i)),F=u(()=>Rn(e(U),e(P),e(w),e(_))),E=u(()=>t.draft.config!==null),y=u(()=>t.draft.number(d)),m=u(()=>t.draft.number(p)),D=u(()=>Ke(t.draft,d)),J=u(()=>Ke(t.draft,p)),K=u(M),W=u(()=>r().busy||e(V)!=="");_t(()=>{if(t.draft.config===null)return;if(t.draft.dirty){B===null&&T();return}const z={x:t.draft.number(d),y:t.draft.number(p)};if(B!==null&&B.x===z.x&&B.y===z.y)return;const $=B===null;B=z,v(L,z,!0),$||k()});async function T(){if(!R){R=!0;try{const z=await Da(),$={x:ur(z.config,d),y:ur(z.config,p)};B=$,v(L,$,!0)}catch{}finally{R=!1}}}function S(z,$){const se=t.draft.number(z)+$;se=2?"s":""}`}function X(z,$){const se=Rr($);se!==null&&t.draft.set(z,se)}async function Z(z){if(!e(W)){v(V,z,!0),r().actionError="",r().notice="";try{const $=await r().protect(()=>xr(z));if($===null)return;r().notice=$.message,await r().refresh()}finally{v(V,"")}}}var le=Fo(),j=s(le);je(j,{title:"Aperçu de l’étiquette",note:"Le même moteur que l’impression (A2) : le décalage se voit parce qu’il est cuit dans le bitmap. L’image porte le gabarit en cours d’édition, mais le décalage ENREGISTRÉ — jamais celui que les flèches sont en train de régler.",children:(z,$)=>{var se=Io(),oe=te(se);{var ce=he=>{var Ee=Ro(),st=s(Ee);f(()=>o(st,e(K))),l(he,Ee)};h(oe,he=>{e(K)!==""&&he(ce)})}var ie=n(oe,2),I=s(ie),Q=s(I),ae=n(Q,2);{var ee=he=>{var Ee=jo(),st=s(Ee);f(()=>o(st,e(N))),l(he,Ee)};h(ae,he=>{e(N)!==""&&he(ee)})}var de=n(I,2),xe=s(de),Ne=n(s(xe),2),q=s(Ne),ue=n(xe,2),pe=s(ue);{let he=u(()=>!e(E)||e(y)<=x);ze(pe,{label:"← 1 dot",get disabled(){return e(he)},onrun:()=>S(d,-1)})}var me=n(pe,2);{let he=u(()=>!e(E));ze(me,{label:"1 dot →",get disabled(){return e(he)},onrun:()=>S(d,1)})}var fe=n(ue,2);{var ke=he=>{var Ee=Ao(),st=s(Ee);f(()=>o(st,e(D))),l(he,Ee)};h(fe,he=>{e(D)!==""&&he(ke)})}var Se=n(fe,2),we=n(s(Se),2),ye=s(we),De=n(Se,2),qe=s(De);{let he=u(()=>!e(E)||e(m)<=x);ze(qe,{label:"↑ 1 dot",get disabled(){return e(he)},onrun:()=>S(p,-1)})}var Fe=n(qe,2);{let he=u(()=>!e(E));ze(Fe,{label:"1 dot ↓",get disabled(){return e(he)},onrun:()=>S(p,1)})}var at=n(De,2);{var rt=he=>{var Ee=Oo(),st=s(Ee);f(()=>o(st,e(J))),l(he,Ee)};h(at,he=>{e(J)!==""&&he(rt)})}var vt=n(at,4),ot=s(vt),ct=n(vt,2),pt=s(ct),gt=n(ct,2);{var Ze=he=>{var Ee=No();l(he,Ee)};h(gt,he=>{e(E)||he(Ze)})}f((he,Ee)=>{_e(Q,"src",e(F)),o(q,he),o(ye,Ee)},[()=>C(e(y)),()=>C(e(m))]),Jt("load",Q,()=>v(N,"")),Jt("error",Q,()=>{O(e(F))}),Ce("change",ot,()=>k()),Va(ot,()=>e(P),he=>v(P,he)),Ce("change",pt,()=>k()),Va(pt,()=>e(w),he=>v(w,he)),l(z,se)},$$slots:{default:!0}});var b=n(j,2);je(b,{title:"Gabarit et impression",children:(z,$)=>{var se=Do(),oe=te(se);{let ae=u(()=>Ke(t.draft,i)),ee=u(()=>!e(E));Qe(oe,{label:"Gabarit",path:i,get value(){return e(U)},hint:"Le gabarit reproduit à l’identique s’appelle weighing_identical (A1).",get fault(){return e(ae)},get disabled(){return e(ee)},onchange:de=>{t.draft.set(i,de),k()}})}var ce=n(oe,2);{let ae=u(()=>t.draft.text("printer.options.darkness")),ee=u(()=>Ke(t.draft,"printer.options.darkness")),de=u(()=>!e(E));Qe(ce,{label:"Noircissement",path:"printer.options.darkness",kind:"number",get value(){return e(ae)},hint:"Trop bas, l’étiquette pâlit au soleil ; trop haut, elle bave et le scanner refuse.",get fault(){return e(ee)},get disabled(){return e(de)},onchange:xe=>X("printer.options.darkness",xe)})}var ie=n(ce,2);{let ae=u(()=>t.draft.text("printer.options.speed")),ee=u(()=>Ke(t.draft,"printer.options.speed")),de=u(()=>!e(E));Qe(ie,{label:"Vitesse",path:"printer.options.speed",kind:"number",get value(){return e(ae)},get fault(){return e(ee)},get disabled(){return e(de)},onchange:xe=>X("printer.options.speed",xe)})}var I=n(ie,2);{let ae=u(()=>t.draft.text("printer.options.copies")),ee=u(()=>Ke(t.draft,"printer.options.copies")),de=u(()=>!e(E));Qe(I,{label:"Exemplaires",path:"printer.options.copies",kind:"number",get value(){return e(ae)},hint:"Un client repart avec une étiquette : deux exemplaires se justifient, pas se devinent.",get fault(){return e(ee)},get disabled(){return e(de)},onchange:xe=>X("printer.options.copies",xe)})}var Q=n(I,2);Re(Q,21,()=>A,ae=>ae.what,(ae,ee)=>{{let de=u(()=>e(V)===e(ee).what);ze(ae,{get act(){return e(ee).what},get label(){return e(ee).label},protected:!0,get busy(){return e(de)},get disabled(){return e(W)},onrun:()=>{Z(e(ee).what)}})}}),l(z,se)},$$slots:{default:!0}}),l(a,le),Me()}it(["change"]);const Ar=[{rank:1,code:"OVERLOAD",label:"Surcharge",when:"La balance annonce elle-même OL, ou le poids brut dépasse la capacité.",severity:"Bloquant",blocking:!0,message:"La balance est en surcharge. Retirez votre article.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la capacité, réglée au garde-fou 9 sous « ${St("limits.max_weight_g")} ».`},{rank:2,code:"MEASUREMENT_EXPIRED",label:"Poids périmé",when:"La mesure est plus vieille que la péremption, dans les deux modes de stabilité.",severity:"Bloquant",blocking:!0,message:"Poids indisponible. Patientez ou appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil à régler : le poste calcule lui-même à partir du rythme de la balance."},{rank:3,code:"BASKET_MISSING",label:"Panier absent",when:"Le poids brut tombe dans la fenêtre négative du panier : il a été soulevé.",severity:"Bloquant",blocking:!0,message:"Le panier n'est pas sur la balance. Reposez-le.",thresholds:[{path:"limits.basket_min_g",label:"Bas de la fenêtre du panier",hint:"En grammes, NÉGATIF : c’est le poids du panier que la balance a perdu."},{path:"limits.basket_max_g",label:"Haut de la fenêtre du panier",hint:"Négatif lui aussi, et plus proche de zéro que le bas."}],switchPath:"limits.basket_check_enabled",switchLabel:"Ce poste travaille avec un panier taré",note:"La règle s’active ou non, en bloc : il n’y a pas de demi-mesure à régler."},{rank:4,code:"SCALE_EMPTY",label:"Plateau vide",when:"Le poids brut ne sort pas de la bande « il n’y a rien sur le plateau ».",severity:"Bloquant — un filet, hors parcours nominal",blocking:!0,message:"Posez votre produit.",thresholds:[{path:"limits.empty_max_g",label:"Plateau considéré vide",hint:"En dessous, le poste considère qu’il n’y a rien sur le plateau."}],switchPath:"",switchLabel:"",note:"Toucher une tuile sur un plateau vide ARME la sélection au lieu d’être refusé : la règle reste évaluée pour la saisie manuelle et les chemins dérivés."},{rank:5,code:"TARE_REQUIRED",label:"Remise à zéro nécessaire",when:"Le brut est sous la bande du plateau vide, et hors de la fenêtre du panier.",severity:"Bloquant",blocking:!0,message:"La balance doit être remise à zéro.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la valeur négative de celui du garde-fou 4, « ${St("limits.empty_max_g")} ».`},{rank:6,code:"WEIGHT_UNSTABLE",label:"Pesée instable",when:"La trame déclare la mesure instable.",severity:"Information par défaut (A3)",blocking:!1,message:"Pesée en cours…",thresholds:[],switchPath:"",switchLabel:"",note:"La sévérité suit l’exigence de stabilité : elle passe à Bloquant quand celle-ci est réglée sur « blocking ». L’impression n’est jamais bloquée par défaut."},{rank:7,code:"TARE_INVALID",label:"Emballage incohérent",when:"Une tare a été saisie, et elle atteint la pesée ou dépasse le maximum.",severity:"Bloquant",blocking:!0,message:"Le poids de l'emballage est supérieur ou égal à la pesée.",thresholds:[{path:"limits.max_tare_g",label:"Tare maximum",hint:"Une tare plus lourde que le maximum est une faute de frappe."}],switchPath:"",switchLabel:"",note:""},{rank:8,code:"WEIGHT_TOO_LOW",label:"Poids trop faible",when:"Vente au poids : le NET est strictement positif et ne dépasse pas le plancher.",severity:"Bloquant",blocking:!0,message:"La balance doit être retarée, ou l'emballage est trop lourd.",thresholds:[{path:"limits.min_weight_g",label:"Poids minimum",hint:"Une dérogation par produit existe, dans l’onglet Catalogue."}],switchPath:"",switchLabel:"",note:""},{rank:9,code:"WEIGHT_TOO_HIGH",label:"Poids trop élevé",when:"Le NET dépasse la capacité — strictement, pour que la capacité reste atteignable.",severity:"Bloquant",blocking:!0,message:"{{.Weight}} kg, ça paraît un peu lourd !",thresholds:[{path:"limits.max_weight_g",label:"Poids maximum",hint:"C’est la capacité du champ NNDDD du code-barres, pas un seuil de vraisemblance."}],switchPath:"",switchLabel:"",note:""},{rank:10,code:"UNITS_OUT_OF_RANGE",label:"Nombre d’unités hors plage",when:"Vente à l’unité : la quantité sort de la plage.",severity:"Bloquant",blocking:!0,message:"{{.Quantity}} unités, ça paraît un peu beaucoup !",thresholds:[{path:"limits.min_units",label:"Unités minimum",hint:""},{path:"limits.max_units",label:"Unités maximum",hint:""}],switchPath:"",switchLabel:"",note:""},{rank:11,code:"AMOUNT_OUT_OF_CAPACITY",label:"Montant hors capacité du code-barres",when:"La charge utile encode un PRIX, et il dépasse ce que le champ peut porter.",severity:"Bloquant",blocking:!0,message:"Prix trop élevé pour le code-barres.",thresholds:[{path:"limits.max_amount_cents",label:"Montant maximum",hint:"En centimes. Aucun préfixe du plan livré n’encode un prix : la règle est éprouvée sans qu’aucun produit puisse l’atteindre."}],switchPath:"",switchLabel:"",note:""},{rank:12,code:"ZERO_PRICE",label:"Prix nul",when:"Le montant du tarif imprimé en grand vaut zéro.",severity:"Bloquant",blocking:!0,message:"Prix nul. Appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : un produit à 0 € est une anomalie sans nuance."},{rank:13,code:"LIGHT_PRODUCT_ALLOWED",label:"Produit léger autorisé",when:"Le garde-fou 8 n’a pas déclenché grâce à la dérogation du produit.",severity:"Information",blocking:!1,message:"",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil général : c’est la dérogation par produit, listée plus bas et posée depuis l’onglet Catalogue. Rien ne s’affiche au client ; l’id du produit est journalisé."},{rank:14,code:"PRODUCT_WITHDRAWN",label:"Produit retiré",when:"Quelqu’un a décidé de ne plus proposer ce produit.",severity:"Bloquant",blocking:!0,message:"Ce produit n'est pas disponible.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : c’est une décision humaine, prise depuis l’onglet Catalogue. Aucune règle d’import ne peut la déduire."}],Mo=["{{.Weight}}","{{.Quantity}}"];function Wo(a){return Ar.find(t=>t.code===a)?.label??"Garde-fou inconnu de cet écran"}function Bo(a){return a==="blocking"?"Bloquant":a==="info"?"Information":"Sévérité inconnue de cet écran"}var Vo=c('

                                                          Rien ne s’affiche au client : c’est une information.

                                                          '),Go=c('

                                                          '),Ho=c('
                                                          '),Jo=c('

                                                          '),Ko=c('
                                                        • '),$o=c(`

                                                            Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve + dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur.

                                                            `,1);function Yo(a,t){Ue(t,!0);function r(A,_){const P=Rr(_);P!==null&&t.draft.set(A,P)}var i=$o(),d=te(i);Re(d,21,()=>Ar,A=>A.code,(A,_)=>{var P=Ko(),w=s(P),N=s(w),L=s(N),V=n(N,2),B=s(V),R=n(V,2),U=s(R),F=n(R,2),E=s(F),y=n(w,2),m=s(y),D=n(y,2);{var J=M=>{var C=Vo();l(M,C)},K=M=>{var C=Go(),X=s(C);f(()=>o(X,`« ${e(_).message??""} »`)),l(M,C)};h(D,M=>{e(_).message===""?M(J):M(K,-1)})}var W=n(D,2);{var T=M=>{{let C=u(()=>t.draft.flag(e(_).switchPath));wa(M,{get path(){return e(_).switchPath},get label(){return e(_).switchLabel},get on(){return e(C)},onchange:X=>t.draft.set(e(_).switchPath,X)})}};h(W,M=>{e(_).switchPath!==""&&M(T)})}var S=n(W,2);Re(S,17,()=>e(_).thresholds,M=>M.path,(M,C)=>{var X=Ho(),Z=s(X);{let le=u(()=>t.draft.text(e(C).path));Qe(Z,{get label(){return e(C).label},get path(){return e(C).path},kind:"number",get value(){return e(le)},get hint(){return e(C).hint},onchange:j=>r(e(C).path,j)})}Ce("focusout",X,le=>jr(le.target,t.draft.text(e(C).path))),l(M,X)});var k=n(S,2);{var O=M=>{var C=Jo(),X=s(C);f(()=>o(X,e(_).note)),l(M,C)};h(k,M=>{e(_).note!==""&&M(O)})}f(M=>{_e(P,"data-code",e(_).code),o(L,e(_).rank),o(B,e(_).label),o(U,e(_).code),_e(F,"data-blocking",M),o(E,e(_).severity),o(m,e(_).when)},[()=>String(e(_).blocking)]),l(A,P)});var p=n(d,4),x=s(p);f(A=>o(x,`Les marqueurs ${A??""} sont remplacés par les valeurs de la pesée au + moment où le message s’affiche.`),[()=>Mo.join(" et ")]),l(a,i),Me()}it(["focusout"]);function Xo(a){const t=a.value("pricing.tiers");return Array.isArray(t)?t.map(r=>{const i=r??{},d=i.discount_percent;return{code:String(i.code??""),label:String(i.label??""),abbrev:String(i.abbrev??""),written:d===void 0?null:String(d),discount:Qo(d)?d:null,rank:Number(i.rank??0)}}):[]}function Qo(a){return typeof a!="number"||!Number.isFinite(a)||a<0||a>100?!1:Math.abs(a*10-Math.round(a*10))<1e-9}function cr(a){return a===null?"":String(a).replace(".",",")}function Zo(a){const t=1e3-Math.round(a*10);return`${String(Math.trunc(t/100))},${String(t%100).padStart(2,"0")}`}var eu=c('

                                                            Aucun tarif déclaré dans la configuration lue.

                                                            '),tu=c('Prix du catalogue Odoo — pas de remise'),dr=c(' '),au=c(' % ',1),ru=c(' '),nu=c(`

                                                            CodeLibelléAbrégéRemiseOrdre

                                                            Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la + retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour + tous les adhérents.

                                                            `,1);function su(a,t){Ue(t,!0);const r=u(()=>Xo(t.draft)),i=u(()=>String(t.draft.value("pricing.reference_code")??""));function d(w){return w<=1?`${String(w)} tarif déclaré`:`${String(w)} tarifs déclarés`}function p(w,N){const L=zo(N);L!==null&&t.draft.set(w,L)}var x=zt(),A=te(x);{var _=w=>{var N=eu();l(w,N)},P=w=>{var N=nu(),L=te(N),V=s(L),B=n(L,2),R=s(B),U=n(s(R));Re(U,21,()=>e(r),pa,(F,E,y)=>{var m=ru(),D=s(m),J=s(D),K=n(D),W=s(K);_e(W,"aria-label",`Libellé du tarif ${y+1}`);var T=n(K),S=s(T);_e(S,"aria-label",`Abrégé du tarif ${y+1}`);var k=n(T),O=s(k);{var M=b=>{var z=tu();l(b,z)},C=b=>{var z=dr(),$=s(z);f(()=>o($,`${e(E).written??""} — le tarif de référence est le prix du catalogue : il + ne peut pas porter de remise, et l’enregistrement la refusera.`)),l(b,z)},X=b=>{var z=dr(),$=s(z);f(()=>o($,`${e(E).written??""} — une remise s’écrit au dixième de point ; celle-ci se + change dans le fichier de configuration.`)),l(b,z)},Z=b=>{var z=au(),$=te(z);_e($,"aria-label",`Remise du tarif ${y+1}`);var se=n($,2),oe=s(se);f((ce,ie)=>{Gt($,ce),o(oe,`un produit à 10,00 €/kg s’affiche ${ie??""} €/kg`)},[()=>cr(e(E).discount??0),()=>Zo(e(E).discount??0)]),Ce("input",$,ce=>p(`pricing.tiers.${String(y)}.discount_percent`,ce.currentTarget.value)),Ce("focusout",$,ce=>jr(ce.currentTarget,cr(e(E).discount??0))),l(b,z)};h(O,b=>{e(E).code===e(i)&&e(E).written===null?b(M):e(E).code===e(i)?b(C,1):e(E).written!==null&&e(E).discount===null?b(X,2):b(Z,-1)})}var le=n(k),j=s(le);f(()=>{o(J,e(E).code),Gt(W,e(E).label),Gt(S,e(E).abbrev),o(j,e(E).rank)}),Ce("input",W,b=>t.draft.set(`pricing.tiers.${String(y)}.label`,b.currentTarget.value)),Ce("input",S,b=>t.draft.set(`pricing.tiers.${String(y)}.abbrev`,b.currentTarget.value)),l(F,m)}),f(F=>o(V,`${F??""}.`),[()=>d(e(r).length)]),l(w,N)};h(A,w=>{e(r).length===0?w(_):w(P,-1)})}l(a,x),Me()}it(["input","focusout"]);var lu=c('

                                                            Aucune dérogation : la limite générale s’applique à tous les produits.

                                                            '),iu=c(`

                                                            Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas + répondu. Les identifiants Odoo restent affichés.

                                                            `),ou=c(`Produit retiré : le garde-fou 14 refuse le produit avant que cette + dérogation ait un sens.`),uu=c('
                                                          1. '),cu=c('

                                                              ',1);function du(a,t){Ue(t,!0);function r(L){return Wa(t.names[L],t.namesState,"Nom non lu")}const i=20,d=u(()=>t.waivers.filter(L=>!L.offered)),p=u(()=>t.waivers.slice(0,i)),x=u(()=>`${ne(e(p).length)} ${e(p).length>1?"lignes affichées":"ligne affichée"} sur ${ne(t.waivers.length)} `+(e(d).length===0?`${t.waivers.length>1?"dérogations en vigueur":"dérogation en vigueur"}.`:`${t.waivers.length>1?"dérogations enregistrées":"dérogation enregistrée"}, dont ${ne(e(d).length)} sur un produit retiré, sans effet : le garde-fou 14 refuse le produit avant que le 8 ait un sens.`)+(t.waivers.length>e(p).length?" Les autres se lisent produit par produit depuis l’onglet Catalogue.":""));function A(L){return`plancher ${ne(L)} g : refusé à ${ne(L)} g et en dessous`}var _=zt(),P=te(_);{var w=L=>{var V=lu();l(L,V)},N=L=>{var V=cu(),B=te(V),R=s(B),U=n(B,2);{var F=y=>{var m=iu();l(y,m)};h(U,y=>{t.namesState==="unread"&&y(F)})}var E=n(U,2);Re(E,21,()=>e(p),y=>y.product_id,(y,m)=>{var D=uu(),J=s(D),K=s(J),W=n(J,2),T=s(W),S=n(W,2),k=s(S),O=n(S,2),M=s(O),C=n(O,2),X=s(C),Z=n(C,2);{var le=j=>{var b=ou();l(j,b)};h(Z,j=>{e(m).offered||j(le)})}f((j,b,z,$)=>{_e(D,"data-withdrawn",j),o(K,b),o(T,e(m).product_id),o(k,z),o(M,e(m).reason),o(X,`${$??""}, ${e(m).decided_by??""}`)},[()=>String(!e(m).offered),()=>r(e(m).product_id),()=>A(e(m).min_weight_g??0),()=>fa(e(m).decided_at)]),l(y,D)}),f(()=>o(R,e(x))),l(L,V)};h(P,L=>{t.waivers.length===0?L(w):L(N,-1)})}l(a,_),Me()}var vu=c(" ",1),pu=c(`

                                                              Les deux arrondis sont distincts parce qu’ils tombent à des endroits différents du + calcul : arrondir le prix au kilo puis le multiplier, ou multiplier puis arrondir, ne + donne pas le même centime sur une étiquette. Le poste applique l’arrondi commercial, + et l’écart se voit au centime près sur la même pesée.

                                                              `,1),fu=c(`

                                                              Aucun verdict en ce moment : ces lignes portent sur la pesée EN COURS, et le poste + est au repos. Elles apparaissent le temps d’un cycle, quand un client pose son sac + et touche une tuile.

                                                              `),gu=c(' '),mu=c(' ',1),hu=c('
                                                            • '),_u=c('

                                                                ',1),bu=c(`

                                                                L’ordre compte : le premier verdict bloquant décide de ce que le client lit. Les + garde-fous 1 à 7 portent sur l’état de la balance, c’est-à-dire le poids brut ; les + garde-fous 8 à 14 portent sur la vente, c’est-à-dire le net. Le code en gris est + celui du journal, celui qu’on lit au téléphone.

                                                                Ce que les garde-fous disent de la pesée en cours

                                                                `,1),wu=c(`

                                                                Le plan de numérotation n’est PAS ici, et c’est tout l’intérêt : préfixes, largeur de + la référence, largeur de la charge utile, décimales et mode de vente sont une + constante du binaire, indexée par préfixe et vérifiée au démarrage. + Un champ qui change le SENS du code lu par la caisse n’est pas un réglage, c’est un + contrat externe : il change avec une version du binaire, relue et testée, jamais + depuis l’écran d’un poste.

                                                                `,1),yu=c('
                                                                ');function xu(a,t){Ue(t,!0);const r=u(()=>t.health.state.diagnostics),i=u(()=>t.health.decisions.filter(R=>R.min_weight_g!==null));let d=H(dt({})),p=H("loading");x();async function x(){try{const R=await _r();v(d,Object.fromEntries(R.products.map(U=>[U.id,U.name])),!0),v(p,"read")}catch{v(p,"unread")}}function A(R){return Wa(e(d)[R],e(p),"Nom non lu")}const _=u(()=>`${ne(e(r).length)} ${e(r).length>1?"verdicts":"verdict"} sur les quatorze garde-fous.`);var P=yu(),w=s(P);je(w,{title:"Grille de tarifs",note:"Un second tarif n’est pas une case à cocher : c’est une ligne de plus dans cette grille.",children:(R,U)=>{var F=vu(),E=te(F);su(E,{get draft(){return t.draft}});var y=n(E,2);{let D=u(()=>t.draft.text("pricing.primary_code"));Qe(y,{label:"Tarif imprimé en grand",path:"pricing.primary_code",get value(){return e(D)},hint:"Le prix que le client lit sur l’étiquette (A7).",onchange:J=>t.draft.set("pricing.primary_code",J)})}var m=n(y,2);{let D=u(()=>t.draft.text("pricing.reference_code"));Qe(m,{label:"Tarif qui serait encodé si le code-barres portait un prix",path:"pricing.reference_code",get value(){return e(D)},hint:"Le plan livré ne porte pas de prix, mais un poids ou un nombre d’unités : la caisse retrouve le prix par la référence, dans Odoo, et ne doit jamais sous-facturer.",onchange:J=>t.draft.set("pricing.reference_code",J)})}l(R,F)},$$slots:{default:!0}});var N=n(w,2);je(N,{title:"Les deux arrondis",children:(R,U)=>{var F=pu(),E=te(F);{let m=u(()=>t.draft.text("pricing.unit_price_rounding"));Qe(E,{label:"Arrondi du prix unitaire dérivé",path:"pricing.unit_price_rounding",get value(){return e(m)},hint:"Il s’applique au prix au kilo calculé par la remise.",onchange:D=>t.draft.set("pricing.unit_price_rounding",D)})}var y=n(E,2);{let m=u(()=>t.draft.text("pricing.amount_rounding"));Qe(y,{label:"Arrondi du montant",path:"pricing.amount_rounding",get value(){return e(m)},hint:"Il s’applique au montant de l’étiquette.",onchange:D=>t.draft.set("pricing.amount_rounding",D)})}l(R,F)},$$slots:{default:!0}});var L=n(N,2);je(L,{title:"Les quatorze garde-fous, dans l’ordre d’évaluation",note:"Le seuil se modifie ici. La sévérité ne se règle pas : elle dit si le poste refuse ou avertit, et cela ne dépend pas du magasin. Le message affiché au client n’est pas encore modifiable depuis cet écran.",children:(R,U)=>{var F=bu(),E=n(te(F),2);Yo(E,{get draft(){return t.draft}});var y=n(E,4);{var m=J=>{var K=fu();l(J,K)},D=J=>{var K=_u(),W=te(K),T=s(W),S=n(W,2);Re(S,21,()=>e(r),k=>k.code,(k,O)=>{var M=hu(),C=s(M),X=s(C),Z=n(C,2),le=s(Z),j=n(Z,2),b=s(j),z=n(j,2);{var $=ce=>{var ie=gu(),I=s(ie);f(()=>o(I,`« ${e(O).message??""} »`)),l(ce,ie)};h(z,ce=>{e(O).message!==""&&ce($)})}var se=n(z,2);{var oe=ce=>{var ie=mu(),I=te(ie),Q=s(I),ae=n(I,2),ee=s(ae);f(de=>{o(Q,de),o(ee,e(O).product_id)},[()=>A(e(O).product_id)]),l(ce,ie)};h(se,ce=>{e(O).product_id!==""&&ce(oe)})}f((ce,ie,I)=>{_e(M,"data-blocking",ce),o(X,ie),o(le,e(O).code),o(b,I)},[()=>String(e(O).blocking),()=>Wo(e(O).code),()=>Bo(e(O).severity)]),l(k,M)}),f(k=>{_e(W,"data-verdicts",k),o(T,e(_))},[()=>String(e(r).length)]),l(J,K)};h(y,J=>{e(r).length===0?J(m):J(D,-1)})}l(R,F)},$$slots:{default:!0}});var V=n(L,2);je(V,{title:"Code-barres",note:"Un seul réglage, et c’est voulu : le reste du plan de numérotation n’est pas de la configuration.",children:(R,U)=>{var F=wu(),E=te(F);{let y=u(()=>t.draft.flag("barcode.verify_reference_check_digit"));wa(E,{path:"barcode.verify_reference_check_digit",label:"Refuser une référence dont la clé de contrôle est fausse",hint:"Décoché, le poste recalcule une clé juste sur une référence fausse, en silence — et la caisse encaisse un autre article.",get on(){return e(y)},onchange:m=>t.draft.set("barcode.verify_reference_check_digit",m)})}l(R,F)},$$slots:{default:!0}});var B=n(V,2);je(B,{title:"Dérogations de poids minimum",note:"En lecture ici ; elles se modifient depuis l’onglet Catalogue, là où se trouve le produit.",children:(R,U)=>{du(R,{get waivers(){return e(i)},get names(){return e(d)},get namesState(){return e(p)}})},$$slots:{default:!0}}),l(a,P),Me()}var ku=c(" "),Su=c(' '),qu=c('
                                                                ChampEn serviceDans le fichier
                                                                ');function Lu(a,t){Ue(t,!0);var r=qu(),i=s(r),d=n(s(i));Re(d,21,()=>t.rows,p=>p.path,(p,x)=>{const A=u(()=>St(e(x).path));var _=Su(),P=s(_),w=s(P),N=n(w);{var L=F=>{var E=ku(),y=s(E);f(()=>o(y,e(x).path)),l(F,E)};h(N,F=>{qt.showTechnicalNames&&e(A)!==e(x).path&&F(L)})}var V=n(P),B=s(V),R=n(V),U=s(R);f(()=>{_e(_,"data-path",e(x).path),o(w,`${e(A)??""} `),o(B,e(x).before),o(U,e(x).after)}),l(p,_)}),l(a,r),Me()}var Eu=c(" "),Cu=c(' '),Pu=c("
                                                              • "),Tu=c(`

                                                                  Recopier reste possible : les valeurs entrent dans le brouillon, où elles se + corrigent champ par champ avant l’enregistrement.

                                                                  `);function zu(a,t){Ue(t,!0);const r=20,i=u(()=>t.faults.slice(0,r)),d=u(()=>va(e(i).length,t.faults.length,"contrôle refuse une clé","contrôles refusent une clé"));var p=Tu(),x=s(p),A=s(x),_=n(x,2);Re(_,21,()=>e(i),pa,(P,w)=>{var N=Pu(),L=s(N),V=s(L),B=n(L,2);{var R=y=>{var m=Eu(),D=s(m);f(()=>o(D,e(w).field)),l(y,m)};h(B,y=>{qt.showTechnicalNames&&y(R)})}var U=n(B),F=n(U);{var E=y=>{var m=Cu(),D=s(m);f(J=>o(D,`Valeurs acceptées : ${J??""}.`),[()=>e(w).allowed.join(", ")]),l(y,m)};h(F,y=>{e(w).allowed!==void 0&&e(w).allowed.length>0&&y(E)})}f(y=>{o(V,y),o(U,` ${e(w).message??""} `)},[()=>St(e(w).field)]),l(P,N)}),f(()=>o(A,`Ce fichier serait refusé en l’état : ${e(d)??""}`)),l(a,p),Me()}const Ru=2e3,Or=300*1e3,vr=3e4;function ju(a){const t=Math.round(a/1e3),r=`Le poste redémarre depuis ${t} seconde${t>1?"s":""}`;if(asetTimeout(t,Ru));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var Nu=c('

                                                                  '),Iu=c('

                                                                  ',1),Du=c(`

                                                                  Touchez de nouveau pour confirmer. Rien ne défait un redémarrage une fois + l’ordinateur parti.

                                                                  `),Fu=c(" ",1),Uu=c('

                                                                  '),Mu=c('
                                                                • '),Wu=c('
                                                                    '),Bu=c(`

                                                                    Trois gestes de reprise, du plus doux au plus brutal. Le premier ne coupe rien.

                                                                    Relire le fichier de configuration
                                                                    Met en service config.json tel qu’il est sur le disque, sans arrêter le + poste. À utiliser après une modification faite à la main dans le fichier.
                                                                    Redémarrer le poste
                                                                    Arrête l’application et la relance. La pesée est interrompue quelques secondes ; + l’écran client revient tout seul.
                                                                    Redémarrer l’ordinateur
                                                                    Redémarre la machine entière. Comptez une minute avant que l’écran revienne.
                                                                    `,1);function Vu(a,t){Ue(t,!0);let r=H(""),i=H(""),d=H(dt([])),p=H(0),x=H(0);const A=u(()=>e(p)>0);let _=H(0),P=H(0),w=H(!1);const N=u(()=>e(_)>0);_t(()=>{if(!e(N))return;const U=setInterval(()=>{v(P,Math.max(0,Math.round((e(_)-Date.now())/1e3)),!0)},1e3);return()=>clearInterval(U)}),_t(()=>{if(!e(A))return;const U=setInterval(()=>{v(x,Date.now()-e(p))},1e3);return()=>clearInterval(U)});async function L(){v(r,"reload-config"),v(i,""),v(d,[],!0);try{const U=await t.admin.protect(async()=>{try{return await yn()}catch(F){if(ma(F))throw F;return t.admin.report(F),v(d,t.admin.lastFaults,!0),null}});if(U===null)return;v(i,`Le fichier est en service. Empreinte ${U.config_fingerprint}.`)}finally{v(r,"")}}async function V(){v(r,"restart"),v(i,""),v(d,[],!0);try{if(await t.admin.protect(()=>xn())===null)return;v(x,0),v(p,Date.now(),!0),v(i,await Ou()?"Le poste est revenu.":Au,!0)}finally{v(r,""),v(p,0)}}async function B(){if(!e(w)){v(w,!0);return}v(r,"reboot"),v(i,""),v(d,[],!0);try{const U=await t.admin.protect(()=>kn());if(U===null)return;v(_,Date.parse(U.at),!0),v(P,U.seconds_left,!0)}finally{v(r,""),v(w,!1)}}async function R(){v(r,"cancel-reboot");try{const U=await t.admin.protect(()=>Sn());if(U===null)return;v(_,0),v(i,U.message,!0)}finally{v(r,"")}}je(a,{title:"Maintenance",children:(U,F)=>{var E=Bu(),y=n(te(E),2),m=n(s(y),2),D=n(s(m),3);{let j=u(()=>e(r)==="reload-config");ze(D,{kind:"write",act:"reload-config",label:"Relire le fichier",protected:!0,get busy(){return e(j)},onrun:L})}var J=n(m,4),K=n(s(J));{let j=u(()=>e(r)==="restart"||e(A));ze(K,{kind:"write",act:"restart",label:"Redémarrer le poste",protected:!0,get busy(){return e(j)},onrun:V})}var W=n(K,2);{var T=j=>{var b=Nu(),z=s(b);f($=>o(z,$),[()=>ju(e(x))]),l(j,b)};h(W,j=>{e(A)&&j(T)})}var S=n(J,4),k=n(s(S));{var O=j=>{var b=Iu(),z=te(b),$=s(z),se=n(z,2);{let oe=u(()=>e(r)==="cancel-reboot");ze(se,{kind:"write",act:"cancel-reboot",label:"Annuler",get busy(){return e(oe)},onrun:R})}f(()=>o($,`L’ordinateur redémarre dans ${e(P)??""} seconde${e(P)>1?"s":""}.`)),l(j,b)},M=j=>{var b=Fu(),z=te(b);{var $=oe=>{var ce=Du();l(oe,ce)};h(z,oe=>{e(w)&&oe($)})}var se=n(z,2);{let oe=u(()=>e(w)?"Confirmer le redémarrage":"Redémarrer l’ordinateur"),ce=u(()=>e(r)==="reboot");ze(se,{kind:"destructive",act:"reboot",get label(){return e(oe)},protected:!0,get busy(){return e(ce)},onrun:B})}l(j,b)};h(k,j=>{e(N)?j(O):j(M,-1)})}var C=n(y,2);{var X=j=>{var b=Uu(),z=s(b);f(()=>o(z,e(i))),l(j,b)};h(C,j=>{e(i)!==""&&j(X)})}var Z=n(C,2);{var le=j=>{var b=Wu();Re(b,21,()=>e(d),z=>z.field,(z,$)=>{var se=Mu(),oe=s(se),ce=s(oe),ie=n(oe);f(()=>{o(ce,e($).field),o(ie,` — ${e($).message??""}`)}),l(z,se)}),l(j,b)};h(Z,j=>{e(d).length>0&&j(le)})}l(U,E)},$$slots:{default:!0}}),Me()}function Gu(a,t){const r=[];for(const i of[...ja(a),...ja(t)]){if(r.some(x=>x.path===i))continue;const d=pr(ha(a,i)),p=pr(ha(t,i));d!==p&&r.push({path:i,before:d,after:p})}return r}function ha(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}function ja(a,t=""){if(a===null||typeof a!="object"||Array.isArray(a))return t===""?[]:[t];const r=[];for(const[i,d]of Object.entries(a))r.push(...ja(d,t===""?i:`${t}.${i}`));return r}function pr(a){return a===void 0?"—":a===null?"vide":typeof a=="boolean"?a?"oui":"non":typeof a=="object"?JSON.stringify(a):String(a)}const Hu=new Set(["modified_at","catalog.options.password"]),Ju=[{path:"station.name",name:"le nom du poste"},{path:"scale.options.port",name:"le port de la balance"},{path:"printer.options.queue",name:"la file d’impression"},{path:"printer.options.path",name:"le nœud d’impression"},{path:"printer.options.address",name:"l’adresse de l’imprimante"},{path:"catalog.options.url",name:"l’adresse du partage"},{path:"catalog.options.username",name:"le compte du partage"},{path:"catalog.images.path",name:"le chemin des images"}];function Ku(a,t){return a===null||t===null?[]:Gu(a,t).filter(r=>!Hu.has(r.path))}function $u(a,t){if(a===null||t===null)return[];const r=a,i=t;return Ju.filter(d=>fr(i,d.path)&&!fr(r,d.path))}function fr(a,t){const r=ha(a,t);return r==null||r===""?!0:typeof r!="object"||Array.isArray(r)?!1:Object.keys(r).length===0}function Yu(a){return a.length<2?a.join(""):`${a.slice(0,-1).join(", ")} et ${a[a.length-1]??""}`}async function Xu(a,t){const i=await fetch(`/admin/api/config/export?hardware=${t?"1":"0"}`,{headers:{accept:"application/json"}});if(!i.ok)throw new Tt(i.status,Nr(await i.text(),"L’export"));return{name:Zu(a,t),blob:await i.blob()}}async function Qu(a){const t=await fetch("/admin/api/config/import",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(a)}),r=await t.text();if(!t.ok)throw new Tt(t.status,Nr(r,"L’import"));return JSON.parse(r)}function Zu(a,t){const r=t?"":"-sans-materiel";return`config-poste${String(a)}${r}-${ec()}.json`}function ec(){const a=new Date,t=String(a.getMonth()+1).padStart(2,"0"),r=String(a.getDate()).padStart(2,"0");return`${String(a.getFullYear())}-${t}-${r}`}function tc(a,t){const r=URL.createObjectURL(t),i=document.createElement("a");i.href=r,i.download=a,document.body.appendChild(i),i.click(),i.remove(),setTimeout(()=>{URL.revokeObjectURL(r)},0)}function Nr(a,t){try{const r=JSON.parse(a);if(typeof r?.message=="string"&&r.message!=="")return r.message}catch{}return`${t} a été refusé par le poste.`}var ac=c('
                                                                    Empreinte de la configuration en service
                                                                    Version du binaire
                                                                    Répertoire de données
                                                                    Espace disque
                                                                    ',1),rc=c('

                                                                    '),nc=c(`

                                                                    Ce fichier n’a été comparé à rien : la configuration en service n’a pas pu être + lue. Ni « identique », ni « n champs changent » — ce que ce fichier changerait + sur ce poste reste inconnu.

                                                                    `),sc=c(`

                                                                    Ce fichier décrit la même configuration que celle en service : il n’y a rien à + recopier. C’est ce qu’on veut lire à la fin d’un clonage. Deux champs ne sont pas + comparés : la date du dernier enregistrement, que chaque poste écrit lui-même, et + le mot de passe du catalogue, qu’aucun des deux ne porte en clair.

                                                                    `),lc=c('

                                                                    '),ic=c(`

                                                                    Recopier n’applique rien : les valeurs entrent dans le brouillon, et c’est + « Enregistrer » qui les met en service.

                                                                    `,1),oc=c('

                                                                    ',1),uc=c(`

                                                                    L’export emporte encore l’empreinte du mot de passe : c’est la seule lecture que le + poste garde derrière la clé. L’import, lui, est lu PAR LE POSTE, qui écarte les deux + secrets, le numéro de poste et le réseau avant de dire ce qui changerait : un fichier + apporté ne déplace jamais l’adresse d’écoute de ce poste-ci.

                                                                    `,1),cc=c('

                                                                    Lecture des versions enregistrées…

                                                                    '),dc=c(`

                                                                    Les versions enregistrées n’ont pas pu être lues : cette liste ne dit donc rien de + ce que ce poste garde. Ce n’est pas « aucune version ».

                                                                    `),vc=c('

                                                                    Aucune version enregistrée : ce poste n’a jamais été reconfiguré.

                                                                    '),pc=c('
                                                                  • '),fc=c(`

                                                                      Remettre une version en service remplace la configuration du poste sur-le-champ, et + ce qui n’a pas été enregistré est perdu : c’est le seul geste de cette page qui + change le poste tout de suite, et le seul qui garde ses 72 px.

                                                                      `,1),gc=c('
                                                                      ');function mc(a,t){Ue(t,!0);const r=Be(t,"admin",7),i=40,d=5;let p=H(dt([])),x=H("reading"),A=H(null),_=H(""),P=H(null),w=H(""),N=H(dt([])),L=H(""),V="";const B=u(()=>r().busy||e(L)!==""),R=u(()=>Ku(e(A),e(P))),U=u(()=>e(A)!==null&&e(P)!==null),F=u(()=>$u(e(A),e(P))),E=u(()=>e(R).slice(0,i)),y=u(()=>e(p).slice(0,d)),m=u(()=>va(e(E).length,e(R).length,"champ qui change","champs qui changent")),D=u(()=>va(e(y).length,e(p).length,"version enregistrée","versions enregistrées")),J=u(()=>e(R).length>1?`Recopier ces ${ne(e(R).length)} champs dans le brouillon`:"Recopier ce champ dans le brouillon");K(),_t(()=>{const b=t.health.config_fingerprint;b!==V&&(V=b,W())});async function K(){const b=await r().load(Ln);if(b===null){v(x,"unreadable");return}v(p,b,!0),v(x,"read")}async function W(){try{v(A,(await Da()).config,!0),v(_,"")}catch(b){v(A,null),v(_,b instanceof Error?b.message:"Le poste n’a pas répondu.",!0)}}async function T(b){v(L,b?"export-all":"export-clone",!0),r().notice="",r().actionError="";try{const z=await r().protect(()=>Xu(t.health.station,b));if(z===null)return;tc(z.name,z.blob),r().notice=`${z.name} est remis au navigateur : c’est lui qui l’enregistre, voyez ses téléchargements.`}finally{v(L,"")}}async function S(b){if(b==null)return;r().notice="",r().actionError="";let z;try{z=JSON.parse(await b.text())}catch{r().actionError=`${b.name} n’est pas un fichier JSON lisible.`;return}v(L,"import");try{const $=await r().protect(()=>Qu(z));if($===null)return;v(P,$.config,!0),v(w,b.name,!0),v(N,$.faults,!0),await W(),r().notice=k(b.name)}finally{v(L,"")}}function k(b){return e(U)?e(R).length===0?`${b} décrit la même configuration que celle en service.`:`${b} est lu. Rien n’est appliqué : relisez le tableau.`:`${b} est lu, mais la configuration en service ne l’est pas : rien ne peut être comparé.`}async function O(){if(e(P)===null)return;const b=e(P),z=[...e(R)];for(const $ of z)t.draft.set($.path,ha(b,$.path));r().actionError="",r().notice="Le fichier est recopié dans le brouillon. Rien n’est appliqué avant « Enregistrer ».",await W()}async function M(b){v(L,`restore-${String(b)}`),r().notice="",r().actionError="";try{if(await r().protect(()=>En(b))===null)return;await t.draft.load(),await K(),await W(),await r().refresh(),r().notice=`La version ${String(b)} est remise en service.`}finally{v(L,"")}}var C=gc(),X=s(C);je(X,{title:"Identité du poste",children:(b,z)=>{var $=ac(),se=te($);{let ke=u(()=>t.draft.text("station.number")),Se=u(()=>Ke(t.draft,"station.number")),we=u(()=>Ht(t.draft,"station.number"));Qe(se,{label:"Numéro du poste",path:"station.number",kind:"number",get value(){return e(ke)},get fault(){return e(Se)},get allowed(){return e(we)},hint:"C’est de lui que dérive le nom du fichier de catalogue attendu, flv_.csv.",onchange:ye=>t.draft.set("station.number",Number(ye))})}var oe=n(se,2);{let ke=u(()=>t.draft.text("station.name")),Se=u(()=>Ke(t.draft,"station.name")),we=u(()=>Ht(t.draft,"station.name"));Qe(oe,{label:"Nom du poste",path:"station.name",get value(){return e(ke)},get fault(){return e(Se)},get allowed(){return e(we)},hint:"Ce que lit un bénévole : « Poste 2 — fruits ».",onchange:ye=>t.draft.set("station.name",ye)})}var ce=n(oe,2);{let ke=u(()=>t.draft.text("station.coop")),Se=u(()=>Ke(t.draft,"station.coop")),we=u(()=>Ht(t.draft,"station.coop"));Qe(ce,{label:"Coopérative",path:"station.coop",get value(){return e(ke)},get fault(){return e(Se)},get allowed(){return e(we)},onchange:ye=>t.draft.set("station.coop",ye)})}var ie=n(ce,2);{let ke=u(()=>t.draft.text("network.listen")),Se=u(()=>Ke(t.draft,"network.listen")),we=u(()=>Ht(t.draft,"network.listen"));Qe(ie,{label:"Adresse d’écoute",path:"network.listen",get value(){return e(ke)},get fault(){return e(Se)},get allowed(){return e(we)},hint:"Un hôte et un port, par exemple 127.0.0.1:8085. Enregistrer déplace l’écoute sur-le-champ et laisse 60 secondes pour confirmer : sans confirmation, le poste revient tout seul à l’adresse précédente.",onchange:ye=>t.draft.set("network.listen",ye)})}var I=n(ie,2);{let ke=u(()=>t.draft.flag("network.admin_on_lan"));wa(I,{path:"network.admin_on_lan",label:"Administration accessible depuis le réseau",hint:"Décoché, ces pages ne répondent que depuis ce poste-ci. Une adresse ouverte au réseau sans cet interrupteur laisse l’administration injoignable d’ailleurs.",get on(){return e(ke)},onchange:Se=>t.draft.set("network.admin_on_lan",Se)})}var Q=n(I,2),ae=n(s(Q),2),ee=s(ae),de=n(ae,4),xe=s(de),Ne=n(de,4),q=s(Ne),ue=n(Ne,4),pe=s(ue);{var me=ke=>{var Se=nt("non mesuré");l(ke,Se)},fe=ke=>{var Se=nt();f((we,ye,De)=>o(Se,`${we??""} libres sur + ${ye??""} — seuil d’alerte + ${De??""} Mo`),[()=>Ra(t.health.disk.free_bytes),()=>Ra(t.health.disk.total_bytes),()=>ne(t.health.disk.alert_mb)]),l(ke,Se)};h(pe,ke=>{t.health.disk===null?ke(me):ke(fe,-1)})}f(()=>{o(ee,t.health.config_fingerprint),o(xe,t.health.version),o(q,t.health.disk===null?"non publié par ce poste":t.health.disk.path)}),l(b,$)},$$slots:{default:!0}});var Z=n(X,2);je(Z,{title:"Exporter, importer",note:"Pour installer un autre poste : ce fichier emporte les tarifs, les garde-fous, l’étiquette, les catégories, et les réglages du matériel que les quatre postes partagent — le décalage d’étiquette, le noircissement, la vitesse, le débit de la balance. Reste ici ce qui désigne ce poste-ci ou ce magasin — le mot de passe, le code de secours, le numéro et le nom du poste, le port de la balance, la file d’impression, l’adresse du partage et son compte, le chemin des images et le réseau.",children:(b,z)=>{var $=uc(),se=te($),oe=s(se);{let q=u(()=>e(L)==="export-all");ze(oe,{label:"Exporter tout",protected:!0,get busy(){return e(q)},get disabled(){return e(B)},onrun:()=>{T(!0)}})}var ce=n(oe,2);{let q=u(()=>e(L)==="export-clone");ze(ce,{label:"Exporter sans le matériel",protected:!0,get busy(){return e(q)},get disabled(){return e(B)},onrun:()=>{T(!1)}})}var ie=n(ce,2);let I;var Q=s(ie),ae=n(Q,3),ee=n(se,4);{var de=q=>{var ue=rc(),pe=s(ue);f(()=>o(pe,`La configuration en service n’a pas pu être lue : ${e(_)??""} La colonne + « En service » ne peut donc rien affirmer.`)),l(q,ue)};h(ee,q=>{e(_)!==""&&q(de)})}var xe=n(ee,2);{var Ne=q=>{var ue=oc(),pe=te(ue),me=s(pe),fe=n(pe,2);{var ke=qe=>{zu(qe,{get faults(){return e(N)}})};h(fe,qe=>{e(N).length>0&&qe(ke)})}var Se=n(fe,2);{var we=qe=>{var Fe=nc();l(qe,Fe)},ye=qe=>{var Fe=sc();l(qe,Fe)},De=qe=>{var Fe=ic(),at=te(Fe),rt=s(at),vt=n(rt);{var ot=Ee=>{var st=nt("Les autres sont dans le fichier, et « Recopier » les prend tous.");l(Ee,st)};h(vt,Ee=>{e(R).length>e(E).length&&Ee(ot)})}var ct=n(at,2);Lu(ct,{get rows(){return e(E)}});var pt=n(ct,2);{var gt=Ee=>{var st=lc(),Kt=s(st);f(Et=>o(Kt,`Ce fichier ne porte pas ${Et??""} : + l’export sans le matériel les retire, et l’import ne remet que le numéro du + poste et le réseau. Les lignes correspondantes sont VIDES ci-dessus, et + « Recopier » recopie ce vide dans le brouillon.`),[()=>Yu(e(F).map(Et=>Et.name))]),l(Ee,st)};h(pt,Ee=>{e(F).length>0&&Ee(gt)})}var Ze=n(pt,2),he=s(Ze);ze(he,{kind:"write",get label(){return e(J)},get disabled(){return e(B)},onrun:()=>{O()}}),f(()=>o(rt,`${e(m)??""} `)),l(qe,Fe)};h(Se,qe=>{e(U)?e(R).length===0?qe(ye,1):qe(De,-1):qe(we)})}f(()=>o(me,`Fichier lu : ${e(w)??""}`)),l(q,ue)};h(xe,q=>{e(P)!==null&&q(Ne)})}f(()=>{I=Dt(ie,1,"choose svelte-18wbxwu",null,I,{working:e(L)==="import",off:e(B)}),o(Q,`${e(L)==="import"?"Lecture du fichier…":"Importer un fichier"} `),ae.disabled=e(B)}),Ce("change",ae,q=>{S(q.currentTarget.files?.item(0))}),l(b,$)},$$slots:{default:!0}});var le=n(Z,2);je(le,{title:"Cinq versions restaurables",note:"Chaque enregistrement fait tourner les versions : la plus récente est la 1.",children:(b,z)=>{var $=zt(),se=te($);{var oe=Q=>{var ae=cc();l(Q,ae)},ce=Q=>{var ae=dc();l(Q,ae)},ie=Q=>{var ae=vc();l(Q,ae)},I=Q=>{var ae=fc(),ee=te(ae),de=s(ee),xe=n(ee,2),Ne=s(xe);Re(Ne,21,()=>e(y),q=>q.version,(q,ue)=>{var pe=pc(),me=s(pe),fe=s(me),ke=n(me,2),Se=s(ke),we=n(ke,2),ye=s(we),De=n(we,2);{let qe=u(()=>e(L)===`restore-${String(e(ue).version)}`);ze(De,{kind:"destructive",label:"Remettre cette version en service",protected:!0,get busy(){return e(qe)},get disabled(){return e(B)},onrun:()=>{M(e(ue).version)}})}f((qe,Fe)=>{o(fe,`version ${qe??""}`),o(Se,Fe),o(ye,e(ue).config_fingerprint)},[()=>ne(e(ue).version),()=>Lt(e(ue).modified_at)]),l(q,pe)}),f(()=>o(de,`${e(D)??""} Le poste n’en garde jamais plus de cinq.`)),l(Q,ae)};h(se,Q=>{e(x)==="reading"?Q(oe):e(x)==="unreadable"?Q(ce,1):e(p).length===0?Q(ie,2):Q(I,-1)})}l(b,$)},$$slots:{default:!0}});var j=n(le,2);Vu(j,{get admin(){return r()}}),l(a,C),Me()}it(["change"]);var hc=c('clé'),_c=c(' '),bc=c('');function Bt(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"hint",3,""),d=Be(t,"disabled",3,!1),p=Be(t,"engaged",3,!1),x=Be(t,"busy",3,!1),A=Be(t,"protected",3,!1);var _=bc();let P;var w=s(_),N=s(w),L=n(N);{var V=U=>{var F=hc();l(U,F)};h(L,U=>{A()&&U(V)})}var B=n(w,2);{var R=U=>{var F=_c(),E=s(F);f(()=>o(E,x()?"En cours…":i())),l(U,F)};h(B,U=>{i()!==""&&U(R)})}f(()=>{P=Dt(_,1,`big touch-target ${r()??""}`,"svelte-15nlt6i",P,{engaged:p(),busy:x()}),_e(_,"data-kind",r()),_.disabled=d()||x(),o(N,`${t.label??""} `)}),Ce("click",_,function(...U){t.onrun?.apply(this,U)}),l(a,_)}it(["click"]);const wc="ERR-SCL-09";var yc=c('

                                                                      '),xc=c('

                                                                      '),kc=c('

                                                                      '),Sc=c('

                                                                      Déposez ici le fichier clé

                                                                      '),qc=c('

                                                                      ',1),Lc=c('');function Ec(a,t){Ue(t,!0);const r=Be(t,"admin",7);let i=H("");const d=u(()=>t.health.state.degraded?.code===wc),p=u(()=>t.health.printing),x=u(()=>e(p)?.on_fallback===!0);let A=H(!1),_=H("");const P=u(()=>r().busy||e(_)!==""),w=new zr;_t(()=>{w.observe(t.health)});const N=u(()=>t.health.catalog_source===null?"Ce poste ne publie pas la source de son catalogue.":"Catalogue surveillé : "+t.health.catalog_source.label),L=u(()=>t.health.catalog!==null&&(t.health.catalog.result==="rejected"||t.health.catalog.result==="failed")?t.health.catalog:null);function V(I){v(i,""),w.forget(),v(_,I,!0)}async function B(I,Q){V(I);try{await r().run(Q)}finally{v(_,"")}}async function R(){V("reload");try{const I=await r().run(un);I!==null&&w.begin(I)}finally{v(_,"")}}async function U(I,Q){V(I);try{const ae=await r().load(Q);ae!==null&&v(i,ae.message,!0)}finally{v(_,"")}}async function F(I,Q){V(I);try{const ae=await r().protect(Q);ae!==null&&(r().notice=ae.message,await r().refresh())}finally{v(_,"")}}async function E(I){if(v(A,!1),I!=null){V("import");try{const Q=await r().protect(()=>yr(I));if(Q===null)return;v(i,Q.result==="rejected"||Q.result==="failed"?`${I.name} : REFUSÉ${Q.reason===""?"":" — "+Q.reason}. Le catalogue en service n’a pas changé.`:`${I.name} : ${String(Q.rows_read_count)} lignes lues, ${String(Q.weighable_count)} pesables. La veille l’appliquera dans la seconde.`,!0),await r().refresh()}finally{v(_,"")}}}var y=Lc(),m=s(y);{var D=I=>{var Q=yc(),ae=s(Q);f(()=>o(ae,e(i))),l(I,Q)};h(m,I=>{e(i)!==""&&I(D)})}var J=n(m,2);{var K=I=>{var Q=xc(),ae=s(Q);f(()=>o(ae,w.sentence)),l(I,Q)};h(J,I=>{w.sentence!==""&&I(K)})}var W=n(J,2);{var T=I=>{var Q=kc(),ae=s(Q);f(()=>o(ae,e(p).banner)),l(I,Q)};h(W,I=>{e(p)!==null&&e(p).banner!==""&&I(T)})}var S=n(W,2),k=s(S),O=n(S,2),M=s(O);{let I=u(()=>e(_)==="scale");Bt(M,{label:"Tester la balance",hint:"Ce que le poste a déjà observé — le port n’est pas rouvert.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{U("scale",pn)}})}var C=n(M,2);{let I=u(()=>e(_)==="printer");Bt(C,{label:"Tester l’imprimante",hint:"Ce que le superviseur a vu il y a moins d’une seconde.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{U("printer",fn)}})}var X=n(C,2);{let I=u(()=>e(_)==="label");Bt(X,{label:"Imprimer une étiquette de test",hint:"Une étiquette de démonstration sort de l’imprimante du poste.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{B("label",gn)}})}var Z=n(X,2);{let I=u(()=>e(_)==="reprint");Bt(Z,{label:"Réimprimer la dernière",hint:"La dernière étiquette imprimée sort une seconde fois.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{B("reprint",on)}})}var le=n(Z,2);{let I=u(()=>e(_)==="reload");Bt(le,{label:"Recharger le catalogue",kind:"write",hint:"La veille refait tout de suite le contrôle qu’elle fait toutes les cinq secondes.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{R()}})}var j=n(le,2);{let I=u(()=>e(d)?"Revenir à la balance":"Basculer en saisie manuelle"),Q=u(()=>e(d)?"Le poids sera de nouveau lu sur la balance.":"Le poids se tape à la main : le poste continue de servir sans balance."),ae=u(()=>e(_)==="manual");Bt(j,{get label(){return e(I)},kind:"destructive",get hint(){return e(Q)},get engaged(){return e(d)},protected:!0,get busy(){return e(ae)},get disabled(){return e(P)},onrun:()=>{F("manual",()=>cn(!e(d)))}})}var b=n(j,2);{let I=u(()=>e(_)==="roll");Bt(b,{label:"J’ai changé le rouleau",kind:"write",hint:"Le compteur d’étiquettes repart à zéro. C’est le seul geste qui dise quelque chose de vrai du papier.",get busy(){return e(I)},get disabled(){return e(P)},onrun:()=>{B("roll",dn)}})}var z=n(b,2);{var $=I=>{{let Q=u(()=>e(x)?"Revenir à l’imprimante du poste":"Imprimer sur l’imprimante du poste voisin"),ae=u(()=>e(x)?"Les étiquettes repartiront sur l’imprimante de ce poste.":"Les étiquettes sortiront sur l’imprimante voisine, pour cette session seulement."),ee=u(()=>e(_)==="fallback");Bt(I,{get label(){return e(Q)},kind:"write",get hint(){return e(ae)},get engaged(){return e(x)},get busy(){return e(ee)},get disabled(){return e(P)},onrun:()=>{B("fallback",()=>vn(!e(x)))}})}};h(z,I=>{e(p)!==null&&e(p).fallback_available&&I($)})}var se=n(z,2),oe=n(O,2);je(oe,{title:"Importer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(I,Q)=>{var ae=Sc();let ee;var de=s(ae),xe=n(s(de)),Ne=s(xe),q=n(de,2),ue=n(s(q));f(()=>{ee=Dt(ae,1,"drop svelte-1yd0nzg",null,ee,{dropping:e(A)}),o(Ne,`flv_${t.health.station??""}.csv`)}),Jt("dragover",ae,pe=>{pe.preventDefault(),v(A,!0)}),Jt("dragleave",ae,()=>v(A,!1)),Jt("drop",ae,pe=>{pe.preventDefault(),E(pe.dataTransfer?.files.item(0))}),Ce("change",ue,pe=>{E(pe.currentTarget.files?.item(0))}),l(I,ae)},$$slots:{default:!0}});var ce=n(oe,2);{var ie=I=>{je(I,{title:"Le dernier fichier n’a pas pris service",children:(Q,ae)=>{var ee=qc(),de=te(ee),xe=s(de),Ne=n(de,2),q=s(Ne);f((ue,pe)=>{o(xe,`${ue??""} — + ${(e(L).reason===""?"aucun motif enregistré.":e(L).reason)??""} + Le catalogue en service n’a pas changé.`),o(q,`Dernier essai : ${pe??""}.`)},[()=>ba(e(L).result),()=>Lt(e(L).occurred_at)]),l(Q,ee)},$$slots:{default:!0}})};h(ce,I=>{e(L)!==null&&I(ie)})}f(()=>{o(k,e(N)),_e(se,"href",mn)}),l(a,y),Me()}it(["change"]);const Cc={succeeded:"La dernière mise à jour a réussi.","rolled-back":"La dernière mise à jour a échoué. La version précédente a été remise et le poste fonctionne.","rolled-back-unhealthy":"La dernière mise à jour a échoué et le poste n’a pas redémarré. Appelez le support.","not-started":"La dernière mise à jour n’a pas démarré : rien n’a été remplacé. Vous pouvez réessayer."},Pc=2e3,Tc=300*1e3;var zc=c('

                                                                      '),gr=c('

                                                                      '),Rc=c("
                                                                      Dernière vérification
                                                                      ",1),jc=c(`

                                                                      La mise à jour depuis cet écran n’existe que sur les postes Windows. Sur les + autres, elle se fait à la main — voir la notice d’installation.

                                                                      `),Ac=c('Voir les nouveautés'),Oc=c('

                                                                      Version disponible : .

                                                                      '),Nc=c('

                                                                      Ce poste est à jour.

                                                                      '),Ic=c("
                                                                      Version installée
                                                                      Dépôt suivi
                                                                      ",1),Dc=c(`

                                                                      Le poste va s’arrêter environ une minute. L’écran client s’éteindra puis reviendra + tout seul. Si la nouvelle version ne démarre pas, la précédente sera remise + automatiquement — mais les données enregistrées, elles, ne reviendront pas en + arrière.

                                                                      `),Fc=c('

                                                                      Mise à jour en cours. Le poste redémarre, ne le débranchez pas.

                                                                      '),Uc=c('
                                                                      ',1),Mc=c("
                                                                      Terminée le
                                                                      ",1),Wc=c("
                                                                      Raison
                                                                      ",1),Bc=c('

                                                                      Depuis
                                                                      Vers
                                                                      ',1),Vc=c(" ",1);function Gc(a,t){Ue(t,!0);let r=H(null),i=H(""),d=H(""),p=H(!1),x=H("");const A=u(()=>e(r)?.outcome??null),_=u(()=>e(r)?.supported===!0&&e(r).available),P=u(()=>e(r)?.latest??"");_t(()=>{w()});async function w(){try{v(r,await Wn(),!0),v(i,"")}catch(m){v(i,m instanceof Error?m.message:"Lecture impossible.",!0)}}async function N(){v(d,"check");try{const m=await t.admin.protect(()=>Bn());m!==null&&(v(r,m,!0),v(i,""))}finally{v(d,"")}}async function L(m){v(d,"apply"),v(x,"");try{if(await t.admin.protect(()=>Vn(m))===null)return;v(p,!0),v(x,await V()?"":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0),await w()}finally{v(d,""),v(p,!1)}}async function V(){const m=Date.now()+Tc;for(;Date.now()setTimeout(D,Pc));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var B=Vc(),R=te(B);je(R,{title:"Version de ce poste",children:(m,D)=>{var J=zt(),K=te(J);{var W=S=>{var k=zc(),O=s(k);f(()=>o(O,e(i)===""?"Lecture…":e(i))),l(S,k)},T=S=>{var k=Ic(),O=te(k);{var M=ie=>{var I=gr(),Q=s(I);f(()=>o(Q,e(i))),l(ie,I)};h(O,ie=>{e(i)!==""&&ie(M)})}var C=n(O,2),X=n(s(C),2),Z=s(X),le=n(X,4),j=s(le),b=n(le,2);{var z=ie=>{var I=Rc(),Q=n(te(I),2),ae=s(Q);f(ee=>o(ae,ee),[()=>Lt(e(r).checked_at)]),l(ie,I)};h(b,ie=>{e(r).checked_at!==""&&ie(z)})}var $=n(C,2);{var se=ie=>{var I=jc();l(ie,I)},oe=ie=>{var I=Oc(),Q=n(s(I)),ae=s(Q),ee=n(Q,2);{var de=q=>{var ue=nt();f(pe=>o(ue,`, publiée le ${pe??""}`),[()=>Lt(e(r).published_at)]),l(q,ue)};h(ee,q=>{e(r).published_at!==""&&q(de)})}var xe=n(ee,2);{var Ne=q=>{var ue=Ac();f(()=>_e(ue,"href",e(r).html_url)),l(q,ue)};h(xe,q=>{e(r).html_url!==""&&q(Ne)})}f(()=>o(ae,e(r).latest)),l(ie,I)},ce=ie=>{var I=Nc();l(ie,I)};h($,ie=>{e(r).supported?e(r).available?ie(oe,1):ie(ce,-1):ie(se)})}f(()=>{o(Z,e(r).running),o(j,e(r).repository)}),l(S,k)};h(K,S=>{e(r)===null?S(W):S(T,-1)})}l(m,J)},$$slots:{default:!0}});var U=n(R,2);{var F=m=>{je(m,{title:"Installer",children:(D,J)=>{var K=Uc(),W=te(K),T=s(W);{let j=u(()=>e(d)==="check");ze(T,{kind:"read",label:"Vérifier maintenant",protected:!0,act:"check",get busy(){return e(j)},get disabled(){return e(p)},onrun:()=>{N()}})}var S=n(T,2);{var k=j=>{{let b=u(()=>`Installer la version ${e(P)}`),z=u(()=>e(d)==="apply");ze(j,{kind:"destructive",get label(){return e(b)},protected:!0,act:"apply",get busy(){return e(z)},onrun:()=>{L(e(P))}})}};h(S,j=>{e(_)&&j(k)})}var O=n(W,2);{var M=j=>{var b=Dc();l(j,b)};h(O,j=>{e(_)&&j(M)})}var C=n(O,2);{var X=j=>{var b=Fc();l(j,b)};h(C,j=>{e(p)&&j(X)})}var Z=n(C,2);{var le=j=>{var b=gr(),z=s(b);f(()=>o(z,e(x))),l(j,b)};h(Z,j=>{e(x)!==""&&j(le)})}l(D,K)},$$slots:{default:!0}})};h(U,m=>{e(r)!==null&&e(r).supported&&m(F)})}var E=n(U,2);{var y=m=>{je(m,{title:"Dernière tentative",children:(D,J)=>{var K=Bc(),W=te(K),T=s(W),S=n(W,2),k=n(s(S),2),O=s(k),M=n(k,4),C=s(M),X=n(M,2);{var Z=b=>{var z=Mc(),$=n(te(z),2),se=s($);f(oe=>o(se,oe),[()=>Lt(e(A).finished_at)]),l(b,z)};h(X,b=>{e(A).finished_at!==""&&b(Z)})}var le=n(X,2);{var j=b=>{var z=Wc(),$=n(te(z),2),se=s($);f(()=>o(se,e(A).reason)),l(b,z)};h(le,b=>{e(A).reason!==""&&b(j)})}f(()=>{_e(W,"data-outcome",e(A).status),o(T,Cc[e(A).status]),o(O,e(A).from===""?"—":e(A).from),o(C,e(A).to===""?"—":e(A).to)}),l(D,K)},$$slots:{default:!0}})};h(E,m=>{e(A)!==null&&m(y)})}l(a,B),Me()}var Hc=c(''),Jc=c('

                                                                      ',1),Kc=c('

                                                                      ',1),$c=c(''),Yc=c(''),Xc=c(''),Qc=c(''),Zc=c(' '),ed=c(''),td=c('

                                                                      Lecture de l’état du poste…

                                                                      '),ad=c('

                                                                      '),rd=c(" "),nd=c("
                                                                    • "),sd=c('
                                                                        '),ld=c('
                                                                        '),id=c('

                                                                        ');function od(a,t){Ue(t,!0);const r=new rs,i=new Gn(r),d=[{title:"Au quotidien",pages:[{id:"dashboard",label:"Tableau de bord"},{id:"troubleshooting",label:"Dépannage"}]},{title:"Réglages",pages:[{id:"hardware",label:"Matériel"},{id:"label",label:"Étiquette"},{id:"rules",label:"Règles"},{id:"catalog",label:"Catalogue"},{id:"journal",label:"Journal"},{id:"station",label:"Poste"},{id:"update",label:"Mise à jour"}]}],p=u(()=>d.flatMap(q=>q.pages).find(q=>q.id===r.page)?.label??""),x=u(()=>r.health===null||r.health.station_name===""?`Poste ${String(r.health?.station??"")}`:r.health.station_name),A=u(()=>(i.pending?.changed_blocks??[]).map(q=>$n(q)).join(", ")),_=u(()=>(i.pending?.changed_blocks??[]).join(", "));Ia(()=>(r.start(),()=>r.stop()));async function P(q){r.open(q),$a(q)&&i.config===null&&await i.load()}async function w(){await r.protect(()=>i.save())===!0&&(r.notice="La configuration est enregistrée et appliquée.")}var N=id(),L=s(N),V=n(s(L),2);Re(V,17,()=>d,q=>q.title,(q,ue)=>{var pe=Jc(),me=te(pe),fe=s(me),ke=n(me,2);Re(ke,17,()=>e(ue).pages,Se=>Se.id,(Se,we)=>{var ye=Hc();let De;var qe=s(ye);f(()=>{De=Dt(ye,1,"entry svelte-9b2mjq",null,De,{current:r.page===e(we).id}),_e(ye,"aria-current",r.page===e(we).id?"page":void 0),o(qe,e(we).label)}),Ce("click",ye,()=>{P(e(we).id)}),l(Se,ye)}),f(()=>o(fe,e(ue).title)),l(q,pe)});var B=n(V,2),R=s(B);{var U=q=>{var ue=Kc(),pe=te(ue),me=s(pe),fe=n(pe,2),ke=s(fe),Se=n(fe,2),we=s(Se);f(()=>{o(me,e(x)),o(ke,`${r.health.coop??""} · version ${r.health.version??""}`),o(we,`configuration ${r.health.config_fingerprint??""}`)}),l(q,ue)};h(R,q=>{r.health!==null&&q(U)})}var F=n(R,2),E=s(F),y=n(F,2);{var m=q=>{var ue=$c();Ce("click",ue,function(...pe){t.onclose?.apply(this,pe)}),l(q,ue)};h(y,q=>{t.onclose!==void 0&&q(m)})}var D=n(L,2),J=s(D);{var K=q=>{var ue=Yc(),pe=s(ue);f(()=>o(pe,r.notice)),l(q,ue)};h(J,q=>{r.notice!==""&&q(K)})}var W=n(J,2);{var T=q=>{var ue=Xc(),pe=s(ue);f(()=>o(pe,r.linkError)),l(q,ue)};h(W,q=>{r.linkError!==""&&q(T)})}var S=n(W,2);{var k=q=>{var ue=Qc(),pe=s(ue);f(()=>o(pe,r.actionError)),l(q,ue)};h(S,q=>{r.actionError!==""&&q(k)})}var O=n(S,2);{var M=q=>{var ue=ed(),pe=s(ue),me=s(pe),fe=n(me);{var ke=ye=>{var De=Zc(),qe=s(De);f(()=>o(qe,e(_))),l(ye,De)};h(fe,ye=>{qt.showTechnicalNames&&ye(ke)})}var Se=n(fe),we=n(pe,2);ze(we,{kind:"write",label:"Tout fonctionne : confirmer",onrun:()=>{r.protect(()=>i.confirm())}}),f(()=>{o(me,`Configuration appliquée mais NON CONFIRMÉE. Ce qui a changé : ${e(A)??""}. `),o(Se,` Le poste reviendra tout seul à la version précédente dans + ${i.pending.seconds_left??""} secondes si personne ne confirme.`)}),l(q,ue)};h(O,q=>{i.pending!==null&&q(M)})}var C=n(O,2),X=s(C),Z=s(X),le=n(X,2);{var j=q=>{var ue=td();l(q,ue)},b=q=>{yi(q,{get health(){return r.health},onshowrows:()=>{P("catalog")},onshowupdate:()=>{P("update")}})},z=q=>{Ec(q,{get admin(){return r},get health(){return r.health}})},$=q=>{ao(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},se=q=>{Uo(q,{get admin(){return r},get draft(){return i}})},oe=q=>{xu(q,{get draft(){return i},get health(){return r.health}})},ce=q=>{Ol(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},ie=q=>{Co(q,{get admin(){return r}})},I=q=>{mc(q,{get admin(){return r},get draft(){return i},get health(){return r.health}})},Q=q=>{Gc(q,{get admin(){return r}})};h(le,q=>{r.health===null?q(j):r.page==="dashboard"?q(b,1):r.page==="troubleshooting"?q(z,2):r.page==="hardware"?q($,3):r.page==="label"?q(se,4):r.page==="rules"?q(oe,5):r.page==="catalog"?q(ce,6):r.page==="journal"?q(ie,7):r.page==="station"?q(I,8):r.page==="update"&&q(Q,9)})}var ae=n(C,2);{var ee=q=>{var ue=ld(),pe=s(ue);{var me=we=>{var ye=ad(),De=s(ye),qe=n(De);Re(qe,16,()=>i.retired,Fe=>Fe,(Fe,at)=>{{let rt=u(()=>`retirer ${at}`);ze(Fe,{kind:"write",get label(){return e(rt)},onrun:()=>i.dropRetired(at)})}}),f(Fe=>o(De,`Ce fichier porte des réglages que cette version du poste ne connaît plus : + ${Fe??""}. `),[()=>i.retired.map(Fe=>St(Fe)).join(", ")]),l(we,ye)};h(pe,we=>{i.retired.length>0&&we(me)})}var fe=n(pe,2);{var ke=we=>{var ye=sd();Re(ye,21,()=>i.faults,De=>De.field,(De,qe)=>{var Fe=nd(),at=s(Fe),rt=s(at),vt=n(at,2);{var ot=Ze=>{var he=rd(),Ee=s(he);f(()=>o(Ee,e(qe).field)),l(Ze,he)};h(vt,Ze=>{qt.showTechnicalNames&&Ze(ot)})}var ct=n(vt),pt=n(ct);{var gt=Ze=>{var he=nt();f(Ee=>o(he,`— valeurs acceptées : ${Ee??""}`),[()=>e(qe).allowed.join(", ")]),l(Ze,he)};h(pt,Ze=>{e(qe).allowed!==void 0&&e(qe).allowed.length>0&&Ze(gt)})}f(Ze=>{o(rt,Ze),o(ct,` ${e(qe).message??""} `)},[()=>St(e(qe).field)]),l(De,Fe)}),l(we,ye)};h(fe,we=>{i.faults.length>0&&we(ke)})}var Se=n(fe,2);{let we=u(()=>i.dirty?"Enregistrer la configuration":"Aucune modification à enregistrer"),ye=u(()=>!i.dirty||r.busy);ze(Se,{kind:"write",get label(){return e(we)},get disabled(){return e(ye)},onrun:()=>{w()}})}l(q,ue)},de=u(()=>$a(r.page)&&i.config!==null);h(ae,q=>{e(de)&&q(ee)})}var xe=n(D,2);{var Ne=q=>{cs(q,{get admin(){return r}})};h(xe,q=>{r.pending!==null&&q(Ne)})}f(()=>{ea(E,qt.showTechnicalNames),o(Z,e(p))}),Ce("change",E,()=>qt.toggleTechnicalNames()),l(a,N),Me()}it(["click","change"]);let ca=null;function dd(a){if(ca!==null||a.querySelector("[data-admin]")!==null)return;const t=document.createElement("div");a.appendChild(t),ca={component:en(od,{target:t,props:{onclose:ud}}),host:t}}function ud(){if(ca===null)return;const{component:a,host:t}=ca;ca=null,tn(a),t.remove()}export{ud as closeAdmin,dd as mountAdmin}; diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 6dcfac1..a5bb210 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -6,7 +6,7 @@ Pesée - + diff --git a/internal/web/maintenance.go b/internal/web/maintenance.go index f206cd6..ac0822f 100644 --- a/internal/web/maintenance.go +++ b/internal/web/maintenance.go @@ -125,8 +125,13 @@ func (s *Server) reloadConfigFromDisk(w http.ResponseWriter, r *http.Request) { // route is not that one: it is the way out of a station under kiosk, where a volunteer // facing something frozen has no console to reach and no other move than the power // switch. It stops nothing by itself either. The station goes through the ordered -// shutdown of §13.4 and returns a non-zero code; the SCM and systemd do the restarting, -// which is precisely the one restart ADR-027 calls legitimate. +// shutdown of §13.4 and returns a non-zero code, and the supervisor does the restarting +// — which is precisely the one restart ADR-027 calls legitimate. +// +// That code is HALF the mechanism, and only half. systemd acts on it alone; the Windows +// SCM ignores it unless the service also carries the non-crash failure flag, which +// InstallService now sets alongside the recovery delays of §15.2. A station registered +// before that flag existed answers this route, stops, and never comes back. func (s *Server) restart(w http.ResponseWriter, _ *http.Request) { if s.restarter == nil { writeProblem(w, http.StatusNotImplemented, codeRestartUnsupervised, diff --git a/internal/web/recovery_test.go b/internal/web/recovery_test.go index 6377771..3e4e252 100644 --- a/internal/web/recovery_test.go +++ b/internal/web/recovery_test.go @@ -270,14 +270,34 @@ func TestARecoveryStillOpensASessionWhenTheFileCannotBeSaved(t *testing.T) { // TestARecoveryTooShortIsRefused: resetting without setting would leave the station // unprotected for as long as nobody came back to it. +// +// The length is DERIVED from MinPasswordLength and never spelled: the literal this bench +// used to post went red the day the owner moved the floor, on a route that was doing what +// it had been asked. And the floor EXACTLY is posted too — a bench that only knew what is +// refused would stay green on a route that refused every password, which on this form +// means a volunteer holding the installation sheet and no way in. func TestARecoveryTooShortIsRefused(t *testing.T) { b := newBench(t, func(o *benchOptions) { o.configStore = &savedConfig{} }) b.setPassword("oublie", "ABCD2345") - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"court"}`) - defer response.Body.Close() - if response.StatusCode != http.StatusUnprocessableEntity { - t.Fatalf("mot de passe trop court = %d, attendu 422", response.StatusCode) + tooShort := strings.Repeat("a", MinPasswordLength-1) + refused := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":`+quote(tooShort)+`}`) + refused.Body.Close() + if refused.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("%q, un caractère sous le plancher = %d, attendu 422", tooShort, refused.StatusCode) + } + + exact := strings.Repeat("a", MinPasswordLength) + accepted := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":`+quote(exact)+`}`) + if accepted.StatusCode != http.StatusOK { + t.Fatalf("%q, le plancher exactement = %d : %s", + exact, accepted.StatusCode, body(t, accepted)) + } + accepted.Body.Close() + if !VerifySecret(b.hub.Config().Admin.PasswordHash, exact) { + t.Fatalf("le mot de passe %q, au plancher exact, n'est pas en service", exact) } } diff --git a/internal/web/sessionroutes.go b/internal/web/sessionroutes.go index 3bf2e24..4eea978 100644 --- a/internal/web/sessionroutes.go +++ b/internal/web/sessionroutes.go @@ -132,9 +132,13 @@ func (s *Server) recoverSession(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusUnauthorized, "", "Code de secours incorrect.") return } - if len(body.Password) < 8 { - writeProblem(w, http.StatusUnprocessableEntity, "", - "Le nouveau mot de passe doit faire au moins 8 caractères.") + // Code points, and NOT bytes: « é » is one character on the on-screen keyboard and two + // bytes on the wire, so counting bytes here let a password through that `openscale + // config password` — which has always counted characters — refuses. Same station, same + // password, two answers depending on which door it came through. + if len([]rune(body.Password)) < MinPasswordLength { + writeProblem(w, http.StatusUnprocessableEntity, "", fmt.Sprintf( + "Le nouveau mot de passe doit faire au moins %d caractères.", MinPasswordLength)) return } if s.configStore == nil || s.controller == nil { diff --git a/internal/web/sessionroutes_test.go b/internal/web/sessionroutes_test.go new file mode 100644 index 0000000..1b9cbc1 --- /dev/null +++ b/internal/web/sessionroutes_test.go @@ -0,0 +1,149 @@ +package web + +import ( + "go/ast" + "go/parser" + "go/token" + "net/http" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// What the session routes decide about the PASSWORD FLOOR: the unit it is counted in, and +// the single place it is allowed to be written. +// +// Two doors set an administration password — `openscale config password` and the recovery +// form of §14.4 — and for years the comment above the terminal's own constant claimed both +// held the same figure. Nothing held it: the terminal counted characters, this route +// counted bytes, and a password of accented characters opened one and was refused by the +// other. The two benches here are what makes that sentence true, the second by refusing +// the very shape the defect took — a number written a second time. + +// TestTheRecoveryFormCountsCODEPOINTSAndNotBYTES. +// +// The terminal's half of this is TestTheTerminalCountsCHARACTERSAndNotBYTES, in +// cmd/openscale: neither package can drive the other's door, so each holds its own side and +// TestTheTwoDoorsNAMETheFloorInsteadOfSpellingIt holds that both read the same constant. +func TestTheRecoveryFormCountsCODEPOINTSAndNotBYTES(t *testing.T) { + // « é » is ONE character and TWO bytes in UTF-8, so a password one character short of + // the floor already exceeds it in bytes. That is exactly what this route let through: + // counting bytes, it accepted what the terminal command refuses, on a form whose whole + // job is to put a password back on a station nobody can log into. + tooShort := strings.Repeat("é", MinPasswordLength-1) + if len(tooShort) < MinPasswordLength { + t.Fatalf("prémisse fausse : %q fait %d octets, il n'atteint pas le plancher et ne "+ + "dit donc rien de l'unité comptée", tooShort, len(tooShort)) + } + + b := newBench(t, func(o *benchOptions) { o.configStore = &savedConfig{} }) + b.setPassword("oublie", "ABCD2345") + + refused := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":`+quote(tooShort)+`}`) + refused.Body.Close() + if refused.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("%q (%d caractères, %d octets) = %d, attendu 422 : la route a compté des octets", + tooShort, len([]rune(tooShort)), len(tooShort), refused.StatusCode) + } + + // And the floor exactly, in accented characters, goes through: it is under the floor in + // nothing at all, and a door that refused it would be counting something else again. + exact := strings.Repeat("é", MinPasswordLength) + accepted := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":`+quote(exact)+`}`) + if accepted.StatusCode != http.StatusOK { + t.Fatalf("%q, le plancher exactement = %d : %s", + exact, accepted.StatusCode, body(t, accepted)) + } + accepted.Body.Close() + if !VerifySecret(b.hub.Config().Admin.PasswordHash, exact) { + t.Fatalf("le mot de passe accentué %q n'est pas en service", exact) + } +} + +// TestTheTwoDoorsNAMETheFloorInsteadOfSpellingIt. +// +// MinPasswordLength is the authority, and the two doors are only allowed to READ it. A +// figure written out a second time is precisely the defect this closes: the terminal +// carried its own constant, this route carried a bare 8, and the comment that swore they +// were the same value had no way of being wrong out loud. +// +// It reads the SOURCE, so it also catches the number a French sentence spells — « au moins +// 8 caractères » on a station whose floor is four is a screen lying to a volunteer about +// what it is going to accept. Comments are out of reach on purpose: the parser is given no +// comment mode, so a comment is free to explain the figure, which is what a comment is for. +func TestTheTwoDoorsNAMETheFloorInsteadOfSpellingIt(t *testing.T) { + for _, path := range []string{ + "sessionroutes.go", + filepath.Join("..", "..", "cmd", "openscale", "configwrite.go"), + } { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + + named := false + ast.Inspect(file, func(node ast.Node) bool { + switch found := node.(type) { + case *ast.Ident: + named = named || found.Name == "MinPasswordLength" + case *ast.BinaryExpr: + if aLengthAgainstANumber(found) { + t.Errorf("%s : une longueur est comparée à un nombre écrit en clair ; "+ + "le plancher du mot de passe s'appelle web.MinPasswordLength", + fset.Position(found.Pos())) + } + case *ast.BasicLit: + if found.Kind == token.STRING && spelledLength.MatchString(found.Value) { + t.Errorf("%s : %s annonce une longueur en chiffres ; celles que ces "+ + "portes tiennent ont un nom — web.MinPasswordLength, "+ + "web.RecoveryCodeLength — et la phrase se compose avec", + fset.Position(found.Pos()), found.Value) + } + } + return true + }) + if !named { + t.Errorf("%s ne nomme pas MinPasswordLength : il applique donc un plancher qui "+ + "n'est pas celui de l'autre porte", path) + } + } +} + +// spelledLength is a length written out in a sentence a volunteer reads. +var spelledLength = regexp.MustCompile(`[0-9]+ caractères`) + +// aLengthAgainstANumber reports whether a comparison holds a len(…) up against a number +// written in place. +// +// Zero is not one of them: « len(notes) > 0 » asks whether there is anything at all, which +// is not a threshold anybody arbitrates and never drifts from another file's copy. +func aLengthAgainstANumber(comparison *ast.BinaryExpr) bool { + switch comparison.Op { + case token.LSS, token.LEQ, token.GTR, token.GEQ: + default: + return false + } + return (isLength(comparison.X) && isNumberInPlace(comparison.Y)) || + (isLength(comparison.Y) && isNumberInPlace(comparison.X)) +} + +// isLength reports whether an expression is a call to the builtin len. +func isLength(expression ast.Expr) bool { + call, isCall := expression.(*ast.CallExpr) + if !isCall { + return false + } + name, isName := call.Fun.(*ast.Ident) + return isName && name.Name == "len" +} + +// isNumberInPlace reports whether an expression is a whole number other than zero, written +// where it is used. +func isNumberInPlace(expression ast.Expr) bool { + literal, isLiteral := expression.(*ast.BasicLit) + return isLiteral && literal.Kind == token.INT && literal.Value != "0" +} diff --git a/web/src/admin/components/Maintenance.svelte b/web/src/admin/components/Maintenance.svelte index 447c2f3..107b01e 100644 --- a/web/src/admin/components/Maintenance.svelte +++ b/web/src/admin/components/Maintenance.svelte @@ -5,6 +5,50 @@ /** Combien de temps il attend avant de renoncer, en millisecondes. */ const PROBE_BUDGET_MS = 5 * 60 * 1000 + /** + * Au bout de combien de temps l'attente cesse d'être ordinaire, en millisecondes. + * + * En dessous, il n'y a rien à faire qu'attendre : le service s'arrête, son gestionnaire + * le relance, l'écran client revient derrière. Au-dessus, ce n'est plus « c'est en train + * de se faire » mais « quelque chose ne se fait pas », et la phrase doit le dire — un + * bouton qui répète « En cours… » pendant cinq minutes ne répond pas à la seule question + * que se pose le bénévole : est-ce que j'attends, ou est-ce que je vais voir ? + */ + const UNUSUAL_AFTER_MS = 30_000 + + /** + * Ce que l'écran dit PENDANT que le poste redémarre. + * + * @param elapsedMs depuis combien de temps le redémarrage a été demandé. + * @returns la phrase à afficher, qui change de nature passé {@link UNUSUAL_AFTER_MS}. + */ + function restartWaitSentence(elapsedMs: number): string { + const seconds = Math.round(elapsedMs / 1000) + const since = `Le poste redémarre depuis ${seconds} seconde${seconds > 1 ? 's' : ''}` + if (elapsedMs < UNUSUAL_AFTER_MS) { + const usual = UNUSUAL_AFTER_MS / 1000 + return `${since}. Il revient d’habitude en moins de ${usual} secondes, écran client compris.` + } + return ( + `${since}, c’est plus long que d’habitude. ` + + 'Allez voir la machine : l’écran client devrait être revenu tout seul.' + ) + } + + /** + * Ce que l'écran dit quand le poste n'est jamais revenu, et qui NOMME LE GESTE. + * + * « Allez le voir » ne disait pas quoi y faire. Un poste qui reste arrêté après ce + * bouton est un poste dont le gestionnaire de services ne relance rien : il faut le + * relancer à la main, PUIS reposer son enregistrement, sans quoi le bouton se conduira + * exactement pareil la fois suivante. + */ + const RESTART_GIVEN_UP = + `Le poste n’a pas répondu dans les ${PROBE_BUDGET_MS / 60_000} minutes. Allez voir la ` + + 'machine : relancez le service avec « openscale service start », puis update.ps1 ou ' + + 'install.ps1 en administrateur — c’est ce qui repose les reprises automatiques sans ' + + 'lesquelles ce bouton restera sans effet.' + /** * Sonde le poste jusqu'à ce qu'il réponde de nouveau. * @@ -64,8 +108,14 @@ /** Les fautes d'un 422, TOUTES : une seule à la fois est un écran qu'on abandonne. */ let faults = $state<{ field: string; message: string }[]>([]) + /** Quand le redémarrage du poste a été demandé, ou 0 quand aucun n'est en cours. */ + let restartingSince = $state(0) + + /** Depuis combien de temps il dure, en millisecondes, redessiné chaque seconde. */ + let restartingFor = $state(0) + /** Vrai pendant le redémarrage du poste : il meurt et revient. */ - let restarting = $state(false) + const restarting = $derived(restartingSince > 0) /** L'échéance du redémarrage de l'ordinateur, ou 0 quand rien n'est armé. */ let rebootAt = $state(0) @@ -86,6 +136,14 @@ return () => clearInterval(tick) }) + $effect(() => { + if (!restarting) return + const tick = setInterval(() => { + restartingFor = Date.now() - restartingSince + }, 1000) + return () => clearInterval(tick) + }) + /** * Relit config.json et met en service ce qui s'y trouve. * @@ -115,7 +173,13 @@ } } - /** Demande au poste de redémarrer, puis attend qu'il réponde de nouveau. */ + /** + * Demande au poste de redémarrer, puis attend qu'il réponde de nouveau. + * + * La phrase du service n'est PAS reprise dans `said` pendant l'attente : elle dit « le + * poste redémarre », et la ligne d'attente le dit déjà en donnant en plus depuis combien + * de temps. Deux exemplaires de la même nouvelle se lisent comme deux nouvelles. + */ async function restartStation(): Promise { working = 'restart' said = '' @@ -123,14 +187,12 @@ try { const started = await admin.protect(() => api.restartStation()) if (started === null) return - said = started.message - restarting = true - said = (await waitForTheStationToComeBack()) - ? 'Le poste est revenu.' - : 'Le poste n’a pas répondu dans les cinq minutes. Allez le voir.' + restartingFor = 0 + restartingSince = Date.now() + said = (await waitForTheStationToComeBack()) ? 'Le poste est revenu.' : RESTART_GIVEN_UP } finally { working = '' - restarting = false + restartingSince = 0 } } @@ -200,6 +262,12 @@ busy={working === 'restart' || restarting} onrun={restartStation} /> + + {#if restarting} +

                                                                        {restartWaitSentence(restartingFor)}

                                                                        + {/if}
                                                                        Redémarrer l’ordinateur
                                                                        diff --git a/web/src/admin/components/PasswordPanel.svelte b/web/src/admin/components/PasswordPanel.svelte index 8090942..40bb1c7 100644 --- a/web/src/admin/components/PasswordPanel.svelte +++ b/web/src/admin/components/PasswordPanel.svelte @@ -1,5 +1,6 @@