Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c65096f4c | |||
| cb142b1cb8 | |||
| 08c8046262 | |||
| c0b131bf6f | |||
| 5bf9651038 | |||
| e91bf36e9e | |||
| 1125ade4c1 | |||
| 518cc9257f | |||
| 46afe10423 | |||
| 25edcc231e | |||
| 6094d33120 | |||
| dcd23abb92 | |||
| 36b98e0abc | |||
| 3c069347b7 | |||
| a0a5595186 | |||
| 6c1172efc3 | |||
| d5536b0778 | |||
| c6794af05b | |||
| bcda6db11f | |||
| 51b5114ae2 | |||
| c97a8739ab | |||
| c2ccef3849 | |||
| 58134c81d1 | |||
| 5b3967327a | |||
| 804b92b957 | |||
| e6772c109d | |||
| 7e84522787 | |||
| 37461ec248 | |||
| 4252a21140 | |||
| 7a889dd466 | |||
| d3cc7c93f0 | |||
| 561b6678ed | |||
| e500024c3a | |||
| 033800f897 | |||
| cf8d79567d | |||
| cb19742cf7 | |||
| c03a3eaf5d | |||
| f7acac3fc8 | |||
| 039b93cfb3 | |||
| c9a0f7fd37 | |||
| 704110d509 | |||
| fb32ed03f8 | |||
| 4439589f0d | |||
| 1e1bc0d421 | |||
| bd26d4f636 | |||
| 2a7f3e8f40 | |||
| 1d37f04d63 | |||
| 1ccf11bcc1 | |||
| d06284fca2 | |||
| fff69ead9c | |||
| c93a833bac | |||
| 05e0e96083 | |||
| b57f796187 | |||
| ecfb9500eb | |||
| e14ce1c36f | |||
| 83cb861e54 | |||
| 1d1bc6a4d6 | |||
| c4636ac8b9 | |||
| f15448ff6e | |||
| bec1f2ec08 | |||
| f625ee5979 | |||
| e68f073505 | |||
| bffb4f9a31 | |||
| 9059a2dc45 | |||
| 6c3ecf1f18 | |||
| 79b67f8eae | |||
| e0a7ac0bf8 | |||
| 598474438b | |||
| f5d98c4e69 | |||
| 14bcbf2cfb | |||
| ac1b1033c4 | |||
| bc71fba289 | |||
| 9c1568ee1c | |||
| c1cea6e813 | |||
| ed978179d8 | |||
| babe7d9f2b | |||
| d3fc0c6d69 | |||
| 0cff0406ab | |||
| d98d152f08 | |||
| 624202c363 | |||
| 24cf2c5a11 | |||
| e721464a7c | |||
| 259bff6707 | |||
| 7d40de3249 | |||
| a3f44702a2 | |||
| 9aa5f171f7 | |||
| 8099b6cb39 | |||
| 8f7df2d06e | |||
| 035e25cb1f | |||
| af5e828864 | |||
| 4216b6b783 | |||
| fd200f5335 | |||
| 3021996658 | |||
| 5aa538ed5d | |||
| d92eb01bc2 | |||
| 9ad61b40e1 | |||
| 05c65a12f4 | |||
| d7185905d8 | |||
| 789ffb16ce | |||
| e0f2586400 | |||
| 2b343c25de | |||
| 13a2e0038a | |||
| 8609d335a3 | |||
| 00361f22f4 | |||
| f0f2954236 | |||
| cb10fd354b | |||
| 675350f5bf | |||
| 60d3cb5606 | |||
| 73d05862af | |||
| 1825676ff9 | |||
| ac8867d5a1 | |||
| e11006e96a | |||
| 34f6f448fb | |||
| abe3f8daa6 | |||
| f719821392 | |||
| db80e58ff1 | |||
| fcc1e9913d | |||
| fa6d4e1039 | |||
| 4a68a54d85 | |||
| 839664377f | |||
| f8db9f7fb1 | |||
| 169ed47fea | |||
| 6bd0b4e9b5 |
20
.env.example
20
.env.example
@@ -1,6 +1,24 @@
|
||||
# file: .env.example
|
||||
# version: 1
|
||||
# version: 3
|
||||
|
||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||
KSP_LOGS_DIRECTORY=logs
|
||||
|
||||
# Optional public Solana Devnet HTTP endpoint override used by config/std.transport.json.
|
||||
# The committed Transport document falls back to https://api.devnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL=https://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet HTTP endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to https://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL=https://api.mainnet-beta.solana.com
|
||||
|
||||
# Optional complete private-provider HTTP endpoint URL used only by the Transport example when explicitly selected.
|
||||
# Keep provider credentials in a KSP_SECRET_* variable; do not copy a real credential-bearing URL into committed JSON.
|
||||
# KSP_SECRET_SOLANA_HTTP_URL=https://provider.example/?api-key=replace-me
|
||||
|
||||
# Minimum time in milliseconds that a KSP desk splash remains visible after its frontend is ready.
|
||||
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
||||
|
||||
# Fade-in/fade-out duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_MS=300
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# file: .gitignore
|
||||
# version: 2
|
||||
# version: 3
|
||||
|
||||
# Rust build artifacts
|
||||
# /target/ # target is outside.
|
||||
@@ -16,6 +16,8 @@ pnpm-debug.log*
|
||||
# Node / frontend dependencies and generated artifacts
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
bindings/
|
||||
gen/
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
5
.pydevproject
Normal file
5
.pydevproject
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<?eclipse-pydev version="1.0"?><pydev_project>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python interpreter</pydev_property>
|
||||
</pydev_project>
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,10 +1,40 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
## 0.2.5 — Wallet foundation — 2026-08-20
|
||||
|
||||
`0.2.5` publie `ksp-wallet-lib` et le format natif interopérable `.kspwallet` V1. La release stabilise les capacités cryptographiquement indépendantes VIEW/OWNER, le masquage de la Pubkey/alias/notes à l’état verrouillé, Argon2id v19 avec paramètres sérialisés et profil de création `65 536 KiB / 3 / 1`, XChaCha20-Poly1305, une autorité Ed25519 OWNER distincte de la keypair Solana, les compartiments `owner_control`/`metadata`/`secret`, la création et l’ouverture mémoire/fichier, la persistence no-clobber, la signature Solana OWNER sans getter secret, l’administration alias/notes, les rotations OWNER/VIEW, la révocation forte VIEW avec rekey metadata, la détection de handles stale et les adapters Solana CLI JSON/Base58 pour inspection/import/export. Le wire V1 est documenté indépendamment de Rust dans `docs/formats/KSPWALLET_V1.md` et couvert par des vecteurs publics ainsi que des canaris adversariaux et une reproduction externe. La release conserve Wallet indépendant de Config/Transport/ExecutionPolicy/Store/Tauri et expose la Pubkey via `ksp-core-lib` tandis que `solana-keypair` reste encapsulée dans Wallet.
|
||||
|
||||
La clôture technique `pre.010-fix.001` à `fix.003` met la dépendance directe KSP à `ed25519-dalek 3.0.0`, accepte la génération `2.2.0` transitive encore imposée par `solana-keypair 3.1.2`, normalise les sources Rust du workspace et introduit `python3 scripts/audit_rust_workspace_rules.py` comme gate structurel complémentaire à rustfmt/Clippy. Le checkpoint final `pre.010-fix.003` est validé avec audit Python clean, `cargo check`, Clippy, tests Wallet et workspace complet verts. Le prompt `prompts/011-V0_2_6_START_PROMPT.md` ouvre ensuite `0.2.6 — Wallet Desk` par audit/sizing avant implémentation.
|
||||
|
||||
## 0.2.4 — HTTP Blocks + Economics + compliance HTTP finale — 2026-08-18
|
||||
|
||||
`0.2.4` achève la surface HTTP Solana standard de `ksp-onchain-transport-lib` : les 10 wrappers Blocks et 5 wrappers Economics portent la couverture typée à **52/52 méthodes HTTP courantes**, tandis que les **14/14 méthodes historiques Deprecated/runtime Removed** restent conservées pour compliance. La release stabilise notamment `getBlock` moderne avec sa forme bare encoding legacy dépréciée, les quatre variantes `transactionDetails`, les versions transaction numériques génériques, `numRewardPartitions` (SIMD-0118), `commissionBps` (SIMD-0291), les overloads de ranges/performance, `getBlockProduction`, les valeurs Economics fournies par le runtime et `getInflationReward` positionnel sans plafond d'adresses inventé. Le réaudit final confirme l'égalité exacte entre l'inventaire HTTP officiel courant et le registre KSP, applique `KSP-TRANSPORT-007` aux **52/52 wrappers**, et ne matérialise aucune extension spéculative issue de la watchlist SIMD. Les canaries de frontières, le workspace complet et les deux smokes Devnet — Transport pur couvrant Accounts/Tokens/Cluster/Transactions/Blocks/Economics et composition Config -> Transport transitoire — sont validés avant publication. Le prompt `prompts/010-V0_2_5_START_PROMPT.md`, enrichi par `pre.009-fix.001`, ouvre `0.2.5 — Wallet foundation` avec threat model, interopérabilité `.kspwallet` et capacités indépendantes VIEW/OWNER.
|
||||
|
||||
Ce changelog résume uniquement les releases KSP considérées comme stables, dans l'ordre chronologique décroissant. Les détails de chaque livraison restent dans `deltas/`.
|
||||
|
||||
## 0.2.3 — HTTP Transactions — 2026-08-18
|
||||
|
||||
`0.2.3` complète la famille HTTP Transactions de `ksp-onchain-transport-lib` avec 11 wrappers typés, portant la surface HTTP typée courante à 37 méthodes : 4 foundation, 22 Accounts/Tokens/Cluster et 11 Transactions. La release stabilise la classification exacte `8 Read / RetrySafe`, `2 WriteSubmission / NeverAfterDispatch` (`requestAirdrop`, `sendTransaction`) et `1 Simulation / RetrySafe` (`simulateTransaction`), avec preuves end-to-end qu'une write submission n'est jamais resoumise après dispatch ambigu mais peut retenter lorsqu'un échec est prouvé `NotDispatched`. `getTransaction` couvre la config moderne complète et la forme bare encoding legacy dépréciée ; les variantes de transaction, metadata, version et `transactionIndex` restent préservées losslessly. `simulateTransaction` couvre sa config complète, ses invariants déterministes et son résultat riche sans introduire de client RPC Solana haut niveau ni de décodage transactionnel dans Transport. La release formalise `KSP-TRANSPORT-007` et réaudite rétroactivement les 37 wrappers courants sans remédiation fonctionnelle. Les canaries conservent 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et 15 Blocks/Economics réservées à `0.2.4`. Les graphes Cargo et les deux smokes Devnet — Transport pur read-only et composition Config -> Transport transitoire — ont été validés avant publication. Le prompt `prompts/009-V0_2_4_START_PROMPT.md` ouvre `0.2.4 — HTTP Blocks + Economics + compliance HTTP finale`.
|
||||
|
||||
## 0.2.2 — HTTP Accounts + Tokens + Cluster — 2026-08-18
|
||||
|
||||
`0.2.2` complète la surface HTTP typée de `ksp-onchain-transport-lib` avec 22 wrappers supplémentaires : 5 Accounts, 5 Tokens et 12 Cluster, portant la couverture typée totale à 26 méthodes avec les quatre canaris foundation de `0.2.1`. La release stabilise les DTOs/configs wire communs, préserve les formes Account encodées/`jsonParsed`, les `null` et champs optionnels, les selectors Token exclusifs, les contextes RPC, les structures Cluster/leader/vote et les limites locales auditées (`getMultipleAccounts <= 100`, `getProgramAccounts <= 4` filtres, `memcmp` raw <= 128 octets, `getSlotLeaders` entre 1 et 5000). Les 22 descriptors restent `Read / RetrySafe` et passent exclusivement par le flux central `descriptor -> execute_standard_rpc -> pool/admission -> executor HTTP`. La release n'ajoute aucune dépendance externe, ne crée aucun client HTTP parallèle et conserve le firewall Transport -> Config/Store/Program. Elle ajoute un smoke Devnet pur à Transport avec settings programmatiques couvrant Accounts/Tokens/Cluster, conserve séparément le smoke historique Config -> Transport comme exception transitoire, et valide les deux explicitement avant publication. Les canaries figent toujours 52 méthodes HTTP courantes, 14 historiques Deprecated/Removed et la partition typed `4 / 22 / 11 / 15`. Le prompt `prompts/008-V0_2_3_START_PROMPT.md` ouvre `0.2.3 — HTTP Transactions`.
|
||||
|
||||
## 0.2.1 — HTTP Solana foundation — 2026-08-17
|
||||
|
||||
`0.2.1` stabilise `ksp-onchain-transport-lib` comme foundation HTTP JSON-RPC Solana provider-neutral : settings publics, endpoints/pool/rôles, priorités et fairness, RPS/burst/concurrence/cooldown, deadline commune, retry/backoff, classification no-resend après dispatch ambigu, snapshots sûrs et exécution HTTP réelle via `reqwest`/rustls. La release fige un registre audité de 52 méthodes HTTP courantes et 14 méthodes historiques Deprecated/Removed, avec quatre wrappers typés canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) et une partition explicite des 48 méthodes restantes sur `0.2.2`–`0.2.4`. Elle ajoute `std.transport`, son schema/exemple et l'adapter `ksp-config-lib -> ksp-onchain-transport-lib`, sans dépendance inverse, ainsi que la redaction des URLs/provider credentials, la neutralisation des URLs contenues dans les `reqwest::Error`, un sink Logging Transport dédié à `info`, des fixtures HTTP déterministes, des canaries de complétude et un smoke Devnet opt-in validant la composition Config -> Transport. Le smoke cross-crates reste temporairement hébergé dans Config et doit migrer vers une future surface d'intégration/orchestration ; ce placement n'est pas un modèle pour les futurs smokes `Config + autre crate`. Le prompt `prompts/007-V0_2_2_START_PROMPT.md` ouvre `0.2.2 — HTTP Accounts + Tokens + Cluster`.
|
||||
|
||||
## 0.2.0 — Audit bot3 et planification de la série `0.2.x` — 2026-08-17
|
||||
|
||||
`0.2.0` stabilise le cadrage de la prochaine phase fonctionnelle de KSP après audit de `khadhroony-bot3`. La release fixe l'ordre `0.2.1+` autour du transport HTTP Solana, du Wallet `.kspwallet`, de Wallet Desk, des transports WebSocket/LaserStream/Yellowstone, du transport off-chain de prix, de `ksp-interface-lib` et de `ksp-program-api`; elle impose la couverture exhaustive des surfaces Transport documentées avec warnings KSP pour les opérations deprecated/obsolete encore fonctionnelles et unstable/experimental. Elle stabilise également la progression durable `RAW -> CORE -> DECODE -> SPECIALIZED`, RAW/CORE sans décodage Program, puis des vertical slices complets par groupe à partir de DECODE, avec priorité Solana Core, SPL token/trading, metadata token, Anchor, Meteora/Raydium/Pump/Orca, routing et Market Desk progressive. Le prompt `prompts/006-V0_2_1_START_PROMPT.md` ouvre `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation` avec un gate de sizing imposant qu'une release concrète reste clôturable dans une seule session.
|
||||
|
||||
## 0.1.4 — Config Desk — 2026-08-17
|
||||
|
||||
`0.1.4` stabilise `ksp-app-config-desk` comme première application desktop/Tauri spécialisée et modèle de référence des futures applications KSP. La release valide de bout en bout les contrats de `ksp-config-lib` et le lifecycle de `ksp-logging-lib` : shell splash/main, inventaire et diagnostics des documents Config, profils et provenance sûre, management `.env` avec shadowing et reveal Secret privilégié, éditeur Logging typé multi-profils/multi-sinks, persistence atomique, hot reload transactionnel, rollback, sélection runtime explicite, génération observable, fichiers de logs distincts par lancement, bridge frontend vers la façade KSP et panneau Test Logging pour démontrer le routing niveau/target/domain. Elle ajoute également les audits desktop/ownership/sécurité, un registre extensible `file_id -> éditeur spécialisé`, une baseline Logging de release `info`/`warn`, et prépare `0.2.0` comme release intermédiaire d’audit de `khadhroony-bot3` et de planification du reste de `0.2.x`.
|
||||
|
||||
## 0.1.3 — Configuration foundation — 2026-08-16
|
||||
|
||||
`0.1.3` stabilise `ksp-config-lib` comme propriétaire KSP unique des documents Config, schemas, profils, compositions, variables `KSP_*`/`KSPB_*`, `.env`, placeholders et persistence autorisée. La release introduit le bootstrap non récursif `cfgpath`/`schemapath`, le registre logique `file_id -> filename`, JSON Schema, globals/profils/`default_profile`, compositions par `file_id`, priorité process env > `.env` > fallback, sensibilité `Public`/`Internal`/`Secret`, représentations real/safe avec provenance, management/persistence atomique JSON et `.env`, ainsi que l'adapter vers `ksp_logging_lib::LoggingSettings` et les audits d'ownership. Elle complète également `ksp-logging-lib` avec les contrats/runtime multi-sink, routing structuré `domain` et hot reload nécessaires au premier document `std.logging.json`, puis prépare `0.1.4 — ksp-app-config-desk` comme validation desktop/Tauri extensible de cette fondation.
|
||||
|
||||
29
Cargo.toml
29
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 61
|
||||
# version: 163
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
version = "0.2.5"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -14,14 +14,29 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "^1.0", features = ["derive"] }
|
||||
argon2 = { version = "^0.5", default-features = false }
|
||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
fs2 = { version = "^0.4" }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
jsonschema = { version = "^0.49", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
solana-keypair = { version = "^3.1", default-features = false }
|
||||
solana-pubkey = { version = "^4.3", default-features = false }
|
||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
||||
tracing = { version = "^0.1", default-features = false }
|
||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||
tracing-appender = { version = "^0.2", default-features = false }
|
||||
tokio = { version = "^1.53", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
|
||||
tokio = { version = "^1.53", default-features = false }
|
||||
tempfile = { version = "^3.27" }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
tauri = { version = "^2.11" }
|
||||
tauri-build = { version = "^2.6" }
|
||||
tauri-plugin-tracing = { version = "^0.3" }
|
||||
ts-rs = { version = "^12.0" }
|
||||
zeroize = { version = "^1.9" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
188
ROADMAP.md
188
ROADMAP.md
@@ -1,11 +1,9 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 16 -->
|
||||
<!-- version: 55 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues pour y parvenir. Une série `X.Y.x` regroupe une famille fonctionnelle de travaux ; elle peut contenir plusieurs releases concrètes et plusieurs sessions.
|
||||
|
||||
Les décisions architecturales négatives ou de prudence n'apparaissent pas comme des tâches à cocher. Elles sont conservées dans les règles et documents d'architecture.
|
||||
Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. Une série `X.Y.x` regroupe une famille fonctionnelle ; chaque release concrète reste une unité de développement/session distincte.
|
||||
|
||||
## Légende
|
||||
|
||||
@@ -15,6 +13,14 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
|
||||
- `[C]` — annulé ;
|
||||
- `[R]` — reporté.
|
||||
|
||||
## Discipline de livraison
|
||||
|
||||
- Une prerelease vise environ **15 à 20 minutes de travail effectif**.
|
||||
- Une release concrète doit être dimensionnée pour pouvoir être ouverte et clôturée dans **une seule session de chat**.
|
||||
- Cette règle fixe une durée maximale par release, pas une obligation de changer de session après chaque release : si une release est clôturée plus vite que prévu, la même session peut ouvrir puis clôturer la release suivante si son propre sizing reste positif et si toutes les frontières version/delta/validation sont conservées.
|
||||
- Si `pre.001` révèle qu'une release est trop grosse, elle est scindée avant implémentation fonctionnelle lourde.
|
||||
- À partir des couches Program/Decode, KSP progresse verticalement groupe par groupe plutôt que par grandes vagues horizontales de decoders/materializers/executors séparés.
|
||||
|
||||
## 0.0.x — Fondation
|
||||
|
||||
- [X] `0.0.1` — Initialiser le dépôt.
|
||||
@@ -25,89 +31,127 @@ Les décisions architecturales négatives ou de prudence n'apparaissent pas comm
|
||||
|
||||
## 0.1.x — Fondations N1
|
||||
|
||||
### Objectifs
|
||||
- [X] `0.1.1` — `ksp-core-lib` : Error/Result, Program IDs fondamentaux et primitives N1.
|
||||
- [X] `0.1.2` — `ksp-logging-lib` : façade KSP de tracing.
|
||||
- [X] `0.1.3` — `ksp-config-lib` : documents, profils, environnement, persistence et adapters.
|
||||
- [X] `0.1.4` — `ksp-app-config-desk` : première application Tauri de référence.
|
||||
|
||||
Regrouper les releases consacrées aux fondations N1. Chaque release concrète est une unité de développement/session distincte et commence par son propre `pre.001` de brainstorming/audit/planification.
|
||||
## 0.2.x — Accès Solana, Wallet et contrats d'extension initiaux
|
||||
|
||||
### Releases concrètes
|
||||
### Cadrage
|
||||
|
||||
- [X] `0.1.1` — Stabiliser `ksp-core-lib` : `Error`/`Result`, Program IDs fondamentaux et primitives réellement N1.
|
||||
- [X] `0.1.2` — Introduire `ksp-logging-lib` comme façade KSP de `tracing`, `tracing-appender` et `tracing-subscriber`.
|
||||
- [X] `0.1.3` — Stabiliser `ksp-config-lib` : documents, profils, résolution, validation, environnement KSP/KSPB, management/persistence et adapter Logging.
|
||||
- [ ] `0.1.4` — Introduire `ksp-app-config-desk` pour valider réellement Config et la frontière Tauri.
|
||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
||||
- [X] `0.2.1` — HTTP foundation stable : matrice 52+14, runtime/routing/résilience/exécution HTTP, 4 canaris typés, `std.transport`, adapter Config -> Transport, canaries de clôture, smoke Devnet opt-in et documentation durable validés ; les 48 wrappers typés restants sont reportés à `0.2.2`–`0.2.4`.
|
||||
|
||||
`0.1.1`, `0.1.2` et `0.1.3` sont désormais stables. `0.1.4` constitue l'étape active suivante avec `ksp-app-config-desk`, première validation desktop/Tauri de Config et modèle des futures applications Tauri KSP.
|
||||
### Releases fonctionnelles décidées/pressenties
|
||||
|
||||
Les contrats publics supplémentaires ne sont introduits que lorsqu'une release concrète en démontre le besoin.
|
||||
- [X] `0.2.1` — **HTTP transport foundation réduite par le gate `pre.001`** : crate/settings/JSON-RPC/registry 52 current + 14 deprecated historiques, pool/rôles/limites/retry, Config adapter, documentation et 4 méthodes typées canari (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) publiés stables.
|
||||
- [X] `0.2.2` — HTTP Accounts + Tokens + Cluster : 22 wrappers typés (5 Accounts + 5 Tokens + 12 Cluster), canaries de complétude 52+14, smoke Devnet Transport pur et smoke historique Config -> Transport validés, documentation durable et prompt `0.2.3` publiés stables.
|
||||
- [X] `0.2.3` — HTTP Transactions stable : 11/11 wrappers typés publiés, classification `8 Read / 2 WriteSubmission / 1 Simulation`, no-resend ambigu prouvé pour les write submissions, `KSP-TRANSPORT-007` réaudité conforme sur les 37 wrappers HTTP courants, graphes Cargo et deux smokes Devnet validés ; `0.2.4` reprend les 15 Blocks/Economics restants.
|
||||
- [X] `0.2.4` — HTTP Blocks + Economics stable : 15/15 wrappers `V0_2_4` publiés, surface typed complète à 52/52 méthodes courantes, 14/14 historiques conservées, réaudit SIMD/inventaire final et `KSP-TRANSPORT-007` global validés ; deux smokes Devnet passés avant publication.
|
||||
- [X] `0.2.5` — Wallet foundation stable : `.kspwallet` V1, VIEW/OWNER indépendants, Argon2id/XChaCha20-Poly1305, autorité Ed25519 OWNER, persistence no-clobber, signature, administration/rotations/révocation VIEW forte, import/export Solana CLI JSON + Base58, canaris adversariaux, interop externe et documentation durable publiés. La clôture `pre.010-fix.001`–`fix.003` ajoute `ed25519-dalek 3.0.0` direct, normalise le Rust workspace et installe l’audit structurel Python complémentaire à rustfmt/Clippy. `Pubkey` reste via `ksp-core-lib`, la keypair reste encapsulée dans Wallet et Config/Transport/ExecutionPolicy/Store/Tauri restent hors Wallet.
|
||||
- [ ] `0.2.6` — Introduire `ksp-app-wallet-desk` utilisant Config composite + Wallet + transport HTTP, notamment pour afficher l'identité et le solde d'un wallet ; le prompt de démarrage est préparé par `0.2.5-pre.010`.
|
||||
- [ ] `0.2.7` — Étendre `ksp-onchain-transport-lib` au WebSocket Solana standard complet ; permettre plusieurs sessions sur une même URL sans imposer encore un pool automatique complexe.
|
||||
- [ ] `0.2.8` — Ajouter Helius LaserStream WebSocket comme extension du moteur WebSocket standard, sans duplication de client.
|
||||
- [ ] `0.2.9` — Ajouter une première fondation Yellowstone gRPC standard/provider-neutral ; dimensionner la surface exacte à `pre.001` selon la documentation normative actuelle.
|
||||
- [ ] `0.2.10` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.11` — Introduire une petite application desk de visualisation/validation des prix offchain, puis intégrer cette capacité dans `ksp-app-wallet-desk` sans dupliquer la logique de récupération/normalisation possédée par le composant spécialisé.
|
||||
- [ ] `0.2.12` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.13` — Introduire `ksp-program-api` comme premier contrat Program extensible, sans imposer encore `ksp-program-lib` complet.
|
||||
|
||||
## 0.2.x — Accès Solana et fondation programmes
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
- [ ] Introduire `ksp-onchain-transport-lib` avec des modèles de transport homogènes indépendants du store.
|
||||
- [ ] Introduire `ksp-wallet-lib` et `ksp-app-wallet-desk`.
|
||||
- [ ] Développer la première surface utile de `ksp-interface-lib`.
|
||||
- [ ] Introduire `ksp-program-api` puis `ksp-program-lib`.
|
||||
- [ ] Définir `ksp-execution-policy-api` comme contrat de policy commun à plusieurs contextes.
|
||||
- [ ] Introduire `ksp-execution-lib` lorsque le premier cycle d'exécution réel justifie l'orchestration programme/policy/wallet/transport.
|
||||
- [ ] Introduire `ksp-offchain-transport-lib` seulement au premier besoin réel.
|
||||
- [ ] Couvrir toutes les méthodes/opérations documentées pour la surface normative ciblée par chaque release.
|
||||
- [ ] Conserver les méthodes deprecated/obsolete encore fonctionnelles et émettre un `warn` KSP lors de leur utilisation.
|
||||
- [ ] Implémenter les méthodes unstable/experimental ciblées et émettre un `warn` KSP lors de leur utilisation.
|
||||
- [ ] Centraliser la metadata de statut des méthodes plutôt que disperser des warnings ad hoc.
|
||||
- [ ] Garder `ksp-onchain-transport-lib` indépendant de `ksp-config-lib`, du Store et des modèles Program/métier.
|
||||
|
||||
## 0.3.x — Données, stockage et acquisition raw
|
||||
## Architecture de données — progression canonique
|
||||
|
||||
- [ ] Introduire `ksp-materializer-api` / `ksp-materializer-lib`.
|
||||
- [ ] Introduire `ksp-store-api` / `ksp-store-lib` avec PostgreSQL de référence.
|
||||
- [ ] Établir les niveaux durables D1 Raw, D2 Core, D3 journal de matérialisation générique et D4 projections de domaine.
|
||||
- [ ] Garantir des replays indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
|
||||
- [ ] Introduire `ksp-app-store-desk`.
|
||||
- [ ] Introduire `ksp-worker-api` et `ksp-worker-raw-retriever`.
|
||||
- [ ] Introduire `ksp-worker-control-lib` lorsque le manager W1 crée le premier besoin concret.
|
||||
- [ ] Introduire `ksp-job-api` et `ksp-job-backfill`.
|
||||
- [ ] Introduire les pipelines spécialisés raw ingestion, Core processing, generic materialization et domain projection lorsque leurs premières frontières fonctionnelles sont développées.
|
||||
- [ ] Introduire les jobs de replay indépendants D1 -> D2, D2 -> D3 et D3 -> D4.
|
||||
- [ ] Normaliser les notifications de données persistées indépendamment de leur producteur et conserver le Store comme source de vérité du backlog.
|
||||
- [ ] Mettre en place claim/lease, outcomes durables et reprise après crash pour les traitements concurrents.
|
||||
La chaîne durable cible est :
|
||||
|
||||
## 0.4.x — Baseline Solana, SPL et metadata
|
||||
```text
|
||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
```
|
||||
|
||||
- [ ] Ajouter progressivement les decoders et `ProgramExecutionPreparer` Core/SPL nécessaires.
|
||||
- [ ] Ajouter Token, Token-2022, ATA et metadata utiles.
|
||||
- [ ] Introduire `ksp-offchain-transport-lib` au plus tard au premier besoin externe.
|
||||
- [ ] Ajouter materializers et jobs ponctuels nécessaires.
|
||||
- [ ] Ajouter les crates `ksp-scenario-<domain>-lib` spécialisées.
|
||||
- [ ] Ajouter les demos `ksp-app-scenario-<domain>-<environment>-desk-demo` correspondantes.
|
||||
- [ ] Garder Memo, Token, ATA, Token-2022 séparés ; regrouper uniquement Metaplex Token Metadata + Token-2022 Metadata dans la famille metadata ; garder SPM séparé.
|
||||
- **RAW** et **CORE** ne nécessitent aucun décodage Program.
|
||||
- À la fin de chaque couche horizontale RAW/CORE, ajouter les jobs/workers/apps nécessaires pour la rendre réellement exploitable avant d'ouvrir la couche suivante.
|
||||
- À partir de **DECODE**, avancer verticalement groupe par groupe : wire -> decode -> matérialisation -> projection spécialisée si utile -> préparation d'exécution -> policy -> execution -> scénarios Devnet.
|
||||
|
||||
## 0.5.x — Anchor et protocoles trading
|
||||
## 0.3.x — RAW / acquisition persistée
|
||||
|
||||
- [ ] Introduire Anchor.
|
||||
- [ ] Étendre Meteora par surfaces bornées.
|
||||
- [ ] Étendre Raydium par surfaces bornées.
|
||||
- [ ] Ajouter progressivement Pump, Orca, Jupiter, OKX et autres intégrations utiles.
|
||||
- [ ] Ajouter interfaces, program implementations, materializers, jobs/scénarios/demos nécessaires pour chaque surface.
|
||||
- [ ] `0.3.1` — Introduire `ksp-store-api` + `ksp-store-lib` avec PostgreSQL de référence et **modèles/persistence RAW uniquement**.
|
||||
- [ ] `0.3.2` — Étendre `ksp-interface-lib` avec les wires génériques nécessaires aux acquisitions et à la future normalisation CORE.
|
||||
- [ ] `0.3.3` — Introduire `ksp-job-api` et un job de backfill historique concret.
|
||||
- [ ] `0.3.4` — Introduire une application spécialisée de backfill/inspection RAW.
|
||||
- [ ] Compléter ensuite la couche RAW avec le worker/service live, son contrôle et les outils d'exploitation réellement nécessaires avant de passer à CORE.
|
||||
|
||||
## 0.6.x — Processing autonome et orchestration
|
||||
## Série CORE suivante
|
||||
|
||||
- [ ] Introduire `ksp-worker-core-processor` pour D1 Raw -> D2 Core canonique.
|
||||
- [ ] Introduire `ksp-worker-generic-materializer` pour D2 Core -> D3 journal de matérialisation générique.
|
||||
- [ ] Introduire `ksp-worker-domain-projector` pour D3 -> D4 projections spécialisées ; nom révisable.
|
||||
- [ ] Exploiter les mêmes pipelines spécialisés pour les workers live et les jobs de replay afin d'éviter la duplication des frontières de processing.
|
||||
- [ ] Étendre `ksp-worker-control-lib` à la gouvernance de plusieurs workers autonomes.
|
||||
- [ ] Fournir pour chaque worker un mode service autonome, avec logique réutilisable séparée du binaire d'enveloppe.
|
||||
- [ ] Construire d'abord les applications spécialisées nécessaires au développement, test et exploitation de chaque capacité.
|
||||
- [ ] Garder jobs et workers sous des lifecycle APIs séparées.
|
||||
- [ ] Définir la persistence CORE canonique Solana générique.
|
||||
- [ ] Implémenter `RAW -> CORE` sans decoder Program : blocs, slots, signatures, transactions/messages, comptes, instructions/CPI brutes, logs/meta et relations structurelles.
|
||||
- [ ] Ajouter replay/backfill RAW -> CORE.
|
||||
- [ ] Ajouter worker/service CORE.
|
||||
- [ ] Ajouter l'application de contrôle/inspection CORE utile.
|
||||
|
||||
## 0.7.x — Trading Intelligence
|
||||
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
||||
|
||||
- [ ] Statistiques et métriques.
|
||||
- [ ] Features et datasets historiques.
|
||||
- [ ] Signaux et risque.
|
||||
### Priorité 1 — Solana Core Programs
|
||||
|
||||
- [ ] Wire/decoding des programmes Core nécessaires transversalement.
|
||||
- [ ] Matérialisation et projections utiles.
|
||||
- [ ] Préparation d'exécution, policy et scénarios Devnet pour les opérations retenues.
|
||||
|
||||
### Priorité 2 — SPL token/trading
|
||||
|
||||
- [ ] SPL Token.
|
||||
- [ ] Associated Token Account.
|
||||
- [ ] Token-2022 et extensions pertinentes.
|
||||
- [ ] Pour chaque famille : decode -> materialize -> specialized -> prepare -> policy -> execute -> scenarios.
|
||||
|
||||
### Priorité 3 — Metadata token
|
||||
|
||||
- [ ] Metaplex Token Metadata.
|
||||
- [ ] Token-2022 Metadata.
|
||||
- [R] Solana Program Metadata (SPM) — redéveloppement plus tard avec le décodage généraliste.
|
||||
|
||||
### Priorité 4 — Anchor
|
||||
|
||||
- [ ] Introduire les contrats et mécanismes Anchor nécessaires aux protocoles trading suivants.
|
||||
|
||||
### Priorité 5 — DEX à fort intérêt
|
||||
|
||||
- [ ] Meteora, y compris vaults/fees/positions/états auxiliaires nécessaires à son groupe.
|
||||
- [ ] Raydium, y compris programmes satellites nécessaires.
|
||||
- [ ] Pump, y compris fee program et composants launch/bonding/pool nécessaires.
|
||||
- [ ] Orca, y compris programmes satellites nécessaires.
|
||||
- [ ] Chaque groupe est terminé verticalement avant de devenir secondaire au profit du suivant.
|
||||
|
||||
### Market Desk V1
|
||||
|
||||
- [ ] Après les premiers groupes Meteora/Raydium/Pump/Orca, introduire une petite `ksp-app-market-desk` spécialisée.
|
||||
- [ ] Visualiser tokens, pools/markets, liquidité, swaps/trades, prix, volumes, OHLC/candles et activité live/récente lorsque disponible.
|
||||
- [ ] Lire les projections SPECIALIZED KSP ; ne pas reconstruire la logique protocolaire dans l'UI.
|
||||
|
||||
### Routing
|
||||
|
||||
- [ ] Jupiter.
|
||||
- [ ] OKX et autres routeurs selon besoin réel.
|
||||
- [ ] Enrichir Market Desk avec routes, legs, DEX impliqués, fees/slippage et comparaison quote/execution lorsque disponible.
|
||||
|
||||
### Trading-adjacent puis décodage généraliste
|
||||
|
||||
- [ ] Ajouter ensuite les programmes indépendants utiles au trading : oracles, locks/vesting indépendants, lifecycle token, risk/signaux, etc.
|
||||
- [ ] Ne jamais y repousser un satellite appartenant à un groupe DEX déjà ciblé.
|
||||
- [ ] Étendre enfin le décodage au reste de Solana selon valeur fonctionnelle.
|
||||
|
||||
## Trading Intelligence et produits ultérieurs
|
||||
|
||||
- [ ] Statistiques/features/datasets historiques.
|
||||
- [ ] Signaux, risque, anomalies et patterns.
|
||||
- [ ] Replay analytique/backtests.
|
||||
- [ ] Patterns/anomalies.
|
||||
- [ ] XGBoost puis autres modèles lorsque les contrats sont stables.
|
||||
|
||||
## 0.8.x et suivantes — Trading opérationnel et expansion produits
|
||||
|
||||
- [ ] Construire les couches puis l'application de trading monoposte au-dessus de Trading Intelligence.
|
||||
- [ ] Étendre l'automatisation de trading.
|
||||
- [ ] Étendre continuellement Program IDs, decoders, execution preparers et materializers.
|
||||
- [ ] Construire progressivement l'explorer Solana.
|
||||
- [ ] Construire progressivement l'explorer/analyse DEX.
|
||||
- [ ] Étudier plus tard d'autres applications utilisant `ksp-wallet-lib`, notamment mobile, extensions navigateur et web.
|
||||
- [ ] XGBoost puis autres modèles lorsque les données et contrats sont stables.
|
||||
- [ ] Construire ensuite les produits de trading opérationnel au-dessus de ces couches.
|
||||
- [ ] Faire évoluer Market Desk vers davantage d'analyse sans la confondre avec l'application globale ou l'orchestrateur.
|
||||
- [ ] Étudier plus tard les autres applications Wallet : mobile, extensions navigateur et web.
|
||||
|
||||
@@ -61,4 +61,4 @@
|
||||
"target_filters": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
64
config/examples/std.transport.example.json
Normal file
64
config/examples/std.transport.example.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 3,
|
||||
"initial_backoff_ms": 150,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"default_profile": "mainnet_mixed",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "mainnet_mixed",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 200,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mainnet_private",
|
||||
"enabled": true,
|
||||
"provider": "private-provider",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_SECRET_SOLANA_HTTP_URL:-https://example.invalid}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 10000,
|
||||
"max_idle_connections_per_host": 16,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 20,
|
||||
"burst_capacity": 40,
|
||||
"max_concurrent_requests": 16,
|
||||
"pause_after_rate_limit_ms": 750
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -263,4 +263,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
config/schemas/std.transport.schema.json
Normal file
124
config/schemas/std.transport.schema.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.transport:v1",
|
||||
"title": "KSP standard HTTP Transport configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["format_version", "retry", "default_profile", "profiles"],
|
||||
"properties": {
|
||||
"format_version": {"const": 1},
|
||||
"retry": {"$ref": "#/$defs/retry"},
|
||||
"default_profile": {"$ref": "#/$defs/profileId"},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/profile"}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]*$"
|
||||
},
|
||||
"descriptor": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^\\S(?:.*\\S)?$"
|
||||
},
|
||||
"positiveMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"positiveU32": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4294967295
|
||||
},
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["max_retries", "initial_backoff_ms", "max_backoff_ms"],
|
||||
"properties": {
|
||||
"max_retries": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"initial_backoff_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_backoff_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requests_per_second": {"$ref": "#/$defs/positiveU32"},
|
||||
"burst_capacity": {"$ref": "#/$defs/positiveU32"},
|
||||
"max_concurrent_requests": {"$ref": "#/$defs/positiveU32"},
|
||||
"pause_after_rate_limit_ms": {"$ref": "#/$defs/positiveMs"}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["role", "enabled", "request_kinds", "priority", "limits"],
|
||||
"properties": {
|
||||
"role": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"request_kinds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"$ref": "#/$defs/descriptor"},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"contains": {"const": "*"}},
|
||||
"then": {"maxItems": 1}
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"limits": {"$ref": "#/$defs/limits"}
|
||||
}
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"url",
|
||||
"connect_timeout_ms",
|
||||
"request_timeout_ms",
|
||||
"roles"
|
||||
],
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/descriptor"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"provider": {"$ref": "#/$defs/descriptor"},
|
||||
"cluster": {"$ref": "#/$defs/descriptor"},
|
||||
"url": {"type": "string", "minLength": 1},
|
||||
"connect_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"request_timeout_ms": {"$ref": "#/$defs/positiveMs"},
|
||||
"max_idle_connections_per_host": {"type": "integer", "minimum": 1},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/role"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "endpoints"],
|
||||
"properties": {
|
||||
"profile_id": {"$ref": "#/$defs/profileId"},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"$ref": "#/$defs/endpoint"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
@@ -24,14 +24,14 @@
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"output_id": "file.all.debug",
|
||||
"output_id": "file.all.info",
|
||||
"enabled": true,
|
||||
"path": "debug/ksp-debug.log",
|
||||
"path": "info/ksp-info.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
@@ -40,6 +40,23 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.onchain_transport.info",
|
||||
"enabled": true,
|
||||
"path": "transport/onchain/ksp-onchain-transport.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"ksp-onchain-transport-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.config.error",
|
||||
"enabled": true,
|
||||
@@ -61,13 +78,21 @@
|
||||
"target_filters": [
|
||||
{
|
||||
"target_prefix": "ksp-config-lib",
|
||||
"level": "trace"
|
||||
"level": "info"
|
||||
},
|
||||
{
|
||||
"target_prefix": "ksp-logging-lib",
|
||||
"level": "debug"
|
||||
"level": "info"
|
||||
},
|
||||
{
|
||||
"target_prefix": "ksp-app-config-desk",
|
||||
"level": "info"
|
||||
},
|
||||
{
|
||||
"target_prefix": "ksp-onchain-transport-lib",
|
||||
"level": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
69
config/std.transport.json
Normal file
69
config/std.transport.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"default_profile": "devnet_public",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet_public",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_HTTP_URL:-https://api.devnet.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "mainnet_public",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_HTTP_URL:-https://api.mainnet-beta.solana.com}",
|
||||
"connect_timeout_ms": 5000,
|
||||
"request_timeout_ms": 15000,
|
||||
"max_idle_connections_per_host": 8,
|
||||
"roles": [
|
||||
{
|
||||
"role": "default",
|
||||
"enabled": true,
|
||||
"request_kinds": ["*"],
|
||||
"priority": 100,
|
||||
"limits": {
|
||||
"requests_per_second": 5,
|
||||
"burst_capacity": 10,
|
||||
"max_concurrent_requests": 8,
|
||||
"pause_after_rate_limit_ms": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
38
crates/ksp-app-config-desk/Cargo.toml
Normal file
38
crates/ksp-app-config-desk/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||
# version: 8
|
||||
|
||||
[package]
|
||||
name = "ksp-app-config-desk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "ksp_app_config_desk_lib"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "ksp-app-config-desk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
ts-rs.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
196
crates/ksp-app-config-desk/README.md
Normal file
196
crates/ksp-app-config-desk/README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 24 -->
|
||||
|
||||
# `ksp-app-config-desk`
|
||||
|
||||
`ksp-app-config-desk` est l'application desktop spécialisée de management et de validation de la configuration KSP.
|
||||
|
||||
La crate est un package Tauri mixte :
|
||||
|
||||
```text
|
||||
package : ksp-app-config-desk
|
||||
lib : ksp_app_config_desk_lib
|
||||
bin : ksp-app-config-desk
|
||||
```
|
||||
|
||||
## Responsabilités
|
||||
|
||||
L'application reste une couche d'interface/composition. Les responsabilités de parsing, validation, profils, environnement, `.env`, secrets et persistence restent dans `ksp-config-lib`; le runtime Logging restera possédé par `ksp-logging-lib`.
|
||||
|
||||
Le gabarit desktop actif fournit maintenant :
|
||||
|
||||
- le verrou single-instance ;
|
||||
- l'assemblage Tauri centralisé dans `src/tauri.rs` ;
|
||||
- les fenêtres `splash` et `main`, orchestrées par `tw_splash.rs` et `tw_main.rs` ;
|
||||
- le frontend Vanilla TypeScript + Vite sous `frontend/` ;
|
||||
- SCSS, Bootstrap et Font Awesome ;
|
||||
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
|
||||
- DataTables 3 + Select 4 pour l’inventaire interactif des documents Config ;
|
||||
- le panneau Documents générique alimenté par le registre `ksp-config-lib`, avec diagnostics backend et réparation validée ;
|
||||
- le panneau Profils générique pour `default_profile`, sélection explicite, vues global/profil/effective sûre et provenance ;
|
||||
- le panneau Environnement alimenté uniquement par `ConfigManagement`, avec rapport desired/effective sûr, source/shadowing, management atomique `.env` et reveal Secret privilégié/transitoire ;
|
||||
- le panneau Logging typé, alimenté par `ConfigManagement::load_logging_document()` / `save_logging_document()`, avec brouillon éditable, profils, console, fichiers, filtres, targets et domains sans parsing JSON frontend ;
|
||||
- l’observabilité du runtime Logging actif : profil, source de sélection, génération, console, compteurs de drop, identité de lancement et prefixes fichiers effectifs ;
|
||||
- l’application explicite d’un profil Logging persisté sans modifier `default_profile` ni le document source ;
|
||||
- `tauri-plugin-tracing` côté Rust et `@fltsci/tauri-plugin-tracing` côté frontend ;
|
||||
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
|
||||
- la destination frontend externe commune à Tauri et Vite;
|
||||
- le bootstrap Config/Logging réel, un `AppState` durable et le premier contrat Tauri/TS-RS `get_app_snapshot`;
|
||||
- le bridge de logging frontend KSP `frontend_log.ts`, routé par `emit_frontend_log` vers des targets statiques whitelistés de `ksp-logging-lib` ;
|
||||
- le lifecycle configurable `splash -> main` avec navigation monofenêtre et instrumentation `debug`/`trace` des interactions frontend.
|
||||
|
||||
## Layout frontend
|
||||
|
||||
```text
|
||||
frontend/
|
||||
├── imgs/
|
||||
├── sass/
|
||||
│ ├── _app.scss
|
||||
│ ├── _bootswatch.scss
|
||||
│ ├── _fontawesome.scss
|
||||
│ ├── _simplebar.scss
|
||||
│ ├── _variables.scss
|
||||
│ ├── main.scss
|
||||
│ └── splash.scss
|
||||
├── ts/
|
||||
│ ├── documents.ts
|
||||
│ ├── environment.ts
|
||||
│ ├── frontend_log.ts
|
||||
│ ├── invoke.ts
|
||||
│ ├── logging.ts
|
||||
│ ├── logging_test.ts
|
||||
│ ├── main.ts
|
||||
│ ├── profiles.ts
|
||||
│ ├── secret_reveal.ts
|
||||
│ ├── shell_registry.ts
|
||||
│ └── splash.ts
|
||||
├── main.html
|
||||
└── splash.html
|
||||
```
|
||||
|
||||
Les premiers bindings TS-RS sont générés par les tests `ts-rs` sous :
|
||||
|
||||
```text
|
||||
frontend/ts/bindings/ksp_app_config_desk/...
|
||||
```
|
||||
|
||||
Les DTO Rust applicatifs restent la source de vérité et les bindings générés ne sont pas versionnés.
|
||||
|
||||
## Robustesse desktop et extensibilité
|
||||
|
||||
`pre.018` rend le fallback de bootstrap Logging testable avant installation du subscriber : la résolution produit d'abord un plan `managed` ou `fallback`, puis seulement l'initialisation runtime est tentée. Une source Logging invalide peut ainsi être couverte par test sans installer un subscriber global dans le processus de tests. Le fallback reste transitoire, console-only, niveau `Info`, sans file sink.
|
||||
|
||||
Le frontend possède désormais `shell_registry.ts`. Le registre décrit les vues du shell et les adapters d'éditeurs spécialisés par `file_id`. Le panneau Documents reste générique ; lorsqu'un document possède un adapter enregistré, **Ouvrir l'éditeur spécialisé** déclenche la navigation par événement de registre. `cfg.std.logging -> Logging` est le premier adapter. Ajouter un futur éditeur ne demande donc pas de réécrire le moteur Documents ni la logique générale d'activation des panneaux.
|
||||
|
||||
Deux audits d'intégration applicatifs complètent les audits Config/Logging existants : contrat Tauri/Vite/package scripts, centralisation des commandes Tauri, absence de `tauri-plugin-log`, interdiction des dialogues navigateur natifs et absence de stockage persistant pour le reveal Secret. Le script frontend `npm run check` exécute uniquement `tsc --noEmit`. Le build Vite de production n’est pas lancé séparément : il appartient au `beforeBuildCommand` de `cargo tauri build`, qui reste la dernière validation après tous les contrôles Rust, le type-check frontend et le parcours fonctionnel `tauri dev`.
|
||||
|
||||
## Artefacts frontend
|
||||
|
||||
Les artefacts frontend construits ne sont pas versionnés. `tauri.conf.json` fixe :
|
||||
|
||||
```text
|
||||
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||
```
|
||||
|
||||
`vite.config.ts` résout cette même destination depuis la racine de la crate, ce qui maintient `dist` hors du workspace source et l'aligne avec la stratégie `.cargo/config.toml` pour les artefacts Rust.
|
||||
|
||||
## Ports desktop
|
||||
|
||||
`ksp-app-config-desk` utilise :
|
||||
|
||||
```text
|
||||
Vite HTTP : 1430
|
||||
Vite WS : 1431
|
||||
```
|
||||
|
||||
Le port HTTP est strict. Les futures applications desk utilisent les couples suivants `1432/1433`, `1434/1435`, etc.
|
||||
|
||||
## Dépendances frontend
|
||||
|
||||
Les bibliothèques consommées par le bundle applicatif sont déclarées sous `dependencies` : `@fltsci/tauri-plugin-tracing`, `@fortawesome/fontawesome-free`, `@tauri-apps/api`, `bootstrap`, `simplebar` et `resize-observer-polyfill`.
|
||||
|
||||
Les outils de compilation/développement et déclarations de types appartiennent à `devDependencies`, notamment `@tauri-apps/cli`, `@types/bootstrap`, `@types/node`, `sass-embedded`, `typescript` et `vite`. Les commandes npm directes sont réservées à l'installation ou à la mise à jour de ces dépendances ; le cycle normal de développement et de build est lancé depuis la racine du workspace par Tauri avec la configuration explicite de l'application.
|
||||
|
||||
|
||||
## Logging frontend KSP
|
||||
|
||||
Le frontend utilise `frontend/ts/frontend_log.ts`. Les helpers `frontendTrace`, `frontendDebug`, `frontendInfo`, `frontendWarn` et `frontendError`, ainsi que le bridge `console.*`, invoquent la commande Tauri `emit_frontend_log`.
|
||||
|
||||
Le payload ne transporte pas de target tracing libre. Il utilise uniquement les identifiants logiques :
|
||||
|
||||
```text
|
||||
frontend -> ksp-app-config-desk.frontend
|
||||
main -> ksp-app-config-desk.frontend.main
|
||||
splash -> ksp-app-config-desk.frontend.splash
|
||||
```
|
||||
|
||||
Rust valide le niveau et le `targetId`, choisit un callsite statique puis émet exclusivement via les macros de `ksp-logging-lib`. Le package applicatif n'importe pas directement `tracing`. Le pont actuel garantit donc le trajet WebView -> Rust tout en conservant l'affichage local des appels `console.*` dans la console WebKit. Le retour général Rust -> console WebKit est volontairement hors périmètre de `0.1.4` : une future intégration devra passer par une couche possédée par `ksp-logging-lib`, sans second subscriber, double émission ni boucle avec le bridge KSP. Le panneau **Test Logging** complète désormais ce bridge : il peut émettre des événements backend via `ksp-logging-lib` avec target KSP statique et domain contrôlé, ou réutiliser le bridge frontend existant avec son target/domain fixes.
|
||||
|
||||
## Panneau Test Logging
|
||||
|
||||
`pre.017` ajoute une surface de validation volontairement explicite. Le champ **Message** est le contenu qui sera réellement journalisé ; il ne doit donc jamais recevoir de Secret. **Log backend** appelle `emit_logging_test`, qui valide le niveau (`trace/debug/info/warn/error/tous`), choisit un target statique (`ksp-app-config-desk` ou `ksp-app-config-desk.logging-test`) et applique un domain absent, connu ou personnalisé borné avant d'émettre exclusivement avec les macros `ksp-logging-lib`. Le résultat retourne uniquement des métadonnées sûres : nombre d'événements, niveau demandé, target/domain effectifs et génération runtime.
|
||||
|
||||
**Log via bridge frontend** réutilise le bridge déjà installé. Son contrat reste volontairement fixe : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. Cela permet de comparer dans le même panneau le routing backend et le chemin WebView -> Rust -> `ksp-logging-lib`, avant et après modification/hot reload des filtres.
|
||||
|
||||
## Bootstrap Config et Logging
|
||||
|
||||
Au démarrage, l'application construit `ConfigBootstrapOptions`, `ConfigFileRegistry`, `ConfigDocumentEngine` et `ConfigManagement` à partir des arguments complets du processus. En développement, le launcher normalise d'abord le current working directory Rust vers la racine du workspace : `cargo tauri dev -c ...` peut sinon lancer le binaire depuis la crate Tauri, ce qui ferait manquer les chemins Config relatifs `config/`, `config/schemas/` et le `.env` racine. Cette normalisation ne lit ni ne parse aucun fichier Config elle-même ; l'ownership reste intégralement dans `ksp-config-lib`. Elle tente ensuite de résoudre le profil Logging par défaut avec un snapshot `ConfigEnvironment` frais.
|
||||
|
||||
Si le document Logging, sa résolution environnementale ou l'initialisation du runtime configuré échoue avant installation du subscriber, Config Desk installe un fallback **transitoire** en mémoire : console stderr, niveau `Info`, aucun fichier, aucun span lifecycle. Ce fallback n'est jamais persisté et son diagnostic frontend est limité à `domain`, `code` et `message`.
|
||||
|
||||
Le `LoggingGuard` est conservé dans `AppState` et sert désormais au hot reload transactionnel du runtime Logging après sauvegarde.
|
||||
|
||||
## Lifecycle des fenêtres
|
||||
|
||||
`tw_splash.rs` valide l'origine de `splash_frontend_ready`, ignore les readiness dupliquées et orchestre le passage à `main`. Les timings sont résolus par `ksp-config-lib` depuis :
|
||||
|
||||
```text
|
||||
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
||||
KSP_DESK_SPLASH_FADE_MS=300
|
||||
```
|
||||
|
||||
Après la readiness frontend, Rust émet `fade_in`, attend la durée minimale, émet `fade_out`, attend la durée de fade puis `tw_main.rs` affiche/focalise `main` avant destruction du splash. Le backend journalise en `debug` les valeurs résolues, leur provenance (`process`, `dotenv` ou `fallback`), chaque attente réellement observée et la durée totale. Avec `12000/3000`, la durée backend attendue entre readiness et activation de `main` est donc d'environ `15000 ms`. Une valeur splash invalide ne bloque pas le manager : des timings de fallback sûrs restent en mémoire afin que la future surface `.env` puisse permettre la réparation.
|
||||
|
||||
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Le logo porte déjà l'identité KSP ; le texte du header suit donc la forme `Config Desk — <vue active>` au lieu de répéter `KSP`. Les quelques commandes principales restent des pills/tabs à droite ; un dropdown sera préféré lorsqu'une application possède trop de commandes pour conserver ce format lisible. La route Logging expose maintenant un brouillon typé éditable, sa persistence atomique et l’application runtime immédiate par hot reload.
|
||||
|
||||
|
||||
## Panneau Profils
|
||||
|
||||
La vue **Profils** inspecte les documents validés qui exposent le contrat standard `default_profile` / `profiles`. Elle ne revalide ni ne fusionne les valeurs dans le frontend : le backend utilise `ConfigDocumentEngine::load_resolved_profile()` puis `ResolvedConfigProfile::resolve_effective_environment_detailed()`.
|
||||
|
||||
L'inspection expose le profil par défaut ou une sélection explicite, les vues source `globals` et `profile`, ainsi que l'effective après résolution environnementale. Cette dernière est toujours sérialisée depuis `ResolvedConfigJson::safe_value()` afin qu'une substitution Secret soit redacted. La provenance top-level distingue `global` / `profile`; la provenance environnement n'expose que JSON Pointer, nom de variable, source `process`/`.env`/fallback et sensibilité, jamais la valeur résolue.
|
||||
|
||||
## Panneau Logging — édition typée et persistence
|
||||
|
||||
La vue **Logging** charge le document standard exclusivement avec `ConfigManagement::load_logging_document()`. Rust mappe ensuite les types publics `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig` et `LoggingTargetFilterConfig` vers des DTO TS-RS dédiés. Le frontend ne parse donc ni le JSON source ni son schema.
|
||||
|
||||
Le panneau expose `format_version`, `logs_directory`, `default_profile`, tous les profils, la console, les fichiers persistants, les filtres locaux, les target overrides et les listes de targets/domains. Le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder et appliquer**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Après persistence, Config Desk recharge un `ConfigEnvironment` frais, résout le `default_profile`, puis appelle `ksp_logging_lib::reinitialize()` sur le `LoggingGuard` actif. Le hot reload est immédiat et `logging_generation` avance uniquement après succès. Si l'application runtime échoue, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** ne modifie que le brouillon/source persistée.
|
||||
|
||||
`pre.016` distingue en plus le **profil default persistant** du **profil runtime actif**. La section **Runtime actif** expose le profil actuellement appliqué, `selection_source` (`default_profile`, `explicit` ou `fallback`), la génération, l'état console, les compteurs de lignes abandonnées et les file sinks réellement actifs. Un profil déjà persisté peut être appliqué explicitement sans modifier `default_profile` ni écrire le document ; cette action est désactivée tant que le brouillon contient des changements non sauvegardés.
|
||||
|
||||
Chaque lancement de Config Desk crée aussi une `LoggingRuntimeIdentity` stable : `application_id` + timestamp UTC de démarrage + PID. `ksp-logging-lib` utilise cette identité pour préfixer les noms des fichiers actifs et la conserve pendant tous les hot reloads du même processus. Deux lancements distincts ne partagent donc plus le même fichier persistant, même avec une rotation `daily`. Avec la configuration de release `info/ksp-info.log`, un prefix effectif peut être `ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log`. Le path Config reste inchangé ; l'identité appartient au runtime, pas au document source.
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
La configuration canonique livrée avec `0.1.4` revient à une baseline opératoire `info` conformément à KSP-APP-031 : console `info`, sink général `info/ksp-info.log` au niveau `info`, et overrides `ksp-config-lib`, `ksp-logging-lib`, `ksp-app-config-desk` à `info`. Le `default_filter` reste `warn` pour les autres targets KSP. Les niveaux `debug`/`trace` restent disponibles et peuvent être remontés temporairement depuis Config Desk lors d’un développement ou diagnostic, puis redescendus avant la release suivante.
|
||||
|
||||
## Traçabilité frontend
|
||||
|
||||
Les actions utilisateur significatives et changements d'état sont journalisés en `debug`; les opérations plus fines (rendu/remplacement DOM, étapes IPC, animations et événements fréquents) en `trace`. Le wrapper `invoke.ts` trace début/fin des commandes sans journaliser leurs arguments, afin de ne pas créer ultérieurement de fuite de valeurs sensibles.
|
||||
|
||||
## Police du splash
|
||||
|
||||
Le splash utilise `DOS_Amazigh.ttf` (`font-family: Dos Amazigh`) pour son titre, via le `@font-face` déclaré dans `frontend/sass/splash.scss`. Le répertoire `frontend/fonts/` documente la provenance et l'empreinte de l'asset de référence. Le lifecycle du splash est désormais actif et utilise cette police pendant la transition vers la fenêtre principale.
|
||||
|
||||
|
||||
## Panneau Environnement / `.env`
|
||||
|
||||
La vue **Environnement / `.env`** expose la projection sûre de `ConfigManagement::environment_report()` puis une zone de management qui appelle exclusivement `ConfigManagement::set_dotenv_value()` et `ConfigManagement::remove_dotenv_value()`. Elle ne lit jamais directement le process environment ni le fichier `.env`. Pour chaque variable KSP/KSPB actuellement présente dans le process ou `.env`, le frontend reçoit uniquement : nom, namespace, sensibilité, valeur desired sûre, valeur effective sûre, source effective et indicateur de shadowing.
|
||||
|
||||
Les mutations acceptent un nom existant ou nouveau sous `KSP_*` / `KSPB_*`, présentent explicitement `source_changed`, `effective_changed`, `shadowed_by_process_environment` et `reload_required`, puis rechargent le rapport depuis Config. Une ligne du tableau ne précharge que le nom de variable : aucune ancienne valeur réelle n'est injectée dans le formulaire. Pour un namespace Secret, la nouvelle saisie est masquée. Les boutons **Révéler effective** et **Révéler .env** ouvrent un flux privilégié distinct : confirmation Bootstrap explicite, commande Tauri dédiée, appel exclusif aux méthodes `ConfigManagement::reveal_*`, puis affichage temporaire masqué par défaut. Le backend refuse ce flux pour les namespaces non-Secret.
|
||||
|
||||
La suppression demande une confirmation via un modal Bootstrap intégré au shell. Les dialogues navigateur natifs `window.alert` / `window.confirm` / `window.prompt` ne sont pas utilisés pour ce flux.
|
||||
|
||||
La valeur révélée n'est jamais stockée dans `AppState`, n'est jamais incluse dans un snapshot/diagnostic/log et est effacée du DOM à la fermeture du modal ou au changement de vue. Aucun `localStorage`/`sessionStorage` n'est utilisé.
|
||||
|
||||
Les valeurs `KSP_SECRET_*` / `KSPB_SECRET_*` restent redacted par `ksp-config-lib` dans le rapport. La priorité `process > .env > fallback` reste visible : une mutation `.env` shadowed peut changer le desired sans modifier l'effective. Les logs frontend/backend n'enregistrent jamais la valeur soumise, seulement le nom de variable, l'opération et les booléens du rapport de changement.
|
||||
10
crates/ksp-app-config-desk/TODO.md
Normal file
10
crates/ksp-app-config-desk/TODO.md
Normal file
@@ -0,0 +1,10 @@
|
||||
<!-- file: crates/ksp-app-config-desk/TODO.md -->
|
||||
<!-- version: 19 -->
|
||||
|
||||
# TODO `ksp-app-config-desk`
|
||||
|
||||
Aucun TODO bloquant ne reste ouvert pour la release `0.1.4`.
|
||||
|
||||
La première version fonctionnelle de Config Desk couvre le shell Tauri, le bootstrap Config/Logging, l’inventaire et la réparation des documents, l’inspection des profils/provenance, le management `.env`, le reveal Secret privilégié, l’éditeur Logging typé, la persistence atomique, le hot reload transactionnel, la sélection explicite du profil runtime, les fichiers de logs isolés par lancement, le panneau Test Logging backend/frontend, le registre d’éditeurs spécialisés et les audits desktop.
|
||||
|
||||
Le retour général **Rust -> console WebKit** via une `WebviewLayer` du plugin tracing est volontairement reporté hors `0.1.4`. Le trajet **WebView -> Rust -> ksp-logging-lib** est déjà validé et le panneau Test Logging couvre le routing runtime. Une future intégration Rust -> WebKit devra être conçue dans `ksp-logging-lib` afin de préserver le subscriber unique, d’éviter les doubles émissions et de ne pas créer de boucle avec le bridge `console.*` existant. Le sujet est conservé dans `docs/IDEAS.md`.
|
||||
336
crates/ksp-app-config-desk/USAGE.md
Normal file
336
crates/ksp-app-config-desk/USAGE.md
Normal file
@@ -0,0 +1,336 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 24 -->
|
||||
|
||||
# Utilisation de `ksp-app-config-desk`
|
||||
|
||||
## État actuel
|
||||
|
||||
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Les panneaux Documents, Profils et Environnement/`.env` sont fonctionnels, y compris les mutations `.env` create/update/remove et les reveals Secrets privilégiés. Logging expose maintenant un brouillon typé éditable, une persistence atomique, un hot reload sans redémarrage, un profil runtime sélectionnable indépendamment du `default_profile`, une identité de lancement observable pour isoler les fichiers persistants et un panneau de test du routing backend/frontend. Le shell possède aussi un registre d'éditeurs spécialisés par `file_id`, utilisé aujourd'hui pour `cfg.std.logging`.
|
||||
|
||||
La fenêtre `splash` est visible au démarrage. Après readiness du frontend et temporisation résolue par Config, l'application effectue le fade-out, affiche/focalise `main` puis détruit `splash`. La fenêtre principale expose immédiatement la navigation monofenêtre de référence.
|
||||
|
||||
## Première installation des dépendances frontend
|
||||
|
||||
Depuis :
|
||||
|
||||
```text
|
||||
crates/ksp-app-config-desk
|
||||
```
|
||||
|
||||
installer les dépendances déclarées avec la commande de gestion prévue par le projet :
|
||||
|
||||
```bash
|
||||
npm i -D
|
||||
cd ../../
|
||||
```
|
||||
|
||||
`npm i -D` sans nom de package installe les dépendances déclarées du package ; la classification `dependencies` / `devDependencies` reste celle de `package.json`. Les lockfiles frontend restent ignorés et ne sont pas versionnés. Après cette installation, les commandes Cargo/Tauri sont exécutées depuis la racine du workspace.
|
||||
|
||||
## Développement normal
|
||||
|
||||
Le frontend n'est pas lancé directement avec npm. KSP est un workspace Rust multi-app : depuis la racine du workspace, la configuration Tauri de l'application doit être sélectionnée explicitement :
|
||||
|
||||
```bash
|
||||
cargo tauri dev -c crates/ksp-app-config-desk/tauri.conf.json
|
||||
```
|
||||
|
||||
Tauri exécute alors le hook :
|
||||
|
||||
```text
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite écoute strictement sur :
|
||||
|
||||
```text
|
||||
HTTP : 1430
|
||||
WS : 1431
|
||||
```
|
||||
|
||||
Si le port HTTP est déjà occupé, le démarrage doit échouer au lieu de sélectionner silencieusement un autre port.
|
||||
|
||||
## Build frontend
|
||||
|
||||
Le build de l'application passe également par Tauri. Depuis la racine du workspace :
|
||||
|
||||
```bash
|
||||
cargo tauri build -c crates/ksp-app-config-desk/tauri.conf.json
|
||||
```
|
||||
|
||||
`beforeBuildCommand` déclenche :
|
||||
|
||||
```text
|
||||
npm run build
|
||||
```
|
||||
|
||||
Vite construit les pages `main.html` et `splash.html` vers :
|
||||
|
||||
```text
|
||||
../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist
|
||||
```
|
||||
|
||||
Cette destination est résolue depuis la racine de la crate dans `vite.config.ts` et correspond au `frontendDist` de `tauri.conf.json`.
|
||||
|
||||
## Tracing desktop
|
||||
|
||||
`tauri-plugin-tracing` est enregistré côté Rust et la capability contient `tracing:default`. Le package frontend associé reste disponible comme adaptateur Tauri, sans installer de subscriber concurrent.
|
||||
|
||||
Les logs techniques du frontend passent maintenant par `frontend/ts/frontend_log.ts` puis la commande Tauri `emit_frontend_log`. Les identifiants de target autorisés sont :
|
||||
|
||||
```text
|
||||
frontend
|
||||
main
|
||||
splash
|
||||
```
|
||||
|
||||
Ils sont convertis côté Rust vers des targets KSP statiques `ksp-app-config-desk.frontend*` et émis uniquement via `ksp-logging-lib`. Un niveau différent de `trace`, `debug`, `info`, `warn` ou `error`, ou un `targetId` non whitelisté, est rejeté avec un `CommandErrorDto` sûr.
|
||||
|
||||
`main.ts` et `splash.ts` installent aussi le bridge `console.*`; l'échec éventuel d'un `invoke` est écrit uniquement sur la console WebView originale afin d'éviter une boucle de logging. Ce bridge conserve les messages JavaScript dans la console WebKit et les transmet vers Rust. Le retour général Rust -> console WebKit est reporté hors `0.1.4`; il devra être ajouté ultérieurement sous ownership de `ksp-logging-lib`, sans installer de subscriber Tauri parallèle ni créer de boucle avec ce bridge.
|
||||
|
||||
## Bindings TS-RS
|
||||
|
||||
Le layout cible est :
|
||||
|
||||
```text
|
||||
frontend/ts/bindings/ksp_app_config_desk/...
|
||||
```
|
||||
|
||||
Les bindings sont générés au premier DTO Tauri réel ; aucune structure factice n'est ajoutée uniquement pour créer le répertoire.
|
||||
|
||||
|
||||
## Bootstrap backend
|
||||
|
||||
Les arguments `--cfgpath`, `--schemapath` et `--filemap=...` sont transmis tels quels à `ksp-config-lib`. En build debug, le launcher replace le current working directory Rust à la racine du workspace avant ce bootstrap afin que les defaults relatifs `config/`, `config/schemas/` et `.env` désignent les ressources racine même lorsque Tauri lance `cargo run` depuis la crate de l'application. Le launcher ne lit pas ces ressources lui-même. Le profil Logging initial est le `default_profile` de `std.logging.json`.
|
||||
|
||||
Si cette configuration ne peut pas être utilisée, l'application doit rester démarrable pour permettre sa réparation : elle utilise alors un fallback Logging console/stderr en mémoire. Le fallback n'écrit aucun fichier de configuration et n'écrase aucune valeur utilisateur.
|
||||
|
||||
La commande Tauri `get_app_snapshot` expose un état sûr du bootstrap. Ses types TypeScript sont générés par `cargo test -p ksp-app-config-desk` sous `frontend/ts/bindings/`.
|
||||
|
||||
## Lifecycle et timings du splash
|
||||
|
||||
Les variables communes sont :
|
||||
|
||||
```text
|
||||
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
||||
KSP_DESK_SPLASH_FADE_MS=300
|
||||
```
|
||||
|
||||
La priorité est celle de Config : process > `.env` > fallback. Les valeurs sont lues uniquement via `ksp-config-lib`. Une valeur non numérique ou hors borne déclenche un fallback runtime en mémoire plutôt qu'un refus de démarrer Config Desk.
|
||||
|
||||
Le frontend `splash.ts` installe son listener puis appelle `splash_frontend_ready`. Le backend vérifie que l'appel provient réellement de la WebView `splash`; une readiness dupliquée (par exemple après reload Vite) est ignorée. La durée minimale commence à cette readiness : Rust émet le fade-in, attend `minimum`, émet le fade-out, attend `fade`, puis active `main`. Ainsi `KSP_DESK_SPLASH_MINIMUM_MS=12000` et `KSP_DESK_SPLASH_FADE_MS=3000` donnent environ `15000 ms` de lifecycle backend. Des logs `debug` indiquent source des deux valeurs, attentes configurées/réelles et durée totale afin de vérifier ce contrat.
|
||||
|
||||
## Navigation principale
|
||||
|
||||
La barre principale contient :
|
||||
|
||||
```text
|
||||
Vue d'ensemble
|
||||
Documents
|
||||
Profils
|
||||
Environnement / .env
|
||||
Logging
|
||||
```
|
||||
|
||||
Les cinq vues sont fonctionnelles. Logging charge le contrat typé `std.logging.json`, permet de modifier son brouillon, de le persister via Config puis de l'appliquer immédiatement au runtime KSP sans redémarrage. Le header affiche `Config Desk — <vue active>` : le logo fournit déjà l'identité KSP. Les commandes principales peu nombreuses utilisent des pills/tabs alignées à droite ; une application plus chargée devra préférer un dropdown. Chaque clic de tab est tracé en `trace`, l'activation utilisateur significative reste tracée en `debug`, et chaque remplacement/rendu de section en `trace`. Les appels Tauri partagés utilisent `frontend/ts/invoke.ts`, qui journalise le début et la fin d'une commande sans journaliser ses arguments.
|
||||
|
||||
## Asset font du splash
|
||||
|
||||
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique au titre du splash.
|
||||
|
||||
## Panneau Documents
|
||||
|
||||
La vue **Documents** liste les documents de kind `Config` depuis le registre `ksp-config-lib`; les schemas ne sont pas codés en dur dans le frontend. DataTables fournit tri, recherche, pagination et sélection simple.
|
||||
|
||||
Pour chaque document, le backend expose :
|
||||
|
||||
- `file_id` ;
|
||||
- filename mappé ;
|
||||
- schema associé ;
|
||||
- path résolu par Config ;
|
||||
- état valide/invalide ;
|
||||
- étape de diagnostic `read`, `json`, `schema`, `semantic`, `effective` ou `other`.
|
||||
|
||||
La sélection charge le source brut via `ConfigManagement::read_source()`, y compris lorsque le document est invalide mais lisible. Le bouton **Valider et sauvegarder** envoie le texte à `ConfigManagement::save_source_candidate()` : aucune écriture frontend directe du fichier n’est effectuée. Un candidat invalide est refusé avant persistence et le diagnostic backend reste l’autorité de validation.
|
||||
|
||||
Les clics, sélections, chargements, remplacements DOM et sauvegardes sont tracés via le bridge frontend KSP sans journaliser le contenu du source.
|
||||
|
||||
|
||||
### Ouvrir un éditeur spécialisé depuis Documents
|
||||
|
||||
Le panneau Documents demeure générique. Lorsqu'un `file_id` possède un adapter déclaré dans `shell_registry.ts`, le détail affiche **Ouvrir l'éditeur spécialisé**. Pour `cfg.std.logging`, ce bouton active la vue Logging sans dupliquer la lecture, la validation ou la persistence Config. Un document sans adapter spécialisé reste entièrement inspectable/réparable dans le panneau Documents et n'affiche pas ce bouton.
|
||||
|
||||
|
||||
## Panneau Profils
|
||||
|
||||
La vue **Profils** charge l'inventaire des documents Config validés qui possèdent `default_profile` et `profiles`. Pour le document sélectionné :
|
||||
|
||||
- **Profil par défaut** appelle le backend sans `profile_id` et doit afficher `source de sélection = default_profile` ;
|
||||
- changer **Profil inspecté** effectue une sélection explicite et doit afficher `source de sélection = sélection explicite` ;
|
||||
- **Globals source** et **Profil source** affichent les objets validés correspondants ;
|
||||
- **Effective sûre** affiche le résultat de résolution environnementale redacted par Config ;
|
||||
- **Provenance top-level** indique si chaque clé effective vient des globals ou du profil ;
|
||||
- **Provenance environnement** indique JSON Pointer, variable, source et sensibilité sans valeur réelle.
|
||||
|
||||
Avec le `std.logging.json` actuel et `KSP_LOGS_DIRECTORY` défini dans `.env`, la provenance attendue pour `/logs_directory` est `KSP_LOGS_DIRECTORY`, source `dotenv`, sensibilité `Internal`. Le contenu des vues JSON n'est pas recopié dans les logs ; seuls les identifiants de document/profil et métadonnées de résolution sont tracés.
|
||||
|
||||
|
||||
## Panneau Environnement / `.env`
|
||||
|
||||
La vue **Environnement / `.env`** charge `get_environment_report`, qui projette exclusivement `ConfigManagement::environment_report()`. La table affiche :
|
||||
|
||||
- nom de variable ;
|
||||
- namespace KSP/KSPB ;
|
||||
- sensibilité ;
|
||||
- valeur persisted `.env` sûre (`desired`) ;
|
||||
- valeur effective process-ou-`.env` sûre ;
|
||||
- source effective ;
|
||||
- indicateur `shadowed` lorsqu'une valeur `.env` existe mais que le process hérité gagne.
|
||||
|
||||
Une valeur Secret doit apparaître redacted (`********`) dans les colonnes desired/effective. Le bouton **Rafraîchir** relit le rapport Config.
|
||||
|
||||
La zone **Management / test .env** accepte une variable existante ou un nouveau nom `KSP_*` / `KSPB_*`. **Créer / Modifier** appelle `ConfigManagement::set_dotenv_value()` et **Supprimer** appelle `ConfigManagement::remove_dotenv_value()` ; aucune écriture `.env` n'est implémentée dans le frontend ou dans la couche Tauri. Après chaque mutation, le rapport est rechargé et les quatre indicateurs Config sont affichés :
|
||||
|
||||
```text
|
||||
source_changed
|
||||
effective_changed
|
||||
shadowed_by_process_environment
|
||||
reload_required
|
||||
```
|
||||
|
||||
Cliquer une ligne du rapport remplit uniquement le nom de variable. L'ancienne valeur n'est jamais préchargée dans le formulaire. Une saisie `KSP_SECRET_*` / `KSPB_SECRET_*` est masquée. Pour une variable Secret sélectionnée, **Révéler effective** et **Révéler .env** deviennent disponibles. Ils ouvrent un modal Bootstrap de confirmation avant toute commande Tauri privilégiée.
|
||||
|
||||
Le reveal utilise un DTO et une commande séparés des rapports ordinaires. Après confirmation, la valeur réelle est affichée dans un champ transitoire `password`, masqué par défaut ; **Afficher/Masquer** ne modifie que le contrôle local. Fermer le modal ou quitter la vue Environnement efface immédiatement la valeur du DOM. Le backend refuse le reveal des namespaces Public/Internal et ne journalise que le nom, la source demandée et la présence/absence d'une valeur.
|
||||
|
||||
Le bouton **Supprimer** ouvre un modal Bootstrap indiquant la variable ciblée. **Annuler**, la fermeture du modal ou `Esc` abandonnent l'opération ; **Supprimer** confirme ensuite l'appel à `ConfigManagement::remove_dotenv_value()`. Aucun `window.confirm()` natif n'est utilisé.
|
||||
|
||||
Pour valider le shadowing, lancer ponctuellement l'application avec `KSP_LOGS_DIRECTORY` fourni par le process puis modifier la même variable dans `.env` depuis le panneau. Le résultat attendu est `source_changed=true`, `effective_changed=false`, `shadowed_by_process_environment=true`, `reload_required=false`. Le process hérité reste prioritaire et n'est jamais modifié par Config Desk.
|
||||
|
||||
## Panneau Logging — édition typée
|
||||
|
||||
La vue **Logging** appelle `get_logging_document`. Le backend charge le document avec `ConfigManagement::load_logging_document()` et expose :
|
||||
|
||||
- `file_id`, path résolu, `format_version`, `logs_directory`, `default_profile` ;
|
||||
- chaque profil avec `default_filter` et `span_events` ;
|
||||
- la console (`enabled`, output, ANSI, format, level, targets, domains) ;
|
||||
- tous les fichiers (`output_id`, path, rotation, format, ANSI et filtre) ;
|
||||
- tous les `target_filters` globaux.
|
||||
|
||||
Le sélecteur **Profil édité** travaille sur un brouillon local. `logs_directory`, `default_profile`, profils, console, file sinks, filtres et target overrides sont modifiables. **Créer**, **Cloner**, **Renommer** et **Supprimer** agissent d'abord sur le brouillon ; la suppression de profil est confirmée par modal Bootstrap. **Sauvegarder et appliquer** envoie un candidat typé à `save_logging_document`, qui reconstruit les contrats Config, persiste atomiquement après validation, recharge un `ConfigEnvironment` frais, résout le `default_profile` et hot-reload le `LoggingGuard`. Si le runtime ne peut pas être préparé/rechargé, l'ancien runtime reste actif et la source précédente est restaurée. **Recharger le document** resynchronise uniquement le brouillon depuis la source persistée et demande confirmation si des changements non sauvegardés existent. Les messages Cargo/Tauri/Vite affichés par `cargo tauri dev` sont externes au runtime Logging KSP et ne dépendent pas de `console.enabled`.
|
||||
|
||||
|
||||
|
||||
## Test Logging — routing backend et bridge frontend
|
||||
|
||||
La section **Test Logging — routing contrôlé** utilise un message explicitement destiné aux logs. Ne jamais y copier une valeur `KSP_SECRET_*` / `KSPB_SECRET_*`.
|
||||
|
||||
Pour le backend :
|
||||
|
||||
1. choisir `trace`, `debug`, `info`, `warn`, `error` ou `tous` ;
|
||||
2. choisir `ksp-app-config-desk` ou `ksp-app-config-desk.logging-test` ;
|
||||
3. choisir un domain `absent`, `connu` (`config.logging_test`, `config.logging_runtime`, `frontend`) ou `personnalisé` ;
|
||||
4. cliquer **Log backend**.
|
||||
|
||||
La commande `emit_logging_test` renvoie le nombre d'événements émis et la génération runtime active, mais ne duplique pas le message dans son DTO de résultat. Le test `tous` émet exactement cinq événements, un par niveau.
|
||||
|
||||
**Log via bridge frontend** réutilise le même message et le même niveau, mais garde le contrat du bridge : `target=ksp-app-config-desk.frontend.main`, `domain=frontend`. En mode `tous`, cinq événements sont envoyés. Cette voie permet de vérifier que les changements de console/file sinks/target/domain appliqués par hot reload affectent aussi les événements issus de la WebView.
|
||||
|
||||
Scénario conseillé : émettre `tous`, modifier les filtres du profil actif, **Sauvegarder et appliquer**, réémettre exactement le même test et comparer les sinks réellement alimentés sans redémarrer l'application.
|
||||
|
||||
## Runtime Logging — profil actif, génération et fichiers par lancement
|
||||
|
||||
La section **Runtime actif** du panneau Logging ne décrit pas le brouillon : elle interroge l'état réellement installé dans `ksp-logging-lib`.
|
||||
|
||||
Elle expose :
|
||||
|
||||
- `active_profile` ;
|
||||
- `selection_source` : `default_profile`, `explicit` ou `fallback` ;
|
||||
- `generation` ;
|
||||
- console active/inactive ;
|
||||
- compteurs cumulés de lignes abandonnées ;
|
||||
- `application_id` ;
|
||||
- `launch_timestamp` ;
|
||||
- chaque file sink actif avec son directory, son prefix effectif et sa rotation.
|
||||
|
||||
Au démarrage de Config Desk, l'identité de lancement est construite une seule fois avec un token de la forme :
|
||||
|
||||
```text
|
||||
20260816-182519.123Z-p4242
|
||||
```
|
||||
|
||||
Avec la baseline de release configurée sur `info/ksp-info.log`, le runtime peut exposer un prefix comme :
|
||||
|
||||
```text
|
||||
ksp-app-config-desk.20260816-182519.123Z-p4242.ksp-info.log
|
||||
```
|
||||
|
||||
Le même `launch_timestamp` doit rester affiché après chaque hot reload du processus courant. Après fermeture puis nouveau lancement de l'application, il doit changer. La rotation `daily` ou `hourly` peut ajouter sa composante de rotation, mais deux lancements ne doivent plus partager le même prefix applicatif.
|
||||
|
||||
### Sélection explicite du profil runtime
|
||||
|
||||
`default_profile` reste une propriété persistée de `std.logging.json`. Le sélecteur **Profil runtime à appliquer** travaille uniquement avec les profils déjà persistés et ne modifie pas ce champ.
|
||||
|
||||
Scénario attendu avec deux profils persistés `local_dev` et `local_test` :
|
||||
|
||||
1. garder `default_profile=local_dev` ;
|
||||
2. sélectionner `local_test` dans **Profil runtime à appliquer** ;
|
||||
3. cliquer **Appliquer le profil** ;
|
||||
4. vérifier `active_profile=local_test`, `selection_source=explicit` et `generation + 1` ;
|
||||
5. cliquer **Recharger le document** : `default_profile` doit toujours être `local_dev` ;
|
||||
6. sélectionner/appliquer `local_dev` explicitement si souhaité ;
|
||||
7. **Sauvegarder et appliquer** un document valide : le runtime revient au `default_profile` du document et `selection_source=default_profile`.
|
||||
|
||||
Le bouton d'application explicite est désactivé lorsque le brouillon est sale afin qu'un profil affiché mais non persisté ne soit jamais confondu avec un profil réellement chargeable par Config.
|
||||
|
||||
Si la résolution ou la préparation du profil explicite échoue, `ksp_logging_lib::reinitialize()` ne remplace pas les layers actifs et la génération ne doit pas avancer. Une sauvegarde de document qui échoue pendant l'application runtime conserve également l'ancien runtime et restaure la source précédente lorsqu'elle avait été modifiée.
|
||||
|
||||
## Baseline Logging de release
|
||||
|
||||
Après les tests `debug`/`trace`, la configuration canonique `0.1.4` revient à :
|
||||
|
||||
```text
|
||||
default_filter = warn
|
||||
console.filter.level = info
|
||||
file.all.info = info/ksp-info.log @ info
|
||||
ksp-config-lib = info
|
||||
ksp-logging-lib = info
|
||||
ksp-app-config-desk = info
|
||||
```
|
||||
|
||||
Pour rouvrir un diagnostic, augmenter temporairement un niveau depuis l’éditeur Logging, utiliser **Sauvegarder et appliquer**, puis restaurer `info`/`warn` avant la prochaine clôture de release.
|
||||
|
||||
## Validation desktop de robustesse
|
||||
|
||||
La validation suit un ordre strict. Depuis la racine du workspace, valider d’abord Rust :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-logging-lib
|
||||
```
|
||||
|
||||
Puis effectuer uniquement le type-check frontend depuis `crates/ksp-app-config-desk` :
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run check` exécute seulement `tsc --noEmit`. Il ne lance pas `vite build` : le build frontend de production est déjà la responsabilité de Tauri via `beforeBuildCommand = npm run build`.
|
||||
|
||||
Revenir ensuite à la racine et effectuer le contrôle fonctionnel :
|
||||
|
||||
```bash
|
||||
cargo tauri dev -c crates/ksp-app-config-desk/tauri.conf.json
|
||||
```
|
||||
|
||||
**Seulement lorsque tous les contrôles précédents sont validés**, exécuter en toute dernière opération :
|
||||
|
||||
```bash
|
||||
cargo tauri build -c crates/ksp-app-config-desk/tauri.conf.json
|
||||
```
|
||||
|
||||
Cette dernière commande déclenche elle-même `npm run build`, donc `tsc && vite build`, avant le build/bundling Tauri. Aucun build Vite séparé n’est nécessaire dans le cycle de validation.
|
||||
|
||||
Un source Logging invalide au démarrage doit sélectionner le fallback transitoire sans persistence automatique. Après réparation du document, un nouveau lancement doit reprendre la configuration managée.
|
||||
12
crates/ksp-app-config-desk/build.rs
Normal file
12
crates/ksp-app-config-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// file: crates/ksp-app-config-desk/build.rs
|
||||
// version: 1
|
||||
|
||||
//! Build script for the KSP configuration desktop application.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
crates/ksp-app-config-desk/capabilities/default.json
Normal file
13
crates/ksp-app-config-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for KSP Config Desk",
|
||||
"windows": [
|
||||
"splash",
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
BIN
crates/ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
31
crates/ksp-app-config-desk/frontend/fonts/README.md
Normal file
31
crates/ksp-app-config-desk/frontend/fonts/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/fonts/README.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Fonts du splash
|
||||
|
||||
Le gabarit de splash repris de `khadhroony-bot3` utilise la police locale suivante :
|
||||
|
||||
```text
|
||||
DOS_Amazigh.ttf
|
||||
font-family: Dos Amazigh
|
||||
```
|
||||
|
||||
Source de référence auditée :
|
||||
|
||||
```text
|
||||
kb-app-demo-desktop/frontend/fonts/DOS_Amazigh.ttf
|
||||
```
|
||||
|
||||
Destination KSP attendue :
|
||||
|
||||
```text
|
||||
crates/ksp-app-config-desk/frontend/fonts/DOS_Amazigh.ttf
|
||||
```
|
||||
|
||||
Empreinte SHA-256 de l'asset bot3 audité :
|
||||
|
||||
```text
|
||||
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
|
||||
```
|
||||
|
||||
Le fichier est un asset tiers/historique du gabarit et doit être repris depuis la copie bot3 possédée par le projet. `splash.scss` déclare désormais explicitement `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`. Le lifecycle visuel complet reste traité dans la tranche splash dédiée.
|
||||
BIN
crates/ksp-app-config-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-config-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-config-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-config-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
651
crates/ksp-app-config-desk/frontend/main.html
Normal file
651
crates/ksp-app-config-desk/frontend/main.html
Normal file
@@ -0,0 +1,651 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
|
||||
<!-- version: 16 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<link rel="icon" href="imgs/logo.png">
|
||||
<link rel="stylesheet" href="sass/main.scss">
|
||||
<title>Config Desk — Vue d'ensemble</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||
<div class="container-fluid px-4 flex-nowrap">
|
||||
<div class="navbar-brand d-flex align-items-center me-4 flex-nowrap">
|
||||
<img alt="KSP" src="imgs/logo.png" class="app-logo">
|
||||
<span class="ps-2 fs-4 fw-semibold text-primary text-nowrap">Config Desk</span>
|
||||
<span class="mx-2 fs-5 text-body-secondary" aria-hidden="true">—</span>
|
||||
<span id="headerViewTitle" class="fs-5 text-body text-nowrap">Vue d'ensemble</span>
|
||||
</div>
|
||||
<div class="app-nav nav nav-pills flex-nowrap gap-1 ms-auto" aria-label="Navigation Config Desk">
|
||||
<button class="nav-link app-nav-button active" type="button" data-view="overview">Vue d'ensemble</button>
|
||||
<button class="nav-link app-nav-button" type="button" data-view="documents">Documents</button>
|
||||
<button class="nav-link app-nav-button" type="button" data-view="profiles">Profils</button>
|
||||
<button class="nav-link app-nav-button" type="button" data-view="environment">Environnement / .env</button>
|
||||
<button class="nav-link app-nav-button" type="button" data-view="logging">Logging</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="app-scrollable" data-simplebar>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<h1 id="viewTitle" class="h3 mb-2">Vue d'ensemble</h1>
|
||||
<p id="viewDescription" class="text-body-secondary mb-4">État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.</p>
|
||||
|
||||
<section id="overviewPanel" aria-label="Vue d'ensemble">
|
||||
<dl class="row mb-4 app-overview-list">
|
||||
<dt class="col-sm-5">Version application</dt>
|
||||
<dd id="overviewVersion" class="col-sm-7 text-sm-end">—</dd>
|
||||
<dt class="col-sm-5">Descripteurs Config enregistrés</dt>
|
||||
<dd id="overviewDocuments" class="col-sm-7 text-sm-end">—</dd>
|
||||
<dt class="col-sm-5">Profil Logging actif</dt>
|
||||
<dd id="overviewLoggingProfile" class="col-sm-7 text-sm-end">—</dd>
|
||||
<dt class="col-sm-5">Génération Logging</dt>
|
||||
<dd id="overviewLoggingGeneration" class="col-sm-7 text-sm-end">—</dd>
|
||||
<dt class="col-sm-5 mb-0">Fallback Logging actif</dt>
|
||||
<dd id="overviewLoggingFallback" class="col-sm-7 text-sm-end mb-0">—</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="documentsPanel" hidden aria-label="Documents Config">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Documents enregistrés</h2>
|
||||
<p class="text-body-secondary small mb-0">Le registre, la validation et la persistence restent exclusivement sous contrôle de ksp-config-lib.</p>
|
||||
</div>
|
||||
<button id="refreshDocuments" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table id="configDocumentsTable" class="table table-striped table-hover align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">file_id</th>
|
||||
<th scope="col">Fichier</th>
|
||||
<th scope="col">Schema</th>
|
||||
<th scope="col">État</th>
|
||||
<th scope="col">Étape</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section id="documentDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du document Config">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Détail du document</h2>
|
||||
<span id="documentDetailStatus" class="badge text-bg-secondary">—</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="openSpecializedEditor" class="btn btn-outline-primary btn-sm" type="button" hidden>Ouvrir l'éditeur spécialisé</button>
|
||||
<button id="reloadDocumentSource" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
|
||||
<button id="saveDocumentSource" class="btn btn-primary btn-sm" type="button">Valider et sauvegarder</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="row small mb-3 app-document-metadata">
|
||||
<dt class="col-lg-3">file_id</dt>
|
||||
<dd id="documentDetailFileId" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Fichier mappé</dt>
|
||||
<dd id="documentDetailFilename" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Schema</dt>
|
||||
<dd id="documentDetailSchema" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Path résolu</dt>
|
||||
<dd id="documentDetailPath" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Étape diagnostic</dt>
|
||||
<dd id="documentDetailStage" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3 mb-0">Diagnostic</dt>
|
||||
<dd id="documentDetailDiagnostic" class="col-lg-9 mb-0">—</dd>
|
||||
</dl>
|
||||
<label class="form-label fw-semibold" for="documentSourceEditor">Source brut Config</label>
|
||||
<textarea id="documentSourceEditor" class="form-control app-document-source" rows="18" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="form-text">La sauvegarde est refusée tant que le candidat échoue au parsing JSON, au schema ou aux invariants sémantiques backend.</div>
|
||||
</section>
|
||||
|
||||
<div id="documentsStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement de l'inventaire Config...</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="profilesPanel" hidden aria-label="Profils Config">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-end justify-content-between mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h2 class="h5 mb-2">Inspection des profils</h2>
|
||||
<p class="text-body-secondary small mb-3">La sélection, la fusion global/profil et la résolution environnementale restent sous contrôle de ksp-config-lib.</p>
|
||||
<div class="row g-2">
|
||||
<div class="col-xl-6">
|
||||
<label class="form-label" for="profileDocumentSelect">Document Config</label>
|
||||
<select id="profileDocumentSelect" class="form-select" disabled></select>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<label class="form-label" for="profileIdSelect">Profil inspecté</label>
|
||||
<select id="profileIdSelect" class="form-select" disabled></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button id="inspectDefaultProfile" class="btn btn-outline-secondary btn-sm" type="button">Profil par défaut</button>
|
||||
<button id="reloadProfileDetail" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
|
||||
<button id="refreshProfiles" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="profileDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du profil Config">
|
||||
<dl class="row small mb-4 app-profile-metadata">
|
||||
<dt class="col-lg-3">file_id</dt>
|
||||
<dd id="profileDetailFileId" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Path résolu</dt>
|
||||
<dd id="profileDetailPath" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">default_profile</dt>
|
||||
<dd id="profileDetailDefault" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Profil sélectionné</dt>
|
||||
<dd id="profileDetailSelected" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Source de sélection</dt>
|
||||
<dd id="profileDetailSelectionSource" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3 mb-0">Sensibilité effective</dt>
|
||||
<dd id="profileDetailSensitivity" class="col-lg-9 mb-0">—</dd>
|
||||
</dl>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-xxl-4">
|
||||
<h3 class="h6">Globals source</h3>
|
||||
<pre id="profileGlobalsJson" class="app-profile-json mb-0"></pre>
|
||||
</div>
|
||||
<div class="col-xxl-4">
|
||||
<h3 class="h6">Profil source</h3>
|
||||
<pre id="profileSourceJson" class="app-profile-json mb-0"></pre>
|
||||
</div>
|
||||
<div class="col-xxl-4">
|
||||
<h3 class="h6">Effective sûre</h3>
|
||||
<pre id="profileEffectiveJson" class="app-profile-json mb-0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-5">
|
||||
<h3 class="h6">Provenance top-level</h3>
|
||||
<div class="table-responsive">
|
||||
<table id="profileOriginsTable" class="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr><th scope="col">Clé</th><th scope="col">Origine</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-7">
|
||||
<h3 class="h6">Provenance environnement</h3>
|
||||
<div class="table-responsive">
|
||||
<table id="profileEnvironmentProvenanceTable" class="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr><th scope="col">JSON Pointer</th><th scope="col">Variable</th><th scope="col">Source</th><th scope="col">Sensibilité</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3">L'effective affichée utilise exclusivement la représentation sûre/redacted de Config ; aucune valeur réelle Secret n'est projetée.</div>
|
||||
</section>
|
||||
|
||||
<div id="profilesStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement des profils Config...</div>
|
||||
</section>
|
||||
|
||||
<section id="environmentPanel" hidden aria-label="Environnement Config">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Rapport Environnement / .env</h2>
|
||||
<p class="text-body-secondary small mb-0">Valeurs desired/effective sûres, provenance externe, shadowing et mutations persistées exclusivement par ksp-config-lib.</p>
|
||||
</div>
|
||||
<button id="refreshEnvironment" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border small" role="note">
|
||||
Priorité effective : <code>process > .env > fallback</code>. Une mutation ne modifie jamais l'environnement process déjà hérité. Les valeurs Secret restent redacted dans le rapport.
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table id="environmentReportTable" class="table table-striped table-hover align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Variable</th>
|
||||
<th scope="col">Namespace</th>
|
||||
<th scope="col">Sensibilité</th>
|
||||
<th scope="col">Desired .env (safe)</th>
|
||||
<th scope="col">Effective (safe)</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Shadowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="environmentStatus" class="alert alert-primary mb-4" role="status" aria-live="polite">Chargement du rapport environnement...</div>
|
||||
|
||||
<section class="border rounded p-3 bg-body-tertiary" aria-label="Management .env">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Management / test .env</h2>
|
||||
<p class="text-body-secondary small mb-0">Cliquer une ligne du rapport sélectionne son nom uniquement. Aucune ancienne valeur réelle n'est préchargée dans le formulaire.</p>
|
||||
</div>
|
||||
<button id="clearEnvironmentEditor" class="btn btn-outline-secondary btn-sm" type="button">Vider le formulaire</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 align-items-end mb-3">
|
||||
<div class="col-xl-5">
|
||||
<label class="form-label" for="environmentVariableName">Variable KSP/KSPB</label>
|
||||
<input id="environmentVariableName" class="form-control font-monospace" type="text" list="environmentVariableSuggestions" autocomplete="off" placeholder="KSP_... ou KSPB_...">
|
||||
<datalist id="environmentVariableSuggestions"></datalist>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<label class="form-label" for="environmentVariableValue">Nouvelle valeur</label>
|
||||
<input id="environmentVariableValue" class="form-control font-monospace" type="text" autocomplete="new-password" spellcheck="false">
|
||||
<div id="environmentEditorSensitivity" class="form-text">La valeur saisie n'est jamais préchargée depuis .env.</div>
|
||||
</div>
|
||||
<div class="col-xl-2 d-grid gap-2">
|
||||
<button id="setEnvironmentValue" class="btn btn-primary btn-sm" type="button">Créer / Modifier</button>
|
||||
<button id="removeEnvironmentValue" class="btn btn-outline-danger btn-sm" type="button">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||
<span class="small text-body-secondary me-1">Reveal Secret privilégié :</span>
|
||||
<button id="revealEnvironmentEffective" class="btn btn-outline-warning btn-sm" type="button" disabled>Révéler effective</button>
|
||||
<button id="revealEnvironmentDotenv" class="btn btn-outline-warning btn-sm" type="button" disabled>Révéler .env</button>
|
||||
<span class="form-text mb-0">Disponible uniquement pour KSP_SECRET_* / KSPB_SECRET_* et jamais préchargé automatiquement.</span>
|
||||
</div>
|
||||
|
||||
<div id="environmentMutationStatus" class="alert alert-secondary mb-3" role="status" aria-live="polite">
|
||||
Aucune mutation effectuée pendant ce lancement.
|
||||
</div>
|
||||
|
||||
<div id="environmentMutationResult" hidden>
|
||||
<h3 class="h6">Dernier résultat ConfigEnvironmentChangeReport</h3>
|
||||
<dl class="row small mb-0 app-profile-metadata">
|
||||
<dt class="col-md-4">Opération</dt>
|
||||
<dd id="environmentMutationOperation" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Variable</dt>
|
||||
<dd id="environmentMutationVariable" class="col-md-8 font-monospace">—</dd>
|
||||
<dt class="col-md-4">source_changed</dt>
|
||||
<dd id="environmentMutationSourceChanged" class="col-md-8 font-monospace">—</dd>
|
||||
<dt class="col-md-4">effective_changed</dt>
|
||||
<dd id="environmentMutationEffectiveChanged" class="col-md-8 font-monospace">—</dd>
|
||||
<dt class="col-md-4">shadowed_by_process</dt>
|
||||
<dd id="environmentMutationShadowed" class="col-md-8 font-monospace">—</dd>
|
||||
<dt class="col-md-4 mb-0">reload_required</dt>
|
||||
<dd id="environmentMutationReload" class="col-md-8 mb-0 font-monospace">—</dd>
|
||||
</dl>
|
||||
<div class="form-text mt-2">Le rapport est rechargé après chaque mutation. Un consumer qui conserve son propre snapshot doit respecter <code>reload_required</code>.</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="loggingPanel" hidden aria-label="Logging Config">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Logging editor — mutations/persistence/hot reload</h2>
|
||||
<p class="text-body-secondary small mb-0">Brouillon typé de <code>std.logging.json</code>, validé et persisté atomiquement par <code>ksp-config-lib</code>. <strong>Sauvegarder et appliquer</strong> hot-reload le runtime KSP sans redémarrage ; <strong>Recharger le document</strong> ne touche qu'au brouillon.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="refreshLoggingDocument" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Recharger le document
|
||||
</button>
|
||||
<button id="saveLoggingDocument" class="btn btn-primary btn-sm" type="button" disabled>
|
||||
<i class="fa-solid fa-floppy-disk me-1" aria-hidden="true"></i>
|
||||
Sauvegarder et appliquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="row small mb-3 app-profile-metadata">
|
||||
<dt class="col-lg-3">file_id</dt>
|
||||
<dd id="loggingFileId" class="col-lg-9 font-monospace">—</dd>
|
||||
<dt class="col-lg-3">Path résolu</dt>
|
||||
<dd id="loggingPath" class="col-lg-9 font-monospace text-break">—</dd>
|
||||
<dt class="col-lg-3 mb-0">format_version</dt>
|
||||
<dd id="loggingFormatVersion" class="col-lg-9 mb-0 font-monospace">—</dd>
|
||||
</dl>
|
||||
|
||||
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Runtime Logging actif">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 class="h6 mb-1">Runtime actif</h3>
|
||||
<p class="text-body-secondary small mb-0">Le profil runtime peut être appliqué explicitement sans modifier <code>default_profile</code>. L'identité de lancement reste stable pendant tous les hot reloads du processus.</p>
|
||||
</div>
|
||||
<button id="refreshLoggingRuntime" class="btn btn-outline-secondary btn-sm" type="button">Rafraîchir runtime</button>
|
||||
</div>
|
||||
<dl class="row small mb-3 app-profile-metadata">
|
||||
<dt class="col-xl-3">Profil actif</dt><dd id="loggingRuntimeActiveProfile" class="col-xl-3 font-monospace">—</dd>
|
||||
<dt class="col-xl-3">Source sélection</dt><dd id="loggingRuntimeSelectionSource" class="col-xl-3 font-monospace">—</dd>
|
||||
<dt class="col-xl-3">Génération</dt><dd id="loggingRuntimeGeneration" class="col-xl-3 font-monospace">—</dd>
|
||||
<dt class="col-xl-3">Console</dt><dd id="loggingRuntimeConsole" class="col-xl-3">—</dd>
|
||||
<dt class="col-xl-3">Fallback</dt><dd id="loggingRuntimeFallback" class="col-xl-3">—</dd>
|
||||
<dt class="col-xl-3">Dropped lines</dt><dd id="loggingRuntimeDropped" class="col-xl-3 font-monospace">—</dd>
|
||||
<dt class="col-xl-3">Application</dt><dd id="loggingRuntimeApplicationId" class="col-xl-3 font-monospace">—</dd>
|
||||
<dt class="col-xl-3">Launch timestamp</dt><dd id="loggingRuntimeLaunchTimestamp" class="col-xl-3 font-monospace">—</dd>
|
||||
</dl>
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-xl-8">
|
||||
<label class="form-label" for="loggingRuntimeProfileSelect">Profil persisté à appliquer explicitement</label>
|
||||
<select id="loggingRuntimeProfileSelect" class="form-select" disabled></select>
|
||||
<div class="form-text">Disponible uniquement lorsque le brouillon est synchronisé avec la source persistée.</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<button id="applyLoggingRuntimeProfile" class="btn btn-outline-primary w-100" type="button" disabled>Appliquer ce profil au runtime</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="loggingRuntimeFilesTable" class="table table-sm align-middle mb-0 w-100">
|
||||
<thead><tr><th>output_id</th><th>Répertoire</th><th>Préfixe fichier effectif</th><th>Rotation</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border rounded p-3 mb-4" aria-label="Test Logging">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 class="h6 mb-1">Test Logging — routing contrôlé</h3>
|
||||
<p class="text-body-secondary small mb-0">Émet des événements volontairement journalisés pour vérifier niveaux, targets, domains, sinks et hot reload. Ne colle jamais de Secret dans le message de test.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6">
|
||||
<label class="form-label" for="loggingTestMessage">Message</label>
|
||||
<input id="loggingTestMessage" class="form-control" type="text" maxlength="500" autocomplete="off" value="Config Desk Logging routing test">
|
||||
</div>
|
||||
<div class="col-md-3 col-xl-2">
|
||||
<label class="form-label" for="loggingTestLevel">Niveau</label>
|
||||
<select id="loggingTestLevel" class="form-select">
|
||||
<option value="trace">trace</option><option value="debug">debug</option><option value="info" selected>info</option><option value="warn">warn</option><option value="error">error</option><option value="all">tous</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5 col-xl-4">
|
||||
<label class="form-label" for="loggingTestTarget">Target backend contrôlé</label>
|
||||
<select id="loggingTestTarget" class="form-select font-monospace">
|
||||
<option value="app">ksp-app-config-desk</option>
|
||||
<option value="logging_test" selected>ksp-app-config-desk.logging-test</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="loggingTestDomainMode">Domain</label>
|
||||
<select id="loggingTestDomainMode" class="form-select">
|
||||
<option value="absent">absent</option>
|
||||
<option value="known" selected>connu</option>
|
||||
<option value="custom">personnalisé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="loggingTestKnownDomain">Domain connu</label>
|
||||
<select id="loggingTestKnownDomain" class="form-select font-monospace">
|
||||
<option value="config.logging_test">config.logging_test</option>
|
||||
<option value="config.logging_runtime">config.logging_runtime</option>
|
||||
<option value="frontend">frontend</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="loggingTestCustomDomain">Domain personnalisé</label>
|
||||
<input id="loggingTestCustomDomain" class="form-control font-monospace" type="text" maxlength="80" autocomplete="off" placeholder="custom.routing" disabled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button id="emitLoggingTestBackend" class="btn btn-outline-primary" type="button">
|
||||
<i class="fa-solid fa-terminal me-1" aria-hidden="true"></i>
|
||||
Log backend
|
||||
</button>
|
||||
<button id="emitLoggingTestFrontend" class="btn btn-outline-secondary" type="button">
|
||||
<i class="fa-solid fa-code me-1" aria-hidden="true"></i>
|
||||
Log via bridge frontend
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text mb-3">Le test backend respecte le target/domain choisis. Le bridge frontend conserve volontairement son contrat fixe <code>target=ksp-app-config-desk.frontend.main</code>, <code>domain=frontend</code> et réutilise uniquement le message/niveau.</div>
|
||||
<div id="loggingTestStatus" class="alert alert-secondary mb-0" role="status" aria-live="polite">Aucun événement de test émis pendant ce lancement.</div>
|
||||
</section>
|
||||
|
||||
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Globals Logging">
|
||||
<h3 class="h6 mb-3">Globals</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-7">
|
||||
<label class="form-label" for="loggingLogsDirectoryInput">logs_directory</label>
|
||||
<input id="loggingLogsDirectoryInput" class="form-control font-monospace" type="text" autocomplete="off" disabled>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<label class="form-label" for="loggingDefaultProfileSelect">default_profile</label>
|
||||
<select id="loggingDefaultProfileSelect" class="form-select" disabled></select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border rounded p-3 mb-4" aria-label="Gestion des profils Logging">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-xl-4">
|
||||
<label class="form-label" for="loggingProfileSelect">Profil édité</label>
|
||||
<select id="loggingProfileSelect" class="form-select" disabled></select>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<label class="form-label" for="loggingProfileIdInput">ID cible create/clone/rename</label>
|
||||
<input id="loggingProfileIdInput" class="form-control font-monospace" type="text" autocomplete="off" placeholder="local_test" disabled>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button id="createLoggingProfile" class="btn btn-outline-primary btn-sm" type="button" disabled>Créer</button>
|
||||
<button id="cloneLoggingProfile" class="btn btn-outline-primary btn-sm" type="button" disabled>Cloner</button>
|
||||
<button id="renameLoggingProfile" class="btn btn-outline-secondary btn-sm" type="button" disabled>Renommer</button>
|
||||
<button id="deleteLoggingProfile" class="btn btn-outline-danger btn-sm" type="button" disabled>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-xl-6">
|
||||
<label class="form-label" for="loggingProfileDefaultFilter">default_filter</label>
|
||||
<select id="loggingProfileDefaultFilter" class="form-select" disabled>
|
||||
<option value="off">off</option><option value="error">error</option><option value="warn">warn</option><option value="info">info</option><option value="debug">debug</option><option value="trace">trace</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<label class="form-label" for="loggingProfileSpanEvents">span_events</label>
|
||||
<select id="loggingProfileSpanEvents" class="form-select" disabled>
|
||||
<option value="off">off</option><option value="new_and_close">new_and_close</option><option value="full">full</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Console Logging">
|
||||
<h3 class="h6 mb-3">Console</h3>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-2 form-check ms-2">
|
||||
<input id="loggingConsoleEnabled" class="form-check-input" type="checkbox" disabled>
|
||||
<label class="form-check-label" for="loggingConsoleEnabled">Enabled</label>
|
||||
</div>
|
||||
<div class="col-md-2 form-check ms-2">
|
||||
<input id="loggingConsoleAnsi" class="form-check-input" type="checkbox" disabled>
|
||||
<label class="form-check-label" for="loggingConsoleAnsi">ANSI</label>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="loggingConsoleOutput">Output</label>
|
||||
<select id="loggingConsoleOutput" class="form-select" disabled><option value="stdout">stdout</option><option value="stderr">stderr</option></select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="loggingConsoleFormat">Format</label>
|
||||
<select id="loggingConsoleFormat" class="form-select" disabled><option value="human">human</option><option value="compact">compact</option><option value="pretty">pretty</option><option value="json">json</option></select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="loggingConsoleLevel">Level</label>
|
||||
<select id="loggingConsoleLevel" class="form-select" disabled><option value="off">off</option><option value="error">error</option><option value="warn">warn</option><option value="info">info</option><option value="debug">debug</option><option value="trace">trace</option></select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="loggingConsoleTargets">Targets</label>
|
||||
<input id="loggingConsoleTargets" class="form-control font-monospace" type="text" autocomplete="off" disabled>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="loggingConsoleDomains">Domains</label>
|
||||
<input id="loggingConsoleDomains" class="form-control font-monospace" type="text" autocomplete="off" disabled>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4" aria-label="Fichiers Logging">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<h3 class="h6 mb-0">Fichiers</h3>
|
||||
<div class="form-text">0, 1 ou plusieurs sinks persistants. Les suppressions restent dans le brouillon jusqu'à Sauvegarder et appliquer.</div>
|
||||
</div>
|
||||
<button id="addLoggingFile" class="btn btn-outline-primary btn-sm" type="button" disabled>Ajouter un fichier</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="loggingFilesTable" class="table table-sm align-middle w-100">
|
||||
<thead><tr><th>output_id</th><th>On</th><th>Path</th><th>Rotation</th><th>Format</th><th>Level</th><th>Targets</th><th>Domains</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-4" aria-label="Target filters Logging">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
|
||||
<h3 class="h6 mb-0">Target filters globaux</h3>
|
||||
<button id="addLoggingTargetFilter" class="btn btn-outline-primary btn-sm" type="button" disabled>Ajouter un target filter</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="loggingTargetFiltersTable" class="table table-sm align-middle w-100">
|
||||
<thead><tr><th>target_prefix</th><th>Level</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="alert alert-warning small py-2 mb-2" role="note">
|
||||
<strong>Runtime actif :</strong> <strong>Sauvegarder et appliquer</strong> persiste le candidat puis applique son <code>default_profile</code>. <strong>Appliquer ce profil au runtime</strong> sélectionne un profil persisté sans modifier le document. En cas d'échec, l'ancien runtime et sa génération restent actifs. <strong>Recharger le document</strong> resynchronise uniquement le brouillon.
|
||||
</div>
|
||||
<div id="loggingSaveReport" class="small text-body-secondary mb-2">Aucune sauvegarde/application effectuée pendant ce lancement.</div>
|
||||
<div id="loggingStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement du document Logging typé...</div>
|
||||
</section>
|
||||
|
||||
<div class="alert alert-primary mb-0" role="status">
|
||||
<i class="fa-solid fa-desktop me-2" aria-hidden="true"></i>
|
||||
<span id="shellStatus" class="app-shell-status">Initialisation du frontend...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="loggingDiscardDraftModal" class="modal fade" tabindex="-1" aria-labelledby="loggingDiscardDraftTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="loggingDiscardDraftTitle" class="modal-title fs-5">Abandonner le brouillon Logging</h2>
|
||||
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">Des modifications non sauvegardées existent. Recharger le document depuis la source persistée les abandonnera. Cette action ne recharge pas le runtime Logging.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
|
||||
<button id="loggingDiscardDraftConfirm" class="btn btn-danger" type="button">Abandonner et recharger le document</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loggingProfileDeleteModal" class="modal fade" tabindex="-1" aria-labelledby="loggingProfileDeleteTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="loggingProfileDeleteTitle" class="modal-title fs-5">Supprimer le profil du brouillon</h2>
|
||||
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Retirer le profil <code id="loggingProfileDeleteName" class="font-monospace"></code> du brouillon Logging ?</p>
|
||||
<p class="text-body-secondary small mb-0">Le fichier n'est pas modifié tant que Sauvegarder et appliquer n'est pas déclenché.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
|
||||
<button id="loggingProfileDeleteConfirm" class="btn btn-danger" type="button">Retirer du brouillon</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="environmentRemoveConfirmModal" class="modal fade" tabindex="-1" aria-labelledby="environmentRemoveConfirmTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="environmentRemoveConfirmTitle" class="modal-title fs-5">Confirmer la suppression .env</h2>
|
||||
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Annuler"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Supprimer l'entrée <code id="environmentRemoveConfirmVariable" class="font-monospace"></code> de <code>.env</code> ?</p>
|
||||
<p class="text-body-secondary small mb-0">L'environnement process déjà hérité ne sera pas modifié.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Annuler</button>
|
||||
<button id="environmentRemoveConfirmAction" class="btn btn-danger" type="button">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="secretRevealModal" class="modal fade" tabindex="-1" aria-labelledby="secretRevealModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="secretRevealModalLabel" class="modal-title fs-5">Reveal Secret privilégié</h2>
|
||||
<button class="btn-close" type="button" data-bs-dismiss="modal" aria-label="Fermer"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="secretRevealConfirmPanel">
|
||||
<p class="mb-2">Variable : <code id="secretRevealVariable" class="font-monospace"></code></p>
|
||||
<p class="mb-3">Source demandée : <code id="secretRevealSource"></code></p>
|
||||
<div class="alert alert-warning small mb-3" role="note">
|
||||
Cette action expose temporairement la valeur réelle dans la WebView. Elle n'est ni journalisée, ni ajoutée à AppState, ni persistée par le frontend.
|
||||
</div>
|
||||
</div>
|
||||
<div id="secretRevealResultPanel" hidden>
|
||||
<label class="form-label fw-semibold" for="secretRevealValue">Valeur réelle transitoire</label>
|
||||
<div class="input-group">
|
||||
<input id="secretRevealValue" class="form-control font-monospace" type="password" readonly autocomplete="off" spellcheck="false">
|
||||
<button id="secretRevealToggleVisibility" class="btn btn-outline-secondary" type="button">Afficher</button>
|
||||
</div>
|
||||
<div id="secretRevealValueAbsent" class="alert alert-secondary small mb-0" hidden>Aucune valeur n'existe dans cette source.</div>
|
||||
</div>
|
||||
<p id="secretRevealStatus" class="text-body-secondary small mt-3 mb-0" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Fermer</button>
|
||||
<button id="secretRevealConfirmAction" class="btn btn-warning" type="button">Révéler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center justify-content-center">
|
||||
<small>© 2026 SASEDEV</small>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="ts/main.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
120
crates/ksp-app-config-desk/frontend/sass/_app.scss
Normal file
120
crates/ksp-app-config-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,120 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
|
||||
// version: 5
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: $gray-100;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: $app-header-height;
|
||||
z-index: 1020;
|
||||
border-bottom: 2px solid rgba($primary, 0.3);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
height: $app-footer-height;
|
||||
z-index: 1020;
|
||||
border-top: 2px solid rgba($primary, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
position: relative;
|
||||
height: calc(100vh - $app-header-height - $app-footer-height);
|
||||
margin-top: $app-header-height;
|
||||
margin-bottom: $app-footer-height;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-scrollable {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.app-shell-card {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-shell-status {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.app-nav-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-overview-list dd {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-metadata dd,
|
||||
.app-document-source,
|
||||
#configDocumentsTable td:first-child,
|
||||
#configDocumentsTable td:nth-child(2),
|
||||
#configDocumentsTable td:nth-child(3) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-source {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
#configDocumentsTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.app-profile-metadata dd,
|
||||
.app-profile-json,
|
||||
#profileOriginsTable td:first-child,
|
||||
#profileEnvironmentProvenanceTable td:first-child,
|
||||
#profileEnvironmentProvenanceTable td:nth-child(2) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-profile-json {
|
||||
min-height: 280px;
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
padding: 0.875rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-body-bg);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
#environmentReportTable td:first-child,
|
||||
#environmentReportTable td:nth-child(4),
|
||||
#environmentReportTable td:nth-child(5) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
160
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_bootswatch.scss
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
|
||||
// Variables
|
||||
|
||||
// Buttons
|
||||
|
||||
.btn {
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:active:focus,
|
||||
&.active:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
color: $gray-900;
|
||||
background-color: $white;
|
||||
border-color: #ccc;
|
||||
|
||||
&:hover {
|
||||
color: $gray-900;
|
||||
background-color: $gray-300;
|
||||
border-color: $gray-500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: tint-color($gray-900, 5%);
|
||||
background-color: $white;
|
||||
border-color: tint-color(#ccc, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&-primary:focus {
|
||||
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||
}
|
||||
|
||||
&-secondary:focus {
|
||||
box-shadow: 0 0 5px $gray-400;
|
||||
}
|
||||
|
||||
&-success:focus {
|
||||
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||
}
|
||||
|
||||
&-info:focus {
|
||||
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||
}
|
||||
|
||||
&-warning:focus {
|
||||
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||
}
|
||||
|
||||
&-danger:focus {
|
||||
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||
}
|
||||
|
||||
&.disabled:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
|
||||
.table .thead-dark th {
|
||||
background-color: $secondary;
|
||||
border-color: $table-border-color;
|
||||
}
|
||||
|
||||
.table-primary,
|
||||
.table-secondary,
|
||||
.table-success,
|
||||
.table-warning,
|
||||
.table-danger,
|
||||
.table-info,
|
||||
.table-light {
|
||||
--#{$prefix}table-color: #{$body-color};
|
||||
}
|
||||
|
||||
// Forms
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||
}
|
||||
|
||||
// Navs
|
||||
|
||||
.nav-tabs {
|
||||
|
||||
.nav-link,
|
||||
.nav-link.active {
|
||||
border-width: 0 0 1px;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active,
|
||||
.nav-link.active:hover,
|
||||
.nav-link.active:focus {
|
||||
border-bottom: 1px solid $primary;
|
||||
}
|
||||
|
||||
.nav-item+.nav-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
&-item.active {
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
// Indicators
|
||||
|
||||
.badge {
|
||||
&.bg-light {
|
||||
color: $dark;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bars
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
// Containers
|
||||
|
||||
.list-group {
|
||||
&-item {
|
||||
color: rgba(255, 255, 255, .8);
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 700;
|
||||
|
||||
&:hover {
|
||||
background-color: $list-group-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled:hover {
|
||||
color: $list-group-disabled-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
Normal file
19
crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_fontawesome.scss
|
||||
// version: 2
|
||||
|
||||
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||
// $font-path: '../webfonts',
|
||||
//);
|
||||
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// use fonts from @fortawesome/fontawesome-free
|
||||
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||
);
|
||||
// load Font Awesome core
|
||||
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||
|
||||
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||
248
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_simplebar.scss
|
||||
// version: 2
|
||||
|
||||
/* Rtl support */
|
||||
[data-simplebar] {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.simplebar-wrapper {
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
max-width: inherit;
|
||||
max-height: inherit;
|
||||
}
|
||||
|
||||
.simplebar-mask {
|
||||
direction: inherit;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
// z-index: 0;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-offset {
|
||||
direction: inherit !important;
|
||||
box-sizing: inherit !important;
|
||||
resize: none !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-content-wrapper {
|
||||
direction: inherit;
|
||||
box-sizing: border-box !important;
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hide-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.simplebar-content {
|
||||
&:before {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-placeholder {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer-wrapper {
|
||||
box-sizing: inherit !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1px;
|
||||
position: relative;
|
||||
float: left;
|
||||
max-height: 1px;
|
||||
overflow: hidden;
|
||||
// z-index: -1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
flex-grow: inherit;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer {
|
||||
box-sizing: inherit;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 1000%;
|
||||
width: 1000%;
|
||||
min-height: 1px;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
// z-index: -1;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
// z-index: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-simplebar].simplebar-dragging {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.simplebar-content {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: 10px;
|
||||
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
background: black;
|
||||
border-radius: 7px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s 0.5s linear;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar.simplebar-visible {
|
||||
&:before {
|
||||
opacity: 0.5;
|
||||
transition-delay: 0s;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 0;
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
left: 0;
|
||||
height: 11px;
|
||||
|
||||
.simplebar-scrollbar {
|
||||
right: auto;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-height: 0;
|
||||
min-width: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-simplebar-direction='rtl'] {
|
||||
.simplebar-track.simplebar-vertical {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-dummy-scrollbar-size {
|
||||
direction: rtl;
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
height: 500px;
|
||||
width: 500px;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: scrollbar !important;
|
||||
|
||||
>div {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
95
crates/ksp-app-config-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-config-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_variables.scss
|
||||
// version: 2
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
$theme: "pulse" !default;
|
||||
|
||||
//
|
||||
// Color system
|
||||
//
|
||||
|
||||
$white: #fff !default;
|
||||
$gray-100: #fafafa !default;
|
||||
$gray-200: #f9f8fc !default;
|
||||
$gray-300: #ededed !default;
|
||||
$gray-400: #cbc8d0 !default;
|
||||
$gray-500: #adb5bd !default;
|
||||
$gray-600: #868e96 !default;
|
||||
$gray-700: #444 !default;
|
||||
$gray-800: #343a40 !default;
|
||||
$gray-900: #17141f !default;
|
||||
$black: #000 !default;
|
||||
|
||||
$blue: #007bff !default;
|
||||
$indigo: #6610f2 !default;
|
||||
$purple: #593196 !default;
|
||||
$pink: #e83e8c !default;
|
||||
$red: #fc3939 !default;
|
||||
$orange: #fd7e14 !default;
|
||||
$yellow: #efa31d !default;
|
||||
$green: #13b955 !default;
|
||||
$teal: #20c997 !default;
|
||||
$cyan: #009cdc !default;
|
||||
|
||||
$primary: $purple !default;
|
||||
$secondary: #a991d4 !default;
|
||||
$success: $green !default;
|
||||
$info: $cyan !default;
|
||||
$warning: $yellow !default;
|
||||
$danger: $red !default;
|
||||
$light: $gray-200 !default;
|
||||
$dark: $gray-900 !default;
|
||||
|
||||
$min-contrast-ratio: 2.1 !default;
|
||||
|
||||
// Options
|
||||
|
||||
$enable-rounded: false !default;
|
||||
|
||||
// Body
|
||||
|
||||
$body-color: $gray-700 !default;
|
||||
|
||||
// Links
|
||||
|
||||
$link-hover-color: $primary !default;
|
||||
|
||||
// Tables
|
||||
|
||||
$table-color: initial !default;
|
||||
|
||||
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||
|
||||
// Forms
|
||||
|
||||
$input-focus-border-color: $primary !default;
|
||||
|
||||
// Dropdowns
|
||||
|
||||
$dropdown-link-hover-color: $white !default;
|
||||
$dropdown-link-hover-bg: $primary !default;
|
||||
|
||||
// Navs
|
||||
|
||||
$nav-tabs-border-color: $gray-300 !default;
|
||||
$nav-tabs-link-hover-border-color: $primary !default;
|
||||
|
||||
// Navbar
|
||||
|
||||
$navbar-padding-y: 1.2rem !default;
|
||||
|
||||
// Progress bars
|
||||
|
||||
$progress-bg: $gray-300 !default;
|
||||
$progress-bar-bg: $primary !default;
|
||||
|
||||
// List group
|
||||
|
||||
$list-group-bg: $gray-900 !default;
|
||||
$list-group-border-color: transparent !default;
|
||||
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||
$list-group-active-color: $white !default;
|
||||
$list-group-active-bg: $list-group-bg !default;
|
||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||
10
crates/ksp-app-config-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-config-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/main.scss
|
||||
// version: 1
|
||||
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "variables";
|
||||
@import "fontawesome";
|
||||
@import "simplebar";
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
@import "bootswatch";
|
||||
@import "app";
|
||||
65
crates/ksp-app-config-desk/frontend/sass/splash.scss
Normal file
65
crates/ksp-app-config-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/splash.scss
|
||||
// version: 3
|
||||
|
||||
@font-face {
|
||||
font-family: "Dos Amazigh";
|
||||
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#splash-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
height: 637px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#splash-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#app-name {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-family: "Dos Amazigh", sans-serif;
|
||||
font-size: 76px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#splash-status {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 3;
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
22
crates/ksp-app-config-desk/frontend/splash.html
Normal file
22
crates/ksp-app-config-desk/frontend/splash.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||
<!-- version: 1 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<link rel="stylesheet" href="sass/splash.scss">
|
||||
<title>KSP Config Desk — Chargement</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="splash-container">
|
||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
||||
<div id="app-name">KSP</div>
|
||||
<div id="splash-status" aria-live="polite">Chargement du shell frontend...</div>
|
||||
</div>
|
||||
<script type="module" src="ts/splash.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
295
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
295
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/documents.ts
|
||||
// version: 2
|
||||
|
||||
//! Documents panel backed exclusively by ksp-config-lib inventory, validation and management APIs.
|
||||
|
||||
import DataTable from "datatables.net-bs5";
|
||||
import "datatables.net-select-bs5";
|
||||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||||
import "datatables.net-select-bs5/css/select.bootstrap5.css";
|
||||
import type { ConfigDocumentDetailDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto";
|
||||
import type { ConfigDocumentErrorDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto";
|
||||
import type { ConfigDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto";
|
||||
import type { ConfigDocumentSummaryDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto";
|
||||
import { frontendDebug, frontendTrace, frontendWarn } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
import { requestViewActivation, specializedEditorForFileId } from "./shell_registry";
|
||||
|
||||
interface DocumentsDataTable {
|
||||
destroy(): unknown;
|
||||
on(event: string, callback: (event: unknown, api: unknown, type: string, indexes: number[]) => void): unknown;
|
||||
row(selector: number): { node(): Node | null };
|
||||
}
|
||||
|
||||
let documentsTable: DocumentsDataTable | null = null;
|
||||
let activeDocument: ConfigDocumentDetailDto | null = null;
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
valid: "Valide",
|
||||
read: "Lecture",
|
||||
json: "JSON",
|
||||
schema: "Schema",
|
||||
semantic: "Sémantique",
|
||||
effective: "Effective",
|
||||
other: "Autre",
|
||||
};
|
||||
|
||||
function stageLabel(stage: string): string {
|
||||
return stageLabels[stage] ?? stage;
|
||||
}
|
||||
|
||||
function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#documentsStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Documents panel status replaced", { tone });
|
||||
}
|
||||
|
||||
function diagnosticMessage(summary: ConfigDocumentSummaryDto): string {
|
||||
if (!summary.diagnostic) {
|
||||
return "Aucun diagnostic backend.";
|
||||
}
|
||||
return `${summary.diagnostic.domain}.${summary.diagnostic.code} — ${summary.diagnostic.message}`;
|
||||
}
|
||||
|
||||
function renderDetail(detail: ConfigDocumentDetailDto): void {
|
||||
activeDocument = detail;
|
||||
setText("#documentDetailFileId", detail.summary.fileId);
|
||||
setText("#documentDetailFilename", detail.summary.filename);
|
||||
setText("#documentDetailSchema", detail.summary.schemaFileId ?? "—");
|
||||
setText("#documentDetailPath", detail.summary.path);
|
||||
setText("#documentDetailStage", stageLabel(detail.summary.diagnosticStage));
|
||||
setText("#documentDetailDiagnostic", diagnosticMessage(detail.summary));
|
||||
const badge = document.querySelector<HTMLElement>("#documentDetailStatus");
|
||||
if (badge) {
|
||||
const valid = detail.summary.validationStatus === "valid";
|
||||
badge.className = `badge ${valid ? "text-bg-success" : "text-bg-danger"}`;
|
||||
badge.textContent = valid ? "Valide" : "Invalide";
|
||||
}
|
||||
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
|
||||
if (editor) {
|
||||
editor.value = detail.source ?? "";
|
||||
editor.disabled = detail.source === null;
|
||||
}
|
||||
const saveButton = document.querySelector<HTMLButtonElement>("#saveDocumentSource");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = detail.source === null;
|
||||
}
|
||||
const specializedEditor = specializedEditorForFileId(detail.summary.fileId);
|
||||
const specializedButton = document.querySelector<HTMLButtonElement>("#openSpecializedEditor");
|
||||
if (specializedButton) {
|
||||
specializedButton.hidden = specializedEditor === null;
|
||||
specializedButton.textContent = specializedEditor?.label ?? "Ouvrir l'éditeur spécialisé";
|
||||
}
|
||||
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = false;
|
||||
}
|
||||
frontendTrace("main", "Config document detail rendered", {
|
||||
fileId: detail.summary.fileId,
|
||||
validationStatus: detail.summary.validationStatus,
|
||||
diagnosticStage: detail.summary.diagnosticStage,
|
||||
sourceAvailable: detail.source !== null,
|
||||
});
|
||||
}
|
||||
|
||||
function clearDetail(): void {
|
||||
activeDocument = null;
|
||||
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = true;
|
||||
}
|
||||
frontendTrace("main", "Config document detail cleared");
|
||||
}
|
||||
|
||||
async function loadDetail(fileId: string, source: "selection" | "reload"): Promise<void> {
|
||||
frontendDebug("main", "Config document detail requested", { fileId, source });
|
||||
try {
|
||||
const detail = await invokeKsp<ConfigDocumentDetailDto>("main", "get_config_document_detail", { fileId });
|
||||
renderDetail(detail);
|
||||
setDocumentsStatus(`Document ${fileId} chargé depuis Config.`, "success");
|
||||
} catch (caughtError) {
|
||||
const error = asDocumentError(caughtError);
|
||||
const message = error
|
||||
? `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}`
|
||||
: "Le détail du document n'a pas pu être chargé.";
|
||||
setDocumentsStatus(message, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function populateTableRows(documents: ConfigDocumentSummaryDto[]): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#configDocumentsTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const summary of documents) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.fileId = summary.fileId;
|
||||
appendCell(row, summary.fileId);
|
||||
appendCell(row, summary.filename);
|
||||
appendCell(row, summary.schemaFileId ?? "—");
|
||||
appendCell(row, summary.validationStatus === "valid" ? "Valide" : "Invalide");
|
||||
appendCell(row, stageLabel(summary.diagnosticStage));
|
||||
body.append(row);
|
||||
}
|
||||
frontendTrace("main", "Config document inventory table DOM replaced", { documentCount: documents.length });
|
||||
}
|
||||
|
||||
function appendCell(row: HTMLTableRowElement, text: string): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = text;
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function initializeDataTable(): void {
|
||||
if (documentsTable) {
|
||||
documentsTable.destroy();
|
||||
documentsTable = null;
|
||||
}
|
||||
const table = new DataTable("#configDocumentsTable", {
|
||||
order: [[0, "asc"]],
|
||||
pageLength: 10,
|
||||
select: { style: "single" },
|
||||
language: {
|
||||
emptyTable: "Aucun document Config enregistré.",
|
||||
info: "_START_ à _END_ sur _TOTAL_ document(s)",
|
||||
infoEmpty: "0 document",
|
||||
lengthMenu: "Afficher _MENU_",
|
||||
search: "Filtrer :",
|
||||
zeroRecords: "Aucun document correspondant.",
|
||||
},
|
||||
}) as unknown as DocumentsDataTable;
|
||||
table.on("select", (event, api, type, indexes) => {
|
||||
void event;
|
||||
void api;
|
||||
if (type !== "row" || indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const node = table.row(indexes[0]).node();
|
||||
if (!(node instanceof HTMLTableRowElement)) {
|
||||
return;
|
||||
}
|
||||
const fileId = node.dataset.fileId;
|
||||
frontendTrace("main", "Config document table row selected", { fileId: fileId ?? null });
|
||||
if (fileId) {
|
||||
void loadDetail(fileId, "selection");
|
||||
}
|
||||
});
|
||||
documentsTable = table;
|
||||
frontendTrace("main", "DataTables Documents initialized", { selection: "single" });
|
||||
}
|
||||
|
||||
async function refreshInventory(): Promise<void> {
|
||||
frontendDebug("main", "Config document inventory refresh requested");
|
||||
clearDetail();
|
||||
try {
|
||||
const documents = await invokeKsp<ConfigDocumentSummaryDto[]>("main", "get_config_documents");
|
||||
populateTableRows(documents);
|
||||
initializeDataTable();
|
||||
setDocumentsStatus(`${documents.length} document(s) Config évalué(s) par le backend.`, "success");
|
||||
} catch {
|
||||
setDocumentsStatus("L'inventaire Config n'a pas pu être chargé.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadActiveDocument(): Promise<void> {
|
||||
if (!activeDocument) {
|
||||
return;
|
||||
}
|
||||
frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId });
|
||||
await loadDetail(activeDocument.summary.fileId, "reload");
|
||||
}
|
||||
|
||||
async function saveActiveDocument(): Promise<void> {
|
||||
if (!activeDocument) {
|
||||
return;
|
||||
}
|
||||
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const fileId = activeDocument.summary.fileId;
|
||||
frontendDebug("main", "Config document validated save clicked", { fileId });
|
||||
setDocumentsStatus(`Validation et sauvegarde de ${fileId} en cours...`, "primary");
|
||||
try {
|
||||
const result = await invokeKsp<ConfigDocumentSaveResultDto>("main", "save_config_document_source", {
|
||||
fileId,
|
||||
source: editor.value,
|
||||
});
|
||||
renderDetail(result.document);
|
||||
const outcome = result.sourceChanged ? "source modifiée" : "source inchangée";
|
||||
setDocumentsStatus(`Sauvegarde validée par Config : ${outcome}; reload_required=${result.reloadRequired}.`, "success");
|
||||
frontendDebug("main", "Config document validated save completed", {
|
||||
fileId,
|
||||
sourceChanged: result.sourceChanged,
|
||||
reloadRequired: result.reloadRequired,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const error = asDocumentError(caughtError);
|
||||
if (error) {
|
||||
setDocumentsStatus(
|
||||
`${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}`,
|
||||
"danger",
|
||||
);
|
||||
frontendWarn("main", "Config document candidate rejected by backend", { fileId, diagnosticStage: error.diagnosticStage });
|
||||
return;
|
||||
}
|
||||
setDocumentsStatus("Le candidat Config a été rejeté sans diagnostic exploitable.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function asDocumentError(value: unknown): ConfigDocumentErrorDto | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) {
|
||||
return null;
|
||||
}
|
||||
const error = candidate.error as Record<string, unknown>;
|
||||
if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") {
|
||||
return null;
|
||||
}
|
||||
return value as ConfigDocumentErrorDto;
|
||||
}
|
||||
|
||||
function bindDocumentActions(): void {
|
||||
document.querySelector<HTMLButtonElement>("#refreshDocuments")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Documents refresh button clicked");
|
||||
void refreshInventory();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#reloadDocumentSource")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Document reload button clicked");
|
||||
void reloadActiveDocument();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#saveDocumentSource")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Document save button clicked");
|
||||
void saveActiveDocument();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#openSpecializedEditor")?.addEventListener("click", () => {
|
||||
const fileId = activeDocument?.summary.fileId ?? "";
|
||||
const descriptor = specializedEditorForFileId(fileId);
|
||||
if (!descriptor) {
|
||||
return;
|
||||
}
|
||||
frontendDebug("main", "Specialized Config editor requested", { fileId, viewId: descriptor.viewId });
|
||||
requestViewActivation(descriptor.viewId);
|
||||
});
|
||||
frontendTrace("main", "Documents panel handlers installed");
|
||||
}
|
||||
|
||||
export function initializeDocumentsPanel(): void {
|
||||
bindDocumentActions();
|
||||
void refreshInventory();
|
||||
}
|
||||
422
crates/ksp-app-config-desk/frontend/ts/environment.ts
Normal file
422
crates/ksp-app-config-desk/frontend/ts/environment.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/environment.ts
|
||||
// version: 4
|
||||
|
||||
//! Safe environment report and `.env` management panel backed exclusively by ConfigManagement.
|
||||
|
||||
import { Modal } from "bootstrap";
|
||||
import DataTable from "datatables.net-bs5";
|
||||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||||
import type { ConfigEnvironmentChangeDto } from "./bindings/ksp_app_config_desk/environment/ConfigEnvironmentChangeDto";
|
||||
import type { ConfigEnvironmentReportDto } from "./bindings/ksp_app_config_desk/environment/ConfigEnvironmentReportDto";
|
||||
import { frontendDebug, frontendTrace } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
import { initializeSecretReveal, requestSecretReveal } from "./secret_reveal";
|
||||
|
||||
interface EnvironmentDataTable {
|
||||
destroy(): unknown;
|
||||
}
|
||||
|
||||
let environmentTable: EnvironmentDataTable | null = null;
|
||||
let currentReports: ConfigEnvironmentReportDto[] = [];
|
||||
let mutationControlsDisabled = false;
|
||||
|
||||
function setEnvironmentStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#environmentStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Environment panel status replaced", { tone });
|
||||
}
|
||||
|
||||
function setMutationStatus(message: string, tone: "secondary" | "success" | "warning" | "danger" = "secondary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#environmentMutationStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Environment mutation status replaced", { tone });
|
||||
}
|
||||
|
||||
function sensitivityLabel(value: string): string {
|
||||
if (value === "public") {
|
||||
return "Public";
|
||||
}
|
||||
if (value === "internal") {
|
||||
return "Internal";
|
||||
}
|
||||
if (value === "secret") {
|
||||
return "Secret";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceLabel(value: string): string {
|
||||
if (value === "process") {
|
||||
return "process";
|
||||
}
|
||||
if (value === "dotenv") {
|
||||
return "dotenv";
|
||||
}
|
||||
if (value === "fallback") {
|
||||
return "fallback";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function safeValue(value: string | null): string {
|
||||
return value ?? "—";
|
||||
}
|
||||
|
||||
function appendCell(row: HTMLTableRowElement, text: string, className?: string): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = text;
|
||||
if (className) {
|
||||
cell.className = className;
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function populateTableRows(reports: ConfigEnvironmentReportDto[]): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#environmentReportTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const report of reports) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.variableName = report.variableName;
|
||||
appendCell(row, report.variableName, "font-monospace");
|
||||
appendCell(row, report.namespace);
|
||||
appendCell(row, sensitivityLabel(report.sensitivity));
|
||||
appendCell(row, safeValue(report.desiredSafeValue), "font-monospace text-break");
|
||||
appendCell(row, safeValue(report.effectiveSafeValue), "font-monospace text-break");
|
||||
appendCell(row, sourceLabel(report.effectiveSource));
|
||||
appendCell(row, report.shadowedByProcessEnvironment ? "oui" : "non");
|
||||
body.append(row);
|
||||
}
|
||||
frontendTrace("main", "Environment report table DOM replaced", { variableCount: reports.length });
|
||||
}
|
||||
|
||||
function populateVariableSuggestions(reports: ConfigEnvironmentReportDto[]): void {
|
||||
const list = document.querySelector<HTMLDataListElement>("#environmentVariableSuggestions");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.replaceChildren();
|
||||
for (const report of reports) {
|
||||
const option = document.createElement("option");
|
||||
option.value = report.variableName;
|
||||
list.append(option);
|
||||
}
|
||||
frontendTrace("main", "Environment variable suggestions replaced", { variableCount: reports.length });
|
||||
}
|
||||
|
||||
function destroyEnvironmentDataTable(): void {
|
||||
if (environmentTable) {
|
||||
environmentTable.destroy();
|
||||
environmentTable = null;
|
||||
frontendTrace("main", "DataTables Environment destroyed before report replacement");
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDataTable(): void {
|
||||
environmentTable = new DataTable("#environmentReportTable", {
|
||||
order: [[0, "asc"]],
|
||||
pageLength: 10,
|
||||
language: {
|
||||
emptyTable: "Aucune variable KSP/KSPB présente dans process ou .env.",
|
||||
info: "_START_ à _END_ sur _TOTAL_ variable(s)",
|
||||
infoEmpty: "0 variable",
|
||||
lengthMenu: "Afficher _MENU_",
|
||||
search: "Filtrer :",
|
||||
zeroRecords: "Aucune variable correspondante.",
|
||||
},
|
||||
}) as unknown as EnvironmentDataTable;
|
||||
frontendTrace("main", "DataTables Environment initialized");
|
||||
}
|
||||
|
||||
function classifyEditorSensitivity(variableName: string): "public" | "internal" | "secret" | "invalid" {
|
||||
if (variableName.startsWith("KSP_SECRET_") || variableName.startsWith("KSPB_SECRET_")) {
|
||||
return "secret";
|
||||
}
|
||||
if (variableName.startsWith("KSP_PUBLIC_") || variableName.startsWith("KSPB_PUBLIC_")) {
|
||||
return "public";
|
||||
}
|
||||
if (variableName.startsWith("KSP_") || variableName.startsWith("KSPB_")) {
|
||||
return "internal";
|
||||
}
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function updateEditorSensitivity(): void {
|
||||
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
|
||||
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
|
||||
const note = document.querySelector<HTMLElement>("#environmentEditorSensitivity");
|
||||
if (!variable || !value || !note) {
|
||||
return;
|
||||
}
|
||||
const sensitivity = classifyEditorSensitivity(variable.value.trim());
|
||||
value.type = sensitivity === "secret" ? "password" : "text";
|
||||
note.textContent = sensitivity === "secret"
|
||||
? "Secret : la nouvelle saisie est masquée et l'ancienne valeur réelle n'est jamais préchargée."
|
||||
: sensitivity === "invalid"
|
||||
? "Saisis un nom KSP_* ou KSPB_* ; Config reste l'autorité de validation."
|
||||
: `${sensitivityLabel(sensitivity)} : la valeur saisie n'est jamais préchargée depuis .env.`;
|
||||
updateRevealButtons(sensitivity);
|
||||
frontendTrace("main", "Environment mutation editor sensitivity updated", { sensitivity });
|
||||
}
|
||||
|
||||
function updateRevealButtons(sensitivity = classifyEditorSensitivity(document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "")): void {
|
||||
const revealDisabled = mutationControlsDisabled || sensitivity !== "secret";
|
||||
document.querySelector<HTMLButtonElement>("#revealEnvironmentEffective")?.toggleAttribute("disabled", revealDisabled);
|
||||
document.querySelector<HTMLButtonElement>("#revealEnvironmentDotenv")?.toggleAttribute("disabled", revealDisabled);
|
||||
}
|
||||
|
||||
function selectVariableForMutation(variableName: string, source: "table" | "input"): void {
|
||||
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
|
||||
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
|
||||
if (!variable || !value) {
|
||||
return;
|
||||
}
|
||||
variable.value = variableName;
|
||||
value.value = "";
|
||||
updateEditorSensitivity();
|
||||
frontendDebug("main", "Environment mutation variable selected", { variableName, source });
|
||||
}
|
||||
|
||||
function renderMutationResult(result: ConfigEnvironmentChangeDto): void {
|
||||
const operation = document.querySelector<HTMLElement>("#environmentMutationOperation");
|
||||
const variable = document.querySelector<HTMLElement>("#environmentMutationVariable");
|
||||
const sourceChanged = document.querySelector<HTMLElement>("#environmentMutationSourceChanged");
|
||||
const effectiveChanged = document.querySelector<HTMLElement>("#environmentMutationEffectiveChanged");
|
||||
const shadowed = document.querySelector<HTMLElement>("#environmentMutationShadowed");
|
||||
const reload = document.querySelector<HTMLElement>("#environmentMutationReload");
|
||||
const panel = document.querySelector<HTMLElement>("#environmentMutationResult");
|
||||
if (operation) {
|
||||
operation.textContent = result.operation === "remove" ? "suppression" : "création / modification";
|
||||
}
|
||||
if (variable) {
|
||||
variable.textContent = result.variableName;
|
||||
}
|
||||
if (sourceChanged) {
|
||||
sourceChanged.textContent = result.sourceChanged ? "true" : "false";
|
||||
}
|
||||
if (effectiveChanged) {
|
||||
effectiveChanged.textContent = result.effectiveChanged ? "true" : "false";
|
||||
}
|
||||
if (shadowed) {
|
||||
shadowed.textContent = result.shadowedByProcessEnvironment ? "true" : "false";
|
||||
}
|
||||
if (reload) {
|
||||
reload.textContent = result.reloadRequired ? "true" : "false";
|
||||
}
|
||||
if (panel) {
|
||||
panel.hidden = false;
|
||||
}
|
||||
frontendTrace("main", "Environment mutation result rendered", {
|
||||
operation: result.operation,
|
||||
variableName: result.variableName,
|
||||
sourceChanged: result.sourceChanged,
|
||||
effectiveChanged: result.effectiveChanged,
|
||||
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
|
||||
reloadRequired: result.reloadRequired,
|
||||
});
|
||||
}
|
||||
|
||||
function setMutationControlsDisabled(disabled: boolean): void {
|
||||
mutationControlsDisabled = disabled;
|
||||
document.querySelector<HTMLButtonElement>("#setEnvironmentValue")?.toggleAttribute("disabled", disabled);
|
||||
document.querySelector<HTMLButtonElement>("#removeEnvironmentValue")?.toggleAttribute("disabled", disabled);
|
||||
updateRevealButtons();
|
||||
}
|
||||
|
||||
function confirmEnvironmentRemoval(variableName: string): Promise<boolean> {
|
||||
const modalElement = document.querySelector<HTMLElement>("#environmentRemoveConfirmModal");
|
||||
const variable = document.querySelector<HTMLElement>("#environmentRemoveConfirmVariable");
|
||||
const confirmButton = document.querySelector<HTMLButtonElement>("#environmentRemoveConfirmAction");
|
||||
if (!modalElement || !variable || !confirmButton) {
|
||||
frontendDebug("main", "Environment removal confirmation modal is unavailable", { variableName });
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
variable.textContent = variableName;
|
||||
const modal = Modal.getOrCreateInstance(modalElement);
|
||||
frontendDebug("main", "Environment .env removal confirmation opened", { variableName });
|
||||
return new Promise<boolean>(resolve => {
|
||||
let confirmed = false;
|
||||
const onConfirm = (): void => {
|
||||
confirmed = true;
|
||||
modal.hide();
|
||||
};
|
||||
const onHidden = (): void => {
|
||||
confirmButton.removeEventListener("click", onConfirm);
|
||||
variable.textContent = "";
|
||||
frontendDebug("main", "Environment .env removal confirmation answered", { variableName, confirmed });
|
||||
resolve(confirmed);
|
||||
};
|
||||
confirmButton.addEventListener("click", onConfirm, { once: true });
|
||||
modalElement.addEventListener("hidden.bs.modal", onHidden, { once: true });
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshEnvironmentReport(): Promise<void> {
|
||||
frontendDebug("main", "Config environment report refresh requested");
|
||||
setEnvironmentStatus("Chargement du rapport environnement sûr...", "primary");
|
||||
try {
|
||||
const reports = await invokeKsp<ConfigEnvironmentReportDto[]>("main", "get_environment_report");
|
||||
currentReports = reports;
|
||||
destroyEnvironmentDataTable();
|
||||
populateTableRows(reports);
|
||||
populateVariableSuggestions(reports);
|
||||
initializeDataTable();
|
||||
const shadowedCount = reports.filter(report => report.shadowedByProcessEnvironment).length;
|
||||
setEnvironmentStatus(`${reports.length} variable(s) KSP/KSPB évaluée(s) par Config ; ${shadowedCount} valeur(s) .env shadowed.`, "success");
|
||||
frontendDebug("main", "Config environment report refresh completed", {
|
||||
variableCount: reports.length,
|
||||
shadowedCount,
|
||||
});
|
||||
} catch {
|
||||
setEnvironmentStatus("Le rapport environnement n'a pas pu être chargé par Config.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnvironmentValue(): Promise<void> {
|
||||
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
|
||||
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
|
||||
if (!variable || !value) {
|
||||
return;
|
||||
}
|
||||
const variableName = variable.value.trim();
|
||||
if (!variableName) {
|
||||
setMutationStatus("Saisis un nom de variable KSP_* ou KSPB_*.", "warning");
|
||||
return;
|
||||
}
|
||||
const sensitivity = classifyEditorSensitivity(variableName);
|
||||
frontendDebug("main", "Environment .env mutation requested", { operation: "set", variableName, sensitivity });
|
||||
setMutationControlsDisabled(true);
|
||||
setMutationStatus("Validation et persistence atomique par Config...", "secondary");
|
||||
try {
|
||||
const result = await invokeKsp<ConfigEnvironmentChangeDto>("main", "set_environment_value", { variableName, value: value.value });
|
||||
renderMutationResult(result);
|
||||
value.value = "";
|
||||
setMutationStatus("Mutation .env acceptée par Config ; rapport rechargé depuis la source persistée.", "success");
|
||||
await refreshEnvironmentReport();
|
||||
frontendDebug("main", "Environment .env mutation completed", {
|
||||
operation: result.operation,
|
||||
variableName: result.variableName,
|
||||
sourceChanged: result.sourceChanged,
|
||||
effectiveChanged: result.effectiveChanged,
|
||||
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
|
||||
reloadRequired: result.reloadRequired,
|
||||
});
|
||||
} catch {
|
||||
setMutationStatus("La mutation .env a été refusée ou n'a pas pu être persistée par Config.", "danger");
|
||||
} finally {
|
||||
setMutationControlsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnvironmentValue(): Promise<void> {
|
||||
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
|
||||
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
|
||||
if (!variable || !value) {
|
||||
return;
|
||||
}
|
||||
const variableName = variable.value.trim();
|
||||
if (!variableName) {
|
||||
setMutationStatus("Saisis ou sélectionne la variable .env à supprimer.", "warning");
|
||||
return;
|
||||
}
|
||||
setMutationControlsDisabled(true);
|
||||
const confirmed = await confirmEnvironmentRemoval(variableName);
|
||||
if (!confirmed) {
|
||||
setMutationControlsDisabled(false);
|
||||
return;
|
||||
}
|
||||
const sensitivity = classifyEditorSensitivity(variableName);
|
||||
frontendDebug("main", "Environment .env mutation requested", { operation: "remove", variableName, sensitivity });
|
||||
setMutationStatus("Suppression atomique par Config...", "secondary");
|
||||
try {
|
||||
const result = await invokeKsp<ConfigEnvironmentChangeDto>("main", "remove_environment_value", { variableName });
|
||||
renderMutationResult(result);
|
||||
value.value = "";
|
||||
setMutationStatus("Suppression .env traitée par Config ; rapport rechargé depuis la source persistée.", "success");
|
||||
await refreshEnvironmentReport();
|
||||
frontendDebug("main", "Environment .env mutation completed", {
|
||||
operation: result.operation,
|
||||
variableName: result.variableName,
|
||||
sourceChanged: result.sourceChanged,
|
||||
effectiveChanged: result.effectiveChanged,
|
||||
shadowedByProcessEnvironment: result.shadowedByProcessEnvironment,
|
||||
reloadRequired: result.reloadRequired,
|
||||
});
|
||||
} catch {
|
||||
setMutationStatus("La suppression .env a été refusée ou n'a pas pu être persistée par Config.", "danger");
|
||||
} finally {
|
||||
setMutationControlsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEnvironmentActions(): void {
|
||||
document.querySelector<HTMLButtonElement>("#refreshEnvironment")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Environment refresh button clicked");
|
||||
void refreshEnvironmentReport();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#environmentVariableName")?.addEventListener("input", () => {
|
||||
updateEditorSensitivity();
|
||||
});
|
||||
document.querySelector<HTMLInputElement>("#environmentVariableName")?.addEventListener("change", event => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const exact = currentReports.find(report => report.variableName === input.value.trim());
|
||||
if (exact) {
|
||||
selectVariableForMutation(exact.variableName, "input");
|
||||
}
|
||||
});
|
||||
document.querySelector<HTMLTableElement>("#environmentReportTable")?.addEventListener("click", event => {
|
||||
const target = event.target as HTMLElement;
|
||||
const row = target.closest<HTMLTableRowElement>("tbody tr");
|
||||
const variableName = row?.dataset.variableName;
|
||||
if (variableName) {
|
||||
selectVariableForMutation(variableName, "table");
|
||||
}
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#setEnvironmentValue")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Environment set value button clicked");
|
||||
void setEnvironmentValue();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#removeEnvironmentValue")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Environment remove value button clicked");
|
||||
void removeEnvironmentValue();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#revealEnvironmentEffective")?.addEventListener("click", () => {
|
||||
const variableName = document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "";
|
||||
requestSecretReveal(variableName, "effective");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#revealEnvironmentDotenv")?.addEventListener("click", () => {
|
||||
const variableName = document.querySelector<HTMLInputElement>("#environmentVariableName")?.value.trim() ?? "";
|
||||
requestSecretReveal(variableName, "dotenv");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#clearEnvironmentEditor")?.addEventListener("click", () => {
|
||||
const variable = document.querySelector<HTMLInputElement>("#environmentVariableName");
|
||||
const value = document.querySelector<HTMLInputElement>("#environmentVariableValue");
|
||||
if (variable) {
|
||||
variable.value = "";
|
||||
}
|
||||
if (value) {
|
||||
value.value = "";
|
||||
}
|
||||
updateEditorSensitivity();
|
||||
frontendTrace("main", "Environment mutation editor cleared");
|
||||
});
|
||||
frontendTrace("main", "Environment panel handlers installed");
|
||||
}
|
||||
|
||||
export function initializeEnvironmentPanel(): void {
|
||||
initializeSecretReveal();
|
||||
bindEnvironmentActions();
|
||||
updateEditorSensitivity();
|
||||
void refreshEnvironmentReport();
|
||||
}
|
||||
125
crates/ksp-app-config-desk/frontend/ts/frontend_log.ts
Normal file
125
crates/ksp-app-config-desk/frontend/ts/frontend_log.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/frontend_log.ts
|
||||
// version: 2
|
||||
|
||||
//! Frontend logging helpers routed through the KSP Logging facade.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FrontendLogPayloadDto } from "./bindings/ksp_app_config_desk/frontend_logging/FrontendLogPayloadDto.ts";
|
||||
|
||||
export type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||
export type FrontendLogTargetId = "frontend" | "main" | "splash";
|
||||
|
||||
type ConsoleMethod = (...items: unknown[]) => void;
|
||||
|
||||
const originalConsole = {
|
||||
trace: console.trace.bind(console),
|
||||
debug: console.debug.bind(console),
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
|
||||
function stringifyItem(item: unknown): string {
|
||||
if (item instanceof Error) {
|
||||
return item.stack ?? item.message;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
return serialized ?? String(item);
|
||||
} catch {
|
||||
return String(item);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(items: unknown[]): string {
|
||||
return items.map(item => stringifyItem(item)).join(" ");
|
||||
}
|
||||
|
||||
async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||
const payload: FrontendLogPayloadDto = {
|
||||
level,
|
||||
targetId,
|
||||
message,
|
||||
};
|
||||
await invoke("emit_frontend_log", { payload });
|
||||
}
|
||||
|
||||
function writeOriginalConsole(level: FrontendLogLevel, message: string): void {
|
||||
return level === "trace"
|
||||
? originalConsole.trace(message)
|
||||
: level === "debug"
|
||||
? originalConsole.debug(message)
|
||||
: level === "info"
|
||||
? originalConsole.info(message)
|
||||
: level === "warn"
|
||||
? originalConsole.warn(message)
|
||||
: originalConsole.error(message);
|
||||
}
|
||||
|
||||
/// Emits one frontend event and resolves only after the Rust bridge accepted it.
|
||||
export async function emitFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise<void> {
|
||||
writeOriginalConsole(level, message);
|
||||
await sendFrontendLog(level, targetId, message);
|
||||
}
|
||||
|
||||
export function frontendTrace(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.trace(message);
|
||||
void sendFrontendLog("trace", targetId, message).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
}
|
||||
|
||||
export function frontendDebug(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.debug(message);
|
||||
void sendFrontendLog("debug", targetId, message).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
}
|
||||
|
||||
export function frontendInfo(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.info(message);
|
||||
void sendFrontendLog("info", targetId, message).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
}
|
||||
|
||||
export function frontendWarn(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.warn(message);
|
||||
void sendFrontendLog("warn", targetId, message).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
}
|
||||
|
||||
export function frontendError(targetId: FrontendLogTargetId, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.error(message);
|
||||
void sendFrontendLog("error", targetId, message).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
}
|
||||
|
||||
function buildConsoleBridge(level: FrontendLogLevel, targetId: FrontendLogTargetId, original: ConsoleMethod): ConsoleMethod {
|
||||
return (...items: unknown[]) => {
|
||||
original(...items);
|
||||
void sendFrontendLog(level, targetId, formatMessage(items)).catch(caughtError =>
|
||||
originalConsole.error("KSP frontend logging bridge failed", caughtError),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function installFrontendConsoleBridge(targetId: FrontendLogTargetId): void {
|
||||
console.trace = buildConsoleBridge("trace", targetId, originalConsole.trace);
|
||||
console.debug = buildConsoleBridge("debug", targetId, originalConsole.debug);
|
||||
console.log = buildConsoleBridge("info", targetId, originalConsole.log);
|
||||
console.info = buildConsoleBridge("info", targetId, originalConsole.info);
|
||||
console.warn = buildConsoleBridge("warn", targetId, originalConsole.warn);
|
||||
console.error = buildConsoleBridge("error", targetId, originalConsole.error);
|
||||
}
|
||||
19
crates/ksp-app-config-desk/frontend/ts/invoke.ts
Normal file
19
crates/ksp-app-config-desk/frontend/ts/invoke.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/invoke.ts
|
||||
// version: 1
|
||||
|
||||
//! Shared Tauri invoke wrapper with bounded frontend lifecycle tracing.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { frontendDebug, frontendError, frontendTrace, type FrontendLogTargetId } from "./frontend_log";
|
||||
|
||||
export async function invokeKsp<T>(targetId: FrontendLogTargetId, command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
frontendDebug(targetId, "Frontend IPC command requested", { command });
|
||||
try {
|
||||
const result = await invoke<T>(command, args);
|
||||
frontendTrace(targetId, "Frontend IPC command completed", { command });
|
||||
return result;
|
||||
} catch (caughtError) {
|
||||
frontendError(targetId, "Frontend IPC command failed", { command });
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
779
crates/ksp-app-config-desk/frontend/ts/logging.ts
Normal file
779
crates/ksp-app-config-desk/frontend/ts/logging.ts
Normal file
@@ -0,0 +1,779 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/logging.ts
|
||||
// version: 6
|
||||
|
||||
//! Typed Logging editor backed by ConfigManagement persistence and KSP-owned runtime hot reload.
|
||||
|
||||
import { Modal } from "bootstrap";
|
||||
import type { LoggingDocumentCandidateDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto";
|
||||
import type { LoggingDocumentDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentDto";
|
||||
import type { LoggingDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/logging/LoggingDocumentSaveResultDto";
|
||||
import type { LoggingFileDto } from "./bindings/ksp_app_config_desk/logging/LoggingFileDto";
|
||||
import type { LoggingProfileDto } from "./bindings/ksp_app_config_desk/logging/LoggingProfileDto";
|
||||
import type { LoggingTargetFilterDto } from "./bindings/ksp_app_config_desk/logging/LoggingTargetFilterDto";
|
||||
import type { LoggingRuntimeStatusDto } from "./bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeStatusDto";
|
||||
import { frontendDebug, frontendTrace } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
import { initializeLoggingTestPanel } from "./logging_test";
|
||||
|
||||
const LEVELS = ["off", "error", "warn", "info", "debug", "trace"] as const;
|
||||
const ROTATIONS = ["never", "hourly", "daily"] as const;
|
||||
const FORMATS = ["human", "compact", "pretty", "json"] as const;
|
||||
|
||||
let draft: LoggingDocumentCandidateDto | null = null;
|
||||
let persistedDocument: LoggingDocumentDto | null = null;
|
||||
let selectedProfileId = "";
|
||||
let dirty = false;
|
||||
|
||||
function setLoggingStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#loggingStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Logging editor status replaced", { tone });
|
||||
}
|
||||
|
||||
function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function inputValue(selector: string): string {
|
||||
return document.querySelector<HTMLInputElement>(selector)?.value ?? "";
|
||||
}
|
||||
|
||||
function selectedValue(selector: string): string {
|
||||
return document.querySelector<HTMLSelectElement>(selector)?.value ?? "";
|
||||
}
|
||||
|
||||
function checkboxValue(selector: string): boolean {
|
||||
return document.querySelector<HTMLInputElement>(selector)?.checked ?? false;
|
||||
}
|
||||
|
||||
function setInputValue(selector: string, value: string): void {
|
||||
const input = document.querySelector<HTMLInputElement>(selector);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectValue(selector: string, value: string): void {
|
||||
const select = document.querySelector<HTMLSelectElement>(selector);
|
||||
if (select) {
|
||||
select.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckboxValue(selector: string, value: boolean): void {
|
||||
const input = document.querySelector<HTMLInputElement>(selector);
|
||||
if (input) {
|
||||
input.checked = value;
|
||||
}
|
||||
}
|
||||
|
||||
function selectorText(values: string[]): string {
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
function parseSelectors(value: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const part of value.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.length > 0 && !result.includes(trimmed)) {
|
||||
result.push(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cloneProfile(profile: LoggingProfileDto): LoggingProfileDto {
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
function selectedProfile(): LoggingProfileDto | null {
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
return draft.profiles.find(profile => profile.profileId === selectedProfileId) ?? null;
|
||||
}
|
||||
|
||||
function markDirty(reason: string): void {
|
||||
dirty = true;
|
||||
const saveButton = document.querySelector<HTMLButtonElement>("#saveLoggingDocument");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = false;
|
||||
}
|
||||
setLoggingStatus("Brouillon modifié. Sauvegarde nécessaire pour persister std.logging.json.", "warning");
|
||||
updateRuntimeActionState();
|
||||
frontendTrace("main", "Logging draft changed", { reason, selectedProfileId });
|
||||
}
|
||||
|
||||
function setEditorEnabled(enabled: boolean): void {
|
||||
const selectors = [
|
||||
"#loggingLogsDirectoryInput",
|
||||
"#loggingDefaultProfileSelect",
|
||||
"#loggingProfileSelect",
|
||||
"#loggingProfileIdInput",
|
||||
"#createLoggingProfile",
|
||||
"#cloneLoggingProfile",
|
||||
"#renameLoggingProfile",
|
||||
"#deleteLoggingProfile",
|
||||
"#loggingProfileDefaultFilter",
|
||||
"#loggingProfileSpanEvents",
|
||||
"#loggingConsoleEnabled",
|
||||
"#loggingConsoleAnsi",
|
||||
"#loggingConsoleOutput",
|
||||
"#loggingConsoleFormat",
|
||||
"#loggingConsoleLevel",
|
||||
"#loggingConsoleTargets",
|
||||
"#loggingConsoleDomains",
|
||||
"#addLoggingFile",
|
||||
"#addLoggingTargetFilter",
|
||||
];
|
||||
for (const selector of selectors) {
|
||||
const element = document.querySelector<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(selector);
|
||||
if (element) {
|
||||
element.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
for (const element of document.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
|
||||
"#loggingFilesTable input, #loggingFilesTable select, #loggingFilesTable button, #loggingTargetFiltersTable input, #loggingTargetFiltersTable select, #loggingTargetFiltersTable button",
|
||||
)) {
|
||||
element.disabled = !enabled;
|
||||
}
|
||||
const save = document.querySelector<HTMLButtonElement>("#saveLoggingDocument");
|
||||
if (save) {
|
||||
save.disabled = !enabled || !dirty;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfileSelects(): void {
|
||||
const profileSelect = document.querySelector<HTMLSelectElement>("#loggingProfileSelect");
|
||||
const defaultSelect = document.querySelector<HTMLSelectElement>("#loggingDefaultProfileSelect");
|
||||
if (!draft || !profileSelect || !defaultSelect) {
|
||||
return;
|
||||
}
|
||||
profileSelect.replaceChildren();
|
||||
defaultSelect.replaceChildren();
|
||||
for (const profile of draft.profiles) {
|
||||
const profileOption = document.createElement("option");
|
||||
profileOption.value = profile.profileId;
|
||||
profileOption.textContent = profile.profileId === draft.defaultProfile ? `${profile.profileId} (default)` : profile.profileId;
|
||||
profileSelect.append(profileOption);
|
||||
const defaultOption = document.createElement("option");
|
||||
defaultOption.value = profile.profileId;
|
||||
defaultOption.textContent = profile.profileId;
|
||||
defaultSelect.append(defaultOption);
|
||||
}
|
||||
if (!draft.profiles.some(profile => profile.profileId === selectedProfileId)) {
|
||||
selectedProfileId = draft.profiles[0]?.profileId ?? "";
|
||||
}
|
||||
profileSelect.value = selectedProfileId;
|
||||
defaultSelect.value = draft.defaultProfile;
|
||||
frontendTrace("main", "Logging profile selectors replaced", { profileCount: draft.profiles.length });
|
||||
}
|
||||
|
||||
function createSelect(values: readonly string[], value: string, onChange: (next: string) => void): HTMLSelectElement {
|
||||
const select = document.createElement("select");
|
||||
select.className = "form-select form-select-sm";
|
||||
for (const item of values) {
|
||||
const option = document.createElement("option");
|
||||
option.value = item;
|
||||
option.textContent = item;
|
||||
select.append(option);
|
||||
}
|
||||
select.value = value;
|
||||
select.addEventListener("change", () => onChange(select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
function createTextInput(value: string, onInput: (next: string) => void, monospace = false): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.autocomplete = "off";
|
||||
input.className = `form-control form-control-sm${monospace ? " font-monospace" : ""}`;
|
||||
input.value = value;
|
||||
input.addEventListener("input", () => onInput(input.value));
|
||||
return input;
|
||||
}
|
||||
|
||||
function createCheckbox(value: boolean, onChange: (next: boolean) => void): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = value;
|
||||
input.addEventListener("change", () => onChange(input.checked));
|
||||
return input;
|
||||
}
|
||||
|
||||
function appendControlCell(row: HTMLTableRowElement, control: HTMLElement): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.append(control);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function renderFiles(profile: LoggingProfileDto): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#loggingFilesTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
profile.files.forEach((file, index) => {
|
||||
const row = document.createElement("tr");
|
||||
appendControlCell(row, createTextInput(file.outputId, value => { file.outputId = value; markDirty("file.output_id"); }, true));
|
||||
appendControlCell(row, createCheckbox(file.enabled, value => { file.enabled = value; markDirty("file.enabled"); }));
|
||||
appendControlCell(row, createTextInput(file.path, value => { file.path = value; markDirty("file.path"); }, true));
|
||||
appendControlCell(row, createSelect(ROTATIONS, file.rotation, value => { file.rotation = value; markDirty("file.rotation"); }));
|
||||
appendControlCell(row, createSelect(FORMATS, file.format, value => { file.format = value; markDirty("file.format"); }));
|
||||
appendControlCell(row, createSelect(LEVELS, file.filter.level, value => { file.filter.level = value; markDirty("file.filter.level"); }));
|
||||
appendControlCell(row, createTextInput(selectorText(file.filter.targets), value => { file.filter.targets = parseSelectors(value); markDirty("file.filter.targets"); }, true));
|
||||
appendControlCell(row, createTextInput(selectorText(file.filter.domains), value => { file.filter.domains = parseSelectors(value); markDirty("file.filter.domains"); }, true));
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "btn btn-outline-danger btn-sm";
|
||||
remove.textContent = "Retirer";
|
||||
remove.addEventListener("click", () => {
|
||||
profile.files.splice(index, 1);
|
||||
markDirty("file.remove");
|
||||
renderFiles(profile);
|
||||
frontendDebug("main", "Logging file sink removed from draft", { profileId: profile.profileId, fileIndex: index });
|
||||
});
|
||||
appendControlCell(row, remove);
|
||||
body.append(row);
|
||||
});
|
||||
frontendTrace("main", "Logging file sink editor DOM replaced", { fileCount: profile.files.length });
|
||||
}
|
||||
|
||||
function renderTargetFilters(profile: LoggingProfileDto): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#loggingTargetFiltersTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
profile.targetFilters.forEach((filter, index) => {
|
||||
const row = document.createElement("tr");
|
||||
appendControlCell(row, createTextInput(filter.targetPrefix, value => { filter.targetPrefix = value; markDirty("target_filter.prefix"); }, true));
|
||||
appendControlCell(row, createSelect(LEVELS, filter.level, value => { filter.level = value; markDirty("target_filter.level"); }));
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "btn btn-outline-danger btn-sm";
|
||||
remove.textContent = "Retirer";
|
||||
remove.addEventListener("click", () => {
|
||||
profile.targetFilters.splice(index, 1);
|
||||
markDirty("target_filter.remove");
|
||||
renderTargetFilters(profile);
|
||||
frontendDebug("main", "Logging target filter removed from draft", { profileId: profile.profileId, filterIndex: index });
|
||||
});
|
||||
appendControlCell(row, remove);
|
||||
body.append(row);
|
||||
});
|
||||
frontendTrace("main", "Logging target filter editor DOM replaced", { targetFilterCount: profile.targetFilters.length });
|
||||
}
|
||||
|
||||
function renderSelectedProfile(source: "load" | "selection" | "mutation"): void {
|
||||
const profile = selectedProfile();
|
||||
if (!profile) {
|
||||
setEditorEnabled(false);
|
||||
return;
|
||||
}
|
||||
setSelectValue("#loggingProfileDefaultFilter", profile.defaultFilter);
|
||||
setSelectValue("#loggingProfileSpanEvents", profile.spanEvents);
|
||||
setCheckboxValue("#loggingConsoleEnabled", profile.console.enabled);
|
||||
setCheckboxValue("#loggingConsoleAnsi", profile.console.ansi);
|
||||
setSelectValue("#loggingConsoleOutput", profile.console.output);
|
||||
setSelectValue("#loggingConsoleFormat", profile.console.format);
|
||||
setSelectValue("#loggingConsoleLevel", profile.console.filter.level);
|
||||
setInputValue("#loggingConsoleTargets", selectorText(profile.console.filter.targets));
|
||||
setInputValue("#loggingConsoleDomains", selectorText(profile.console.filter.domains));
|
||||
renderFiles(profile);
|
||||
renderTargetFilters(profile);
|
||||
setEditorEnabled(true);
|
||||
frontendTrace("main", "Logging profile editor rendered", {
|
||||
profileId: profile.profileId,
|
||||
source,
|
||||
fileCount: profile.files.length,
|
||||
targetFilterCount: profile.targetFilters.length,
|
||||
});
|
||||
}
|
||||
|
||||
function updateRuntimeActionState(): void {
|
||||
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
|
||||
const apply = document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile");
|
||||
if (select) {
|
||||
select.disabled = persistedDocument === null || dirty;
|
||||
}
|
||||
if (apply) {
|
||||
apply.disabled = persistedDocument === null || dirty || !select || select.value.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRuntimeProfileSelect(): void {
|
||||
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
|
||||
if (!select || !persistedDocument) {
|
||||
updateRuntimeActionState();
|
||||
return;
|
||||
}
|
||||
const previous = select.value;
|
||||
select.replaceChildren();
|
||||
for (const profile of persistedDocument.profiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.profileId;
|
||||
option.textContent = profile.profileId === persistedDocument.defaultProfile ? `${profile.profileId} (default)` : profile.profileId;
|
||||
select.append(option);
|
||||
}
|
||||
if (persistedDocument.profiles.some(profile => profile.profileId === previous)) {
|
||||
select.value = previous;
|
||||
} else {
|
||||
select.value = persistedDocument.defaultProfile;
|
||||
}
|
||||
updateRuntimeActionState();
|
||||
}
|
||||
|
||||
function renderRuntimeFiles(status: LoggingRuntimeStatusDto): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#loggingRuntimeFilesTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const file of status.files) {
|
||||
const row = document.createElement("tr");
|
||||
const values: Array<{ value: string; monospace: boolean }> = [
|
||||
{ value: file.outputId, monospace: true },
|
||||
{ value: file.directory, monospace: true },
|
||||
{ value: file.fileNamePrefix, monospace: true },
|
||||
{ value: file.rotation, monospace: false },
|
||||
];
|
||||
for (const item of values) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = item.value;
|
||||
if (item.monospace) {
|
||||
cell.classList.add("font-monospace");
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
body.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRuntimeStatus(status: LoggingRuntimeStatusDto): void {
|
||||
setText("#loggingRuntimeActiveProfile", status.activeProfile ?? "fallback transitoire");
|
||||
setText("#loggingRuntimeSelectionSource", status.selectionSource);
|
||||
setText("#loggingRuntimeGeneration", status.generation.toString());
|
||||
setText("#loggingRuntimeFallback", status.fallbackActive ? "oui" : "non");
|
||||
setText("#loggingRuntimeApplicationId", status.applicationId);
|
||||
setText("#loggingRuntimeLaunchTimestamp", status.launchTimestamp);
|
||||
setText("#loggingRuntimeConsole", status.consoleEnabled ? "active" : "désactivée");
|
||||
setText("#loggingRuntimeDropped", `${status.droppedTotalLines} total · console=${status.droppedConsoleLines} · fichiers=${status.droppedFileLines}`);
|
||||
renderRuntimeFiles(status);
|
||||
const select = document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect");
|
||||
if (select && status.activeProfile && persistedDocument?.profiles.some(profile => profile.profileId === status.activeProfile)) {
|
||||
select.value = status.activeProfile;
|
||||
}
|
||||
updateRuntimeActionState();
|
||||
frontendTrace("main", "Logging runtime metadata rendered", {
|
||||
generation: status.generation,
|
||||
selectionSource: status.selectionSource,
|
||||
fileCount: status.files.length,
|
||||
consoleEnabled: status.consoleEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshRuntimeStatus(source: "startup" | "save" | "explicit" | "user"): Promise<void> {
|
||||
if (source === "user") {
|
||||
frontendDebug("main", "Logging runtime metadata refresh requested");
|
||||
}
|
||||
try {
|
||||
const status = await invokeKsp<LoggingRuntimeStatusDto>("main", "get_logging_runtime_status");
|
||||
renderRuntimeStatus(status);
|
||||
frontendDebug("main", "Logging runtime metadata refresh completed", { generation: status.generation, activeProfile: status.activeProfile });
|
||||
} catch {
|
||||
setText("#loggingRuntimeActiveProfile", "indisponible");
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRuntimeProfile(): Promise<void> {
|
||||
if (dirty) {
|
||||
setLoggingStatus("Sauvegarde ou abandonne d'abord le brouillon avant d'appliquer un profil runtime explicite.", "warning");
|
||||
return;
|
||||
}
|
||||
const profileId = selectedValue("#loggingRuntimeProfileSelect");
|
||||
if (profileId.length === 0) {
|
||||
return;
|
||||
}
|
||||
const apply = document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile");
|
||||
if (apply) {
|
||||
apply.disabled = true;
|
||||
}
|
||||
setLoggingStatus(`Application explicite du profil runtime ${profileId}...`);
|
||||
frontendDebug("main", "Explicit Logging runtime profile apply requested", { profileId });
|
||||
try {
|
||||
const status = await invokeKsp<LoggingRuntimeStatusDto>("main", "apply_logging_profile", { profileId });
|
||||
renderRuntimeStatus(status);
|
||||
window.dispatchEvent(new CustomEvent("ksp:logging-runtime-updated"));
|
||||
setLoggingStatus(`Profil ${profileId} appliqué au runtime sans modifier default_profile. Génération ${status.generation}.`, "success");
|
||||
frontendDebug("main", "Explicit Logging runtime profile apply completed", { profileId, generation: status.generation });
|
||||
} catch {
|
||||
updateRuntimeActionState();
|
||||
setLoggingStatus("Application explicite refusée. Le runtime précédent et sa génération sont conservés.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDocument(documentDto: LoggingDocumentDto): void {
|
||||
persistedDocument = structuredClone(documentDto);
|
||||
draft = {
|
||||
logsDirectory: documentDto.logsDirectory,
|
||||
defaultProfile: documentDto.defaultProfile,
|
||||
profiles: documentDto.profiles.map(profile => cloneProfile(profile)),
|
||||
};
|
||||
dirty = false;
|
||||
selectedProfileId = draft.profiles.some(profile => profile.profileId === selectedProfileId) ? selectedProfileId : draft.defaultProfile;
|
||||
if (!draft.profiles.some(profile => profile.profileId === selectedProfileId)) {
|
||||
selectedProfileId = draft.profiles[0]?.profileId ?? "";
|
||||
}
|
||||
setText("#loggingFileId", documentDto.fileId);
|
||||
setText("#loggingPath", documentDto.path);
|
||||
setText("#loggingFormatVersion", documentDto.formatVersion.toString());
|
||||
setInputValue("#loggingLogsDirectoryInput", draft.logsDirectory);
|
||||
renderProfileSelects();
|
||||
renderRuntimeProfileSelect();
|
||||
renderSelectedProfile("load");
|
||||
setText("#loggingSaveReport", "Aucune sauvegarde effectuée depuis le dernier chargement.");
|
||||
frontendTrace("main", "Logging document editor rendered", { profileCount: documentDto.profiles.length });
|
||||
}
|
||||
|
||||
function defaultProfile(profileId: string): LoggingProfileDto {
|
||||
return {
|
||||
profileId,
|
||||
defaultFilter: "warn",
|
||||
spanEvents: "new_and_close",
|
||||
console: {
|
||||
enabled: true,
|
||||
output: "stderr",
|
||||
ansi: true,
|
||||
format: "compact",
|
||||
filter: { level: "info", targets: ["*"], domains: ["*"] },
|
||||
},
|
||||
files: [],
|
||||
targetFilters: [],
|
||||
};
|
||||
}
|
||||
|
||||
function targetProfileId(): string {
|
||||
return inputValue("#loggingProfileIdInput").trim();
|
||||
}
|
||||
|
||||
function profileIdAvailable(profileId: string, exceptProfileId?: string): boolean {
|
||||
if (!draft || profileId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !draft.profiles.some(profile => profile.profileId === profileId && profile.profileId !== exceptProfileId);
|
||||
}
|
||||
|
||||
function createProfile(): void {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const profileId = targetProfileId();
|
||||
if (!profileIdAvailable(profileId)) {
|
||||
setLoggingStatus("L'ID cible est vide ou existe déjà dans le brouillon.", "danger");
|
||||
return;
|
||||
}
|
||||
draft.profiles.push(defaultProfile(profileId));
|
||||
selectedProfileId = profileId;
|
||||
setInputValue("#loggingProfileIdInput", "");
|
||||
markDirty("profile.create");
|
||||
renderProfileSelects();
|
||||
renderSelectedProfile("mutation");
|
||||
frontendDebug("main", "Logging profile created in draft", { profileId });
|
||||
}
|
||||
|
||||
function cloneSelectedProfile(): void {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const source = selectedProfile();
|
||||
const profileId = targetProfileId();
|
||||
if (!source || !profileIdAvailable(profileId)) {
|
||||
setLoggingStatus("Sélectionne un profil et fournis un ID cible libre pour le clone.", "danger");
|
||||
return;
|
||||
}
|
||||
const clone = cloneProfile(source);
|
||||
clone.profileId = profileId;
|
||||
draft.profiles.push(clone);
|
||||
selectedProfileId = profileId;
|
||||
setInputValue("#loggingProfileIdInput", "");
|
||||
markDirty("profile.clone");
|
||||
renderProfileSelects();
|
||||
renderSelectedProfile("mutation");
|
||||
frontendDebug("main", "Logging profile cloned in draft", { sourceProfileId: source.profileId, profileId });
|
||||
}
|
||||
|
||||
function renameSelectedProfile(): void {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const profile = selectedProfile();
|
||||
const profileId = targetProfileId();
|
||||
if (!profile || !profileIdAvailable(profileId, profile.profileId)) {
|
||||
setLoggingStatus("Fournis un nouvel ID de profil libre avant de renommer.", "danger");
|
||||
return;
|
||||
}
|
||||
const previousId = profile.profileId;
|
||||
profile.profileId = profileId;
|
||||
if (draft.defaultProfile === previousId) {
|
||||
draft.defaultProfile = profileId;
|
||||
}
|
||||
selectedProfileId = profileId;
|
||||
setInputValue("#loggingProfileIdInput", "");
|
||||
markDirty("profile.rename");
|
||||
renderProfileSelects();
|
||||
renderSelectedProfile("mutation");
|
||||
frontendDebug("main", "Logging profile renamed in draft", { previousId, profileId });
|
||||
}
|
||||
|
||||
function confirmDeleteSelectedProfile(): void {
|
||||
if (!draft || draft.profiles.length <= 1) {
|
||||
setLoggingStatus("Le document Logging doit conserver au moins un profil.", "danger");
|
||||
return;
|
||||
}
|
||||
const profile = selectedProfile();
|
||||
const modalElement = document.querySelector<HTMLElement>("#loggingProfileDeleteModal");
|
||||
if (!profile || !modalElement) {
|
||||
return;
|
||||
}
|
||||
setText("#loggingProfileDeleteName", profile.profileId);
|
||||
modalElement.dataset.profileId = profile.profileId;
|
||||
frontendDebug("main", "Logging profile draft deletion confirmation opened", { profileId: profile.profileId });
|
||||
Modal.getOrCreateInstance(modalElement).show();
|
||||
}
|
||||
|
||||
function deleteConfirmedProfile(): void {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const modalElement = document.querySelector<HTMLElement>("#loggingProfileDeleteModal");
|
||||
const profileId = modalElement?.dataset.profileId ?? "";
|
||||
const index = draft.profiles.findIndex(profile => profile.profileId === profileId);
|
||||
if (index < 0 || draft.profiles.length <= 1) {
|
||||
return;
|
||||
}
|
||||
draft.profiles.splice(index, 1);
|
||||
if (draft.defaultProfile === profileId) {
|
||||
draft.defaultProfile = draft.profiles[0].profileId;
|
||||
}
|
||||
selectedProfileId = draft.profiles[Math.min(index, draft.profiles.length - 1)].profileId;
|
||||
markDirty("profile.delete");
|
||||
renderProfileSelects();
|
||||
renderSelectedProfile("mutation");
|
||||
if (modalElement) {
|
||||
Modal.getOrCreateInstance(modalElement).hide();
|
||||
delete modalElement.dataset.profileId;
|
||||
}
|
||||
frontendDebug("main", "Logging profile removed from draft", { profileId });
|
||||
}
|
||||
|
||||
function addFile(): void {
|
||||
const profile = selectedProfile();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
const file: LoggingFileDto = {
|
||||
outputId: `file.new.${profile.files.length + 1}`,
|
||||
enabled: true,
|
||||
path: `new/output-${profile.files.length + 1}.log`,
|
||||
rotation: "daily",
|
||||
format: "human",
|
||||
ansi: false,
|
||||
filter: { level: "info", targets: ["*"], domains: ["*"] },
|
||||
};
|
||||
profile.files.push(file);
|
||||
markDirty("file.add");
|
||||
renderFiles(profile);
|
||||
frontendDebug("main", "Logging file sink added to draft", { profileId: profile.profileId, fileCount: profile.files.length });
|
||||
}
|
||||
|
||||
function addTargetFilter(): void {
|
||||
const profile = selectedProfile();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
const filter: LoggingTargetFilterDto = { targetPrefix: "ksp-", level: "info" };
|
||||
profile.targetFilters.push(filter);
|
||||
markDirty("target_filter.add");
|
||||
renderTargetFilters(profile);
|
||||
frontendDebug("main", "Logging target filter added to draft", { profileId: profile.profileId, targetFilterCount: profile.targetFilters.length });
|
||||
}
|
||||
|
||||
function syncStaticProfileFields(): void {
|
||||
const profile = selectedProfile();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
profile.defaultFilter = selectedValue("#loggingProfileDefaultFilter");
|
||||
profile.spanEvents = selectedValue("#loggingProfileSpanEvents");
|
||||
profile.console.enabled = checkboxValue("#loggingConsoleEnabled");
|
||||
profile.console.ansi = checkboxValue("#loggingConsoleAnsi");
|
||||
profile.console.output = selectedValue("#loggingConsoleOutput");
|
||||
profile.console.format = selectedValue("#loggingConsoleFormat");
|
||||
profile.console.filter.level = selectedValue("#loggingConsoleLevel");
|
||||
profile.console.filter.targets = parseSelectors(inputValue("#loggingConsoleTargets"));
|
||||
profile.console.filter.domains = parseSelectors(inputValue("#loggingConsoleDomains"));
|
||||
}
|
||||
|
||||
async function saveLoggingDocument(): Promise<void> {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
syncStaticProfileFields();
|
||||
draft.logsDirectory = inputValue("#loggingLogsDirectoryInput");
|
||||
draft.defaultProfile = selectedValue("#loggingDefaultProfileSelect");
|
||||
setEditorEnabled(false);
|
||||
setLoggingStatus("Validation, sauvegarde atomique et application hot reload du Logging...");
|
||||
frontendDebug("main", "Typed Logging document persistence and runtime apply requested", {
|
||||
profileCount: draft.profiles.length,
|
||||
defaultProfile: draft.defaultProfile,
|
||||
fileCount: draft.profiles.reduce((count, profile) => count + profile.files.length, 0),
|
||||
});
|
||||
try {
|
||||
const result = await invokeKsp<LoggingDocumentSaveResultDto>("main", "save_logging_document", { candidate: draft });
|
||||
renderDocument(result.document);
|
||||
await refreshRuntimeStatus("save");
|
||||
window.dispatchEvent(new CustomEvent("ksp:logging-runtime-updated"));
|
||||
setText(
|
||||
"#loggingSaveReport",
|
||||
`source_changed=${result.sourceChanged} · reload_required=${result.reloadRequired} · runtime_applied=${result.runtimeApplied} · generation=${result.loggingGeneration} · active_profile=${result.activeProfile}`,
|
||||
);
|
||||
setLoggingStatus(
|
||||
`Document Logging validé, persisté et appliqué sans redémarrage. Runtime génération ${result.loggingGeneration}, profil ${result.activeProfile}.`,
|
||||
"success",
|
||||
);
|
||||
frontendDebug("main", "Typed Logging document persistence and runtime apply completed", {
|
||||
sourceChanged: result.sourceChanged,
|
||||
reloadRequired: result.reloadRequired,
|
||||
runtimeApplied: result.runtimeApplied,
|
||||
loggingGeneration: result.loggingGeneration,
|
||||
activeProfile: result.activeProfile,
|
||||
profileCount: result.document.profiles.length,
|
||||
});
|
||||
} catch {
|
||||
setEditorEnabled(true);
|
||||
setLoggingStatus(
|
||||
"Sauvegarde/application Logging refusée. Le runtime précédent est conservé ; si la persistence avait commencé, la transaction restaure la source précédente ou remonte une erreur de rollback.",
|
||||
"danger",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestReload(): void {
|
||||
if (!dirty) {
|
||||
void refreshLoggingDocument("user");
|
||||
return;
|
||||
}
|
||||
const modalElement = document.querySelector<HTMLElement>("#loggingDiscardDraftModal");
|
||||
if (!modalElement) {
|
||||
return;
|
||||
}
|
||||
frontendDebug("main", "Logging dirty draft discard confirmation opened");
|
||||
Modal.getOrCreateInstance(modalElement).show();
|
||||
}
|
||||
|
||||
function confirmReload(): void {
|
||||
const modalElement = document.querySelector<HTMLElement>("#loggingDiscardDraftModal");
|
||||
if (modalElement) {
|
||||
Modal.getOrCreateInstance(modalElement).hide();
|
||||
}
|
||||
frontendDebug("main", "Logging dirty draft discard confirmed");
|
||||
void refreshLoggingDocument("user");
|
||||
}
|
||||
|
||||
async function refreshLoggingDocument(source: "startup" | "user"): Promise<void> {
|
||||
if (source === "user") {
|
||||
frontendDebug("main", "Logging document reload requested", { dirty });
|
||||
}
|
||||
setEditorEnabled(false);
|
||||
setLoggingStatus("Chargement du document Logging typé...");
|
||||
try {
|
||||
const documentDto = await invokeKsp<LoggingDocumentDto>("main", "get_logging_document");
|
||||
renderDocument(documentDto);
|
||||
setLoggingStatus(
|
||||
`${documentDto.profiles.length} profil(s) Logging chargé(s). Le brouillon est synchronisé avec la source persistée ; cette action ne modifie pas le runtime actif.`,
|
||||
"success",
|
||||
);
|
||||
frontendDebug("main", "Logging document reload completed", {
|
||||
profileCount: documentDto.profiles.length,
|
||||
defaultProfile: documentDto.defaultProfile,
|
||||
});
|
||||
await refreshRuntimeStatus(source === "startup" ? "startup" : "user");
|
||||
} catch {
|
||||
draft = null;
|
||||
persistedDocument = null;
|
||||
setEditorEnabled(false);
|
||||
updateRuntimeActionState();
|
||||
setLoggingStatus("Le document Logging typé n'a pas pu être chargé. Consulte les diagnostics backend.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function bindStaticDraftFields(): void {
|
||||
document.querySelector<HTMLInputElement>("#loggingLogsDirectoryInput")?.addEventListener("input", () => {
|
||||
if (draft) {
|
||||
draft.logsDirectory = inputValue("#loggingLogsDirectoryInput");
|
||||
markDirty("logs_directory");
|
||||
}
|
||||
});
|
||||
document.querySelector<HTMLSelectElement>("#loggingDefaultProfileSelect")?.addEventListener("change", () => {
|
||||
if (draft) {
|
||||
draft.defaultProfile = selectedValue("#loggingDefaultProfileSelect");
|
||||
markDirty("default_profile");
|
||||
renderProfileSelects();
|
||||
}
|
||||
});
|
||||
const profileSelectors = ["#loggingProfileDefaultFilter", "#loggingProfileSpanEvents", "#loggingConsoleOutput", "#loggingConsoleFormat", "#loggingConsoleLevel"];
|
||||
for (const selector of profileSelectors) {
|
||||
document.querySelector<HTMLSelectElement>(selector)?.addEventListener("change", () => {
|
||||
syncStaticProfileFields();
|
||||
markDirty(selector);
|
||||
});
|
||||
}
|
||||
for (const selector of ["#loggingConsoleEnabled", "#loggingConsoleAnsi"]) {
|
||||
document.querySelector<HTMLInputElement>(selector)?.addEventListener("change", () => {
|
||||
syncStaticProfileFields();
|
||||
markDirty(selector);
|
||||
});
|
||||
}
|
||||
for (const selector of ["#loggingConsoleTargets", "#loggingConsoleDomains"]) {
|
||||
document.querySelector<HTMLInputElement>(selector)?.addEventListener("input", () => {
|
||||
syncStaticProfileFields();
|
||||
markDirty(selector);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the typed Logging editor surface.
|
||||
export function initializeLoggingPanel(): void {
|
||||
bindStaticDraftFields();
|
||||
document.querySelector<HTMLButtonElement>("#refreshLoggingDocument")?.addEventListener("click", requestReload);
|
||||
document.querySelector<HTMLButtonElement>("#saveLoggingDocument")?.addEventListener("click", () => void saveLoggingDocument());
|
||||
document.querySelector<HTMLButtonElement>("#refreshLoggingRuntime")?.addEventListener("click", () => void refreshRuntimeStatus("user"));
|
||||
document.querySelector<HTMLButtonElement>("#applyLoggingRuntimeProfile")?.addEventListener("click", () => void applyRuntimeProfile());
|
||||
document.querySelector<HTMLSelectElement>("#loggingRuntimeProfileSelect")?.addEventListener("change", updateRuntimeActionState);
|
||||
document.querySelector<HTMLSelectElement>("#loggingProfileSelect")?.addEventListener("change", event => {
|
||||
syncStaticProfileFields();
|
||||
selectedProfileId = (event.currentTarget as HTMLSelectElement).value;
|
||||
frontendDebug("main", "Logging profile selection changed", { profileId: selectedProfileId });
|
||||
renderSelectedProfile("selection");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#createLoggingProfile")?.addEventListener("click", createProfile);
|
||||
document.querySelector<HTMLButtonElement>("#cloneLoggingProfile")?.addEventListener("click", cloneSelectedProfile);
|
||||
document.querySelector<HTMLButtonElement>("#renameLoggingProfile")?.addEventListener("click", renameSelectedProfile);
|
||||
document.querySelector<HTMLButtonElement>("#deleteLoggingProfile")?.addEventListener("click", confirmDeleteSelectedProfile);
|
||||
document.querySelector<HTMLButtonElement>("#loggingProfileDeleteConfirm")?.addEventListener("click", deleteConfirmedProfile);
|
||||
document.querySelector<HTMLButtonElement>("#loggingDiscardDraftConfirm")?.addEventListener("click", confirmReload);
|
||||
document.querySelector<HTMLButtonElement>("#addLoggingFile")?.addEventListener("click", addFile);
|
||||
document.querySelector<HTMLButtonElement>("#addLoggingTargetFilter")?.addEventListener("click", addTargetFilter);
|
||||
initializeLoggingTestPanel();
|
||||
frontendTrace("main", "Logging editor mutation handlers installed");
|
||||
void refreshLoggingDocument("startup");
|
||||
}
|
||||
122
crates/ksp-app-config-desk/frontend/ts/logging_test.ts
Normal file
122
crates/ksp-app-config-desk/frontend/ts/logging_test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/logging_test.ts
|
||||
// version: 1
|
||||
|
||||
//! Controlled Logging test panel for proving backend routing and the frontend bridge.
|
||||
|
||||
import type { LoggingTestRequestDto } from "./bindings/ksp_app_config_desk/logging_test/LoggingTestRequestDto";
|
||||
import type { LoggingTestResultDto } from "./bindings/ksp_app_config_desk/logging_test/LoggingTestResultDto";
|
||||
import { emitFrontendLog, frontendTrace, type FrontendLogLevel } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
const FRONTEND_BRIDGE_TARGET = "ksp-app-config-desk.frontend.main";
|
||||
const FRONTEND_BRIDGE_DOMAIN = "frontend";
|
||||
|
||||
function inputValue(selector: string): string {
|
||||
return document.querySelector<HTMLInputElement>(selector)?.value ?? "";
|
||||
}
|
||||
|
||||
function selectedValue(selector: string): string {
|
||||
return document.querySelector<HTMLSelectElement>(selector)?.value ?? "";
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: "secondary" | "success" | "warning" | "danger" = "secondary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#loggingTestStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
function updateDomainControls(): void {
|
||||
const mode = selectedValue("#loggingTestDomainMode");
|
||||
const known = document.querySelector<HTMLSelectElement>("#loggingTestKnownDomain");
|
||||
const custom = document.querySelector<HTMLInputElement>("#loggingTestCustomDomain");
|
||||
if (known) {
|
||||
known.disabled = mode !== "known";
|
||||
}
|
||||
if (custom) {
|
||||
custom.disabled = mode !== "custom";
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveDomain(): string {
|
||||
const mode = selectedValue("#loggingTestDomainMode");
|
||||
if (mode === "known") {
|
||||
return selectedValue("#loggingTestKnownDomain");
|
||||
}
|
||||
if (mode === "custom") {
|
||||
return inputValue("#loggingTestCustomDomain");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildRequest(): LoggingTestRequestDto {
|
||||
return {
|
||||
level: selectedValue("#loggingTestLevel"),
|
||||
targetId: selectedValue("#loggingTestTarget"),
|
||||
domainMode: selectedValue("#loggingTestDomainMode"),
|
||||
domain: effectiveDomain(),
|
||||
message: inputValue("#loggingTestMessage"),
|
||||
};
|
||||
}
|
||||
|
||||
async function emitBackendTest(): Promise<void> {
|
||||
const request = buildRequest();
|
||||
setStatus("Émission backend en cours...");
|
||||
try {
|
||||
const result = await invokeKsp<LoggingTestResultDto>("main", "emit_logging_test", { request });
|
||||
const domain = result.domain ?? "<absent>";
|
||||
const summary = `${result.emittedEventCount} événement(s) backend émis · niveau=${result.requestedLevel} · target=${result.target}`
|
||||
+ ` · domain=${domain} · génération=${result.loggingGeneration}.`;
|
||||
setStatus(summary, "success");
|
||||
} catch {
|
||||
setStatus("Émission backend refusée. Vérifie le message, le target et le domain sélectionnés.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function emitFrontendLevel(level: FrontendLogLevel, message: string): Promise<void> {
|
||||
await emitFrontendLog(level, "main", message);
|
||||
}
|
||||
|
||||
async function emitFrontendBridgeTest(): Promise<void> {
|
||||
const message = inputValue("#loggingTestMessage").trim();
|
||||
if (message.length === 0 || message.length > 500) {
|
||||
setStatus("Le message de test frontend doit contenir entre 1 et 500 caractères.", "danger");
|
||||
return;
|
||||
}
|
||||
const level = selectedValue("#loggingTestLevel");
|
||||
setStatus("Émission via bridge frontend en cours...");
|
||||
try {
|
||||
if (level === "all") {
|
||||
for (const item of ["trace", "debug", "info", "warn", "error"] as const) {
|
||||
await emitFrontendLevel(item, `[logging-test frontend/${item}] ${message}`);
|
||||
}
|
||||
setStatus(
|
||||
`5 événements acceptés par le bridge frontend · target=${FRONTEND_BRIDGE_TARGET} · domain=${FRONTEND_BRIDGE_DOMAIN}.`,
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (level === "trace" || level === "debug" || level === "info" || level === "warn" || level === "error") {
|
||||
await emitFrontendLevel(level, `[logging-test frontend/${level}] ${message}`);
|
||||
setStatus(
|
||||
`1 événement accepté par le bridge frontend · target=${FRONTEND_BRIDGE_TARGET} · domain=${FRONTEND_BRIDGE_DOMAIN}.`,
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStatus("Le niveau de test frontend sélectionné n'est pas supporté.", "danger");
|
||||
} catch {
|
||||
setStatus("Le bridge frontend n'a pas accepté l'événement de test.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the controlled Logging test surface.
|
||||
export function initializeLoggingTestPanel(): void {
|
||||
document.querySelector<HTMLSelectElement>("#loggingTestDomainMode")?.addEventListener("change", updateDomainControls);
|
||||
document.querySelector<HTMLButtonElement>("#emitLoggingTestBackend")?.addEventListener("click", () => void emitBackendTest());
|
||||
document.querySelector<HTMLButtonElement>("#emitLoggingTestFrontend")?.addEventListener("click", () => void emitFrontendBridgeTest());
|
||||
updateDomainControls();
|
||||
frontendTrace("main", "Logging test panel handlers installed");
|
||||
}
|
||||
144
crates/ksp-app-config-desk/frontend/ts/main.ts
Normal file
144
crates/ksp-app-config-desk/frontend/ts/main.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/main.ts
|
||||
// version: 13
|
||||
|
||||
import "bootstrap";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import "simplebar";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
|
||||
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { initializeDocumentsPanel } from "./documents";
|
||||
import { initializeEnvironmentPanel } from "./environment";
|
||||
import { initializeLoggingPanel } from "./logging";
|
||||
import { initializeProfilesPanel } from "./profiles";
|
||||
import { invokeKsp } from "./invoke";
|
||||
import { isViewId, registeredViewIds, viewRegistry, type ViewId } from "./shell_registry";
|
||||
import { clearTransientSecretReveal } from "./secret_reveal";
|
||||
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
installFrontendConsoleBridge("main");
|
||||
|
||||
function activateView(viewId: ViewId, source: "startup" | "user" | "registry"): void {
|
||||
if (viewId !== "environment") {
|
||||
clearTransientSecretReveal();
|
||||
}
|
||||
if (source !== "startup") {
|
||||
frontendDebug("main", "Main navigation activated", { viewId, source });
|
||||
}
|
||||
const descriptor = viewRegistry[viewId];
|
||||
const title = document.querySelector<HTMLElement>("#viewTitle");
|
||||
const headerTitle = document.querySelector<HTMLElement>("#headerViewTitle");
|
||||
const description = document.querySelector<HTMLElement>("#viewDescription");
|
||||
if (title) {
|
||||
title.textContent = descriptor.title;
|
||||
}
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = descriptor.title;
|
||||
}
|
||||
document.title = `Config Desk — ${descriptor.title}`;
|
||||
if (description) {
|
||||
description.textContent = descriptor.description;
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||
const active = button.dataset.view === viewId;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-current", active ? "page" : "false");
|
||||
});
|
||||
frontendTrace("main", "Main navigation tab state changed", { viewId, source });
|
||||
for (const registeredViewId of registeredViewIds()) {
|
||||
const panel = document.querySelector<HTMLElement>(`#${viewRegistry[registeredViewId].panelId}`);
|
||||
if (panel) {
|
||||
panel.hidden = registeredViewId !== viewId;
|
||||
}
|
||||
}
|
||||
frontendTrace("main", "Main view DOM updated", { viewId, source });
|
||||
}
|
||||
|
||||
function bindNavigation(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const requestedView = button.dataset.view;
|
||||
frontendTrace("main", "Main navigation tab clicked", { requestedView: requestedView ?? null });
|
||||
if (requestedView && isViewId(requestedView)) {
|
||||
activateView(requestedView, "user");
|
||||
}
|
||||
});
|
||||
});
|
||||
window.addEventListener("ksp:activate-view", event => {
|
||||
const detail = event instanceof CustomEvent ? event.detail : null;
|
||||
const requestedView = typeof detail === "object" && detail !== null && "viewId" in detail ? String(detail.viewId) : "";
|
||||
if (isViewId(requestedView)) {
|
||||
activateView(requestedView, "registry");
|
||||
}
|
||||
});
|
||||
frontendTrace("main", "Main navigation handlers installed");
|
||||
}
|
||||
|
||||
function renderSnapshot(snapshot: AppSnapshotDto): void {
|
||||
const version = document.querySelector<HTMLElement>("#overviewVersion");
|
||||
const documents = document.querySelector<HTMLElement>("#overviewDocuments");
|
||||
const profile = document.querySelector<HTMLElement>("#overviewLoggingProfile");
|
||||
const generation = document.querySelector<HTMLElement>("#overviewLoggingGeneration");
|
||||
const fallback = document.querySelector<HTMLElement>("#overviewLoggingFallback");
|
||||
if (version) {
|
||||
version.textContent = snapshot.applicationVersion;
|
||||
}
|
||||
if (documents) {
|
||||
documents.textContent = snapshot.configDocumentCount.toString();
|
||||
}
|
||||
if (profile) {
|
||||
profile.textContent = snapshot.activeLoggingProfile ?? "fallback transitoire";
|
||||
}
|
||||
if (generation) {
|
||||
generation.textContent = snapshot.loggingGeneration.toString();
|
||||
}
|
||||
if (fallback) {
|
||||
fallback.textContent = snapshot.fallbackLoggingActive ? "oui" : "non";
|
||||
}
|
||||
frontendTrace("main", "Application snapshot rendered", {
|
||||
loggingGeneration: snapshot.loggingGeneration,
|
||||
fallbackLoggingActive: snapshot.fallbackLoggingActive,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
const status = document.querySelector<HTMLElement>("#shellStatus");
|
||||
const snapshot = await invokeKsp<AppSnapshotDto>("main", "get_app_snapshot");
|
||||
renderSnapshot(snapshot);
|
||||
if (status) {
|
||||
status.textContent = "Shell principal prêt.";
|
||||
}
|
||||
frontendTrace("main", "Main shell status replaced", { status: "ready" });
|
||||
}
|
||||
|
||||
function bindRuntimeSnapshotRefresh(): void {
|
||||
window.addEventListener("ksp:logging-runtime-updated", () => {
|
||||
frontendDebug("main", "Application snapshot refresh requested after Logging runtime update");
|
||||
void loadSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeMain(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
|
||||
bindNavigation();
|
||||
bindRuntimeSnapshotRefresh();
|
||||
initializeDocumentsPanel();
|
||||
initializeProfilesPanel();
|
||||
initializeEnvironmentPanel();
|
||||
initializeLoggingPanel();
|
||||
activateView("overview", "startup");
|
||||
try {
|
||||
await loadSnapshot();
|
||||
} catch {
|
||||
const status = document.querySelector<HTMLElement>("#shellStatus");
|
||||
if (status) {
|
||||
status.textContent = "Le snapshot initial n'a pas pu être chargé.";
|
||||
}
|
||||
frontendTrace("main", "Main shell status replaced", { status: "snapshot_error" });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initializeMain();
|
||||
});
|
||||
261
crates/ksp-app-config-desk/frontend/ts/profiles.ts
Normal file
261
crates/ksp-app-config-desk/frontend/ts/profiles.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/profiles.ts
|
||||
// version: 1
|
||||
|
||||
import type { ConfigProfileDetailDto } from "./bindings/ksp_app_config_desk/profiles/ConfigProfileDetailDto.ts";
|
||||
import type { ConfigProfileDocumentDto } from "./bindings/ksp_app_config_desk/profiles/ConfigProfileDocumentDto.ts";
|
||||
import { frontendDebug, frontendTrace } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
let profileDocuments: ConfigProfileDocumentDto[] = [];
|
||||
let activeDetail: ConfigProfileDetailDto | null = null;
|
||||
|
||||
function setProfilesStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#profilesStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Profiles panel status replaced", { tone });
|
||||
}
|
||||
|
||||
function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setCode(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === "default_profile") {
|
||||
return "default_profile";
|
||||
}
|
||||
if (source === "explicit") {
|
||||
return "sélection explicite";
|
||||
}
|
||||
if (source === "composite") {
|
||||
return "composite";
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function sensitivityLabel(value: string): string {
|
||||
if (value === "public") {
|
||||
return "Public";
|
||||
}
|
||||
if (value === "internal") {
|
||||
return "Internal";
|
||||
}
|
||||
if (value === "secret") {
|
||||
return "Secret";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function populateDocumentSelect(documents: ConfigProfileDocumentDto[]): void {
|
||||
const select = document.querySelector<HTMLSelectElement>("#profileDocumentSelect");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
select.replaceChildren();
|
||||
for (const item of documents) {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.fileId;
|
||||
option.textContent = `${item.fileId} — ${item.defaultProfile}`;
|
||||
select.append(option);
|
||||
}
|
||||
select.disabled = documents.length === 0;
|
||||
frontendTrace("main", "Profile document select DOM replaced", { documentCount: documents.length });
|
||||
}
|
||||
|
||||
function populateProfileSelect(detail: ConfigProfileDetailDto): void {
|
||||
const select = document.querySelector<HTMLSelectElement>("#profileIdSelect");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
select.replaceChildren();
|
||||
for (const profileId of detail.profileIds) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profileId;
|
||||
option.textContent = profileId === detail.defaultProfile ? `${profileId} (défaut)` : profileId;
|
||||
option.selected = profileId === detail.selectedProfile;
|
||||
select.append(option);
|
||||
}
|
||||
select.disabled = detail.profileIds.length === 0;
|
||||
frontendTrace("main", "Profile id select DOM replaced", { profileCount: detail.profileIds.length });
|
||||
}
|
||||
|
||||
function renderValueOrigins(detail: ConfigProfileDetailDto): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#profileOriginsTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const entry of detail.valueOrigins) {
|
||||
const row = document.createElement("tr");
|
||||
const key = document.createElement("td");
|
||||
const origin = document.createElement("td");
|
||||
key.textContent = entry.key;
|
||||
origin.textContent = entry.origin === "global" ? "global" : entry.origin === "profile" ? "profil" : entry.origin;
|
||||
row.append(key, origin);
|
||||
body.append(row);
|
||||
}
|
||||
frontendTrace("main", "Profile top-level provenance table DOM replaced", { entryCount: detail.valueOrigins.length });
|
||||
}
|
||||
|
||||
function renderEnvironmentProvenance(detail: ConfigProfileDetailDto): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#profileEnvironmentProvenanceTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const entry of detail.environmentProvenance) {
|
||||
const row = document.createElement("tr");
|
||||
for (const value of [entry.jsonPointer, entry.variableName, entry.source, sensitivityLabel(entry.sensitivity)]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
row.append(cell);
|
||||
}
|
||||
body.append(row);
|
||||
}
|
||||
if (detail.environmentProvenance.length === 0) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = 4;
|
||||
cell.className = "text-body-secondary text-center";
|
||||
cell.textContent = "Aucune substitution d'environnement dans l'effective sélectionnée.";
|
||||
row.append(cell);
|
||||
body.append(row);
|
||||
}
|
||||
frontendTrace("main", "Profile environment provenance table DOM replaced", { entryCount: detail.environmentProvenance.length });
|
||||
}
|
||||
|
||||
function renderDetail(detail: ConfigProfileDetailDto): void {
|
||||
activeDetail = detail;
|
||||
populateProfileSelect(detail);
|
||||
setText("#profileDetailFileId", detail.fileId);
|
||||
setText("#profileDetailPath", detail.path);
|
||||
setText("#profileDetailDefault", detail.defaultProfile);
|
||||
setText("#profileDetailSelected", detail.selectedProfile);
|
||||
setText("#profileDetailSelectionSource", sourceLabel(detail.selectionSource));
|
||||
setText("#profileDetailSensitivity", sensitivityLabel(detail.effectiveSensitivity));
|
||||
setCode("#profileGlobalsJson", detail.globalsJson);
|
||||
setCode("#profileSourceJson", detail.profileJson);
|
||||
setCode("#profileEffectiveJson", detail.effectiveSafeJson);
|
||||
renderValueOrigins(detail);
|
||||
renderEnvironmentProvenance(detail);
|
||||
const detailPanel = document.querySelector<HTMLElement>("#profileDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = false;
|
||||
}
|
||||
frontendTrace("main", "Config profile detail rendered", {
|
||||
fileId: detail.fileId,
|
||||
selectedProfile: detail.selectedProfile,
|
||||
selectionSource: detail.selectionSource,
|
||||
effectiveSensitivity: detail.effectiveSensitivity,
|
||||
});
|
||||
}
|
||||
|
||||
function activeDocumentId(): string | null {
|
||||
const select = document.querySelector<HTMLSelectElement>("#profileDocumentSelect");
|
||||
if (!select || select.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return select.value;
|
||||
}
|
||||
|
||||
async function loadProfile(fileId: string, profileId: string | null, source: "default" | "explicit" | "reload"): Promise<void> {
|
||||
frontendDebug("main", "Config profile inspection requested", { fileId, profileId, source });
|
||||
setProfilesStatus(`Résolution du profil ${profileId ?? "par défaut"}...`, "primary");
|
||||
try {
|
||||
const detail = await invokeKsp<ConfigProfileDetailDto>("main", "get_config_profile_detail", {
|
||||
fileId,
|
||||
profileId,
|
||||
});
|
||||
renderDetail(detail);
|
||||
setProfilesStatus(
|
||||
`${detail.fileId} / ${detail.selectedProfile} résolu via ${sourceLabel(detail.selectionSource)}; effective sûre affichée.`,
|
||||
"success",
|
||||
);
|
||||
frontendDebug("main", "Config profile inspection completed", {
|
||||
fileId: detail.fileId,
|
||||
selectedProfile: detail.selectedProfile,
|
||||
selectionSource: detail.selectionSource,
|
||||
});
|
||||
} catch {
|
||||
setProfilesStatus("Le profil Config n'a pas pu être résolu par le backend.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfileDocuments(): Promise<void> {
|
||||
frontendDebug("main", "Config profile document inventory refresh requested");
|
||||
try {
|
||||
profileDocuments = await invokeKsp<ConfigProfileDocumentDto[]>("main", "get_config_profile_documents");
|
||||
populateDocumentSelect(profileDocuments);
|
||||
if (profileDocuments.length === 0) {
|
||||
activeDetail = null;
|
||||
const detailPanel = document.querySelector<HTMLElement>("#profileDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = true;
|
||||
}
|
||||
setProfilesStatus("Aucun document Config avec profils standard n'est enregistré.", "warning");
|
||||
return;
|
||||
}
|
||||
await loadProfile(profileDocuments[0].fileId, null, "default");
|
||||
} catch {
|
||||
setProfilesStatus("L'inventaire des profils Config n'a pas pu être chargé.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function bindProfileActions(): void {
|
||||
document.querySelector<HTMLButtonElement>("#refreshProfiles")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Profiles refresh button clicked");
|
||||
void refreshProfileDocuments();
|
||||
});
|
||||
document.querySelector<HTMLSelectElement>("#profileDocumentSelect")?.addEventListener("change", event => {
|
||||
const select = event.currentTarget;
|
||||
if (!(select instanceof HTMLSelectElement) || select.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
frontendTrace("main", "Profile document selection changed", { fileId: select.value });
|
||||
void loadProfile(select.value, null, "default");
|
||||
});
|
||||
document.querySelector<HTMLSelectElement>("#profileIdSelect")?.addEventListener("change", event => {
|
||||
const select = event.currentTarget;
|
||||
const fileId = activeDocumentId();
|
||||
if (!(select instanceof HTMLSelectElement) || !fileId || select.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
frontendTrace("main", "Explicit profile selection changed", { fileId, profileId: select.value });
|
||||
void loadProfile(fileId, select.value, "explicit");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#inspectDefaultProfile")?.addEventListener("click", () => {
|
||||
const fileId = activeDocumentId();
|
||||
if (!fileId) {
|
||||
return;
|
||||
}
|
||||
frontendTrace("main", "Default profile inspection button clicked", { fileId });
|
||||
void loadProfile(fileId, null, "default");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#reloadProfileDetail")?.addEventListener("click", () => {
|
||||
if (!activeDetail) {
|
||||
return;
|
||||
}
|
||||
const explicit = activeDetail.selectionSource === "explicit" ? activeDetail.selectedProfile : null;
|
||||
frontendTrace("main", "Profile detail reload button clicked", { fileId: activeDetail.fileId, explicitProfile: explicit });
|
||||
void loadProfile(activeDetail.fileId, explicit, "reload");
|
||||
});
|
||||
frontendTrace("main", "Profiles panel handlers installed");
|
||||
}
|
||||
|
||||
export function initializeProfilesPanel(): void {
|
||||
bindProfileActions();
|
||||
void refreshProfileDocuments();
|
||||
}
|
||||
206
crates/ksp-app-config-desk/frontend/ts/secret_reveal.ts
Normal file
206
crates/ksp-app-config-desk/frontend/ts/secret_reveal.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/secret_reveal.ts
|
||||
// version: 1
|
||||
|
||||
import { Modal } from "bootstrap";
|
||||
import type { SecretRevealRequestDto } from "./bindings/ksp_app_config_desk/secrets/SecretRevealRequestDto";
|
||||
import type { SecretRevealResponseDto } from "./bindings/ksp_app_config_desk/secrets/SecretRevealResponseDto";
|
||||
import { frontendDebug, frontendTrace } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
export type SecretRevealSource = "effective" | "dotenv";
|
||||
|
||||
let pendingRequest: SecretRevealRequestDto | null = null;
|
||||
let revealInFlight = false;
|
||||
let revealGeneration = 0;
|
||||
|
||||
function sourceLabel(source: SecretRevealSource): string {
|
||||
return source === "effective" ? "effective (process prioritaire sur .env)" : ".env persisté";
|
||||
}
|
||||
|
||||
function modalElement(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>("#secretRevealModal");
|
||||
}
|
||||
|
||||
function clearSecretValue(): void {
|
||||
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
|
||||
const absent = document.querySelector<HTMLElement>("#secretRevealValueAbsent");
|
||||
const result = document.querySelector<HTMLElement>("#secretRevealResultPanel");
|
||||
const confirmation = document.querySelector<HTMLElement>("#secretRevealConfirmPanel");
|
||||
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
|
||||
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.type = "password";
|
||||
}
|
||||
if (absent) {
|
||||
absent.hidden = true;
|
||||
}
|
||||
if (result) {
|
||||
result.hidden = true;
|
||||
}
|
||||
if (confirmation) {
|
||||
confirmation.hidden = false;
|
||||
}
|
||||
if (confirm) {
|
||||
confirm.hidden = false;
|
||||
confirm.disabled = false;
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.hidden = true;
|
||||
toggle.textContent = "Afficher";
|
||||
}
|
||||
}
|
||||
|
||||
function renderRevealResponse(response: SecretRevealResponseDto): void {
|
||||
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
|
||||
const absent = document.querySelector<HTMLElement>("#secretRevealValueAbsent");
|
||||
const result = document.querySelector<HTMLElement>("#secretRevealResultPanel");
|
||||
const confirmation = document.querySelector<HTMLElement>("#secretRevealConfirmPanel");
|
||||
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
|
||||
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
|
||||
if (confirmation) {
|
||||
confirmation.hidden = true;
|
||||
}
|
||||
if (confirm) {
|
||||
confirm.hidden = true;
|
||||
}
|
||||
if (result) {
|
||||
result.hidden = false;
|
||||
}
|
||||
if (response.value === null) {
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.hidden = true;
|
||||
}
|
||||
if (absent) {
|
||||
absent.hidden = false;
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.hidden = true;
|
||||
}
|
||||
} else {
|
||||
if (input) {
|
||||
input.hidden = false;
|
||||
input.type = "password";
|
||||
input.value = response.value;
|
||||
}
|
||||
if (absent) {
|
||||
absent.hidden = true;
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.hidden = false;
|
||||
toggle.textContent = "Afficher";
|
||||
}
|
||||
}
|
||||
frontendDebug("main", "Privileged Secret reveal rendered", {
|
||||
variableName: response.variableName,
|
||||
source: response.source,
|
||||
valuePresent: response.value !== null,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeReveal(): Promise<void> {
|
||||
if (!pendingRequest || revealInFlight) {
|
||||
return;
|
||||
}
|
||||
const request = pendingRequest;
|
||||
const requestGeneration = revealGeneration;
|
||||
const confirm = document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction");
|
||||
const status = document.querySelector<HTMLElement>("#secretRevealStatus");
|
||||
revealInFlight = true;
|
||||
if (confirm) {
|
||||
confirm.disabled = true;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "Reveal privilégié en cours via ksp-config-lib...";
|
||||
}
|
||||
frontendDebug("main", "Privileged Secret reveal requested", { variableName: request.variableName, source: request.source });
|
||||
try {
|
||||
const response = await invokeKsp<SecretRevealResponseDto>("main", "reveal_environment_value", { request });
|
||||
if (requestGeneration !== revealGeneration || !pendingRequest) {
|
||||
frontendDebug("main", "Privileged Secret reveal response discarded after modal state changed", {
|
||||
variableName: request.variableName,
|
||||
source: request.source,
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderRevealResponse(response);
|
||||
if (status) {
|
||||
status.textContent = response.value === null ? "Aucune valeur n'existe dans la source demandée." : "Valeur révélée temporairement. Elle sera effacée à la fermeture.";
|
||||
}
|
||||
} catch {
|
||||
if (status) {
|
||||
status.textContent = "Le reveal a été refusé ou a échoué.";
|
||||
}
|
||||
if (confirm) {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration === revealGeneration) {
|
||||
revealInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRevealVisibility(): void {
|
||||
const input = document.querySelector<HTMLInputElement>("#secretRevealValue");
|
||||
const toggle = document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility");
|
||||
if (!input || !toggle || input.hidden) {
|
||||
return;
|
||||
}
|
||||
const show = input.type === "password";
|
||||
input.type = show ? "text" : "password";
|
||||
toggle.textContent = show ? "Masquer" : "Afficher";
|
||||
frontendDebug("main", "Transient Secret reveal visibility changed", { visible: show });
|
||||
}
|
||||
|
||||
export function requestSecretReveal(variableName: string, source: SecretRevealSource): void {
|
||||
const modal = modalElement();
|
||||
const variable = document.querySelector<HTMLElement>("#secretRevealVariable");
|
||||
const sourceElement = document.querySelector<HTMLElement>("#secretRevealSource");
|
||||
const status = document.querySelector<HTMLElement>("#secretRevealStatus");
|
||||
if (!modal || !variable || !sourceElement || !status) {
|
||||
frontendDebug("main", "Privileged Secret reveal modal is unavailable", { variableName, source });
|
||||
return;
|
||||
}
|
||||
revealGeneration += 1;
|
||||
clearSecretValue();
|
||||
pendingRequest = { variableName, source };
|
||||
variable.textContent = variableName;
|
||||
sourceElement.textContent = sourceLabel(source);
|
||||
status.textContent = "Confirmation explicite requise avant tout accès à la valeur réelle.";
|
||||
frontendDebug("main", "Privileged Secret reveal confirmation opened", { variableName, source });
|
||||
Modal.getOrCreateInstance(modal).show();
|
||||
}
|
||||
|
||||
export function clearTransientSecretReveal(): void {
|
||||
const modal = modalElement();
|
||||
const variableName = pendingRequest?.variableName ?? null;
|
||||
revealGeneration += 1;
|
||||
clearSecretValue();
|
||||
pendingRequest = null;
|
||||
revealInFlight = false;
|
||||
if (modal) {
|
||||
Modal.getOrCreateInstance(modal).hide();
|
||||
}
|
||||
frontendTrace("main", "Transient Secret reveal state cleared", { variableName });
|
||||
}
|
||||
|
||||
export function initializeSecretReveal(): void {
|
||||
const modal = modalElement();
|
||||
document.querySelector<HTMLButtonElement>("#secretRevealConfirmAction")?.addEventListener("click", () => {
|
||||
void executeReveal();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#secretRevealToggleVisibility")?.addEventListener("click", () => {
|
||||
toggleRevealVisibility();
|
||||
});
|
||||
modal?.addEventListener("hidden.bs.modal", () => {
|
||||
const variableName = pendingRequest?.variableName ?? null;
|
||||
revealGeneration += 1;
|
||||
clearSecretValue();
|
||||
pendingRequest = null;
|
||||
revealInFlight = false;
|
||||
frontendDebug("main", "Privileged Secret reveal closed and value cleared", { variableName });
|
||||
});
|
||||
frontendTrace("main", "Privileged Secret reveal handlers installed");
|
||||
}
|
||||
70
crates/ksp-app-config-desk/frontend/ts/shell_registry.ts
Normal file
70
crates/ksp-app-config-desk/frontend/ts/shell_registry.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/shell_registry.ts
|
||||
// version: 1
|
||||
|
||||
//! Declarative shell and specialized-editor registry for Config Desk frontend extensibility.
|
||||
|
||||
export type ViewId = "overview" | "documents" | "profiles" | "environment" | "logging";
|
||||
|
||||
export interface ViewDescriptor {
|
||||
title: string;
|
||||
description: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
export interface SpecializedEditorDescriptor {
|
||||
fileId: string;
|
||||
viewId: ViewId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const viewRegistry: Record<ViewId, ViewDescriptor> = {
|
||||
overview: {
|
||||
title: "Vue d'ensemble",
|
||||
description: "État sûr du bootstrap Config Desk et du runtime Logging actuellement actif.",
|
||||
panelId: "overviewPanel",
|
||||
},
|
||||
documents: {
|
||||
title: "Documents",
|
||||
description: "Inventaire, diagnostics et réparation validée des documents Config enregistrés.",
|
||||
panelId: "documentsPanel",
|
||||
},
|
||||
profiles: {
|
||||
title: "Profils",
|
||||
description: "Sélection default/explicite, vues global/profil/effective sûre et provenance Config.",
|
||||
panelId: "profilesPanel",
|
||||
},
|
||||
environment: {
|
||||
title: "Environnement / .env",
|
||||
description: "Rapport sûr desired/effective/source/shadow et management atomique des variables .env KSP/KSPB.",
|
||||
panelId: "environmentPanel",
|
||||
},
|
||||
logging: {
|
||||
title: "Logging",
|
||||
description: "Édition typée, hot reload runtime, profils actifs et panneau de test du routing Logging KSP.",
|
||||
panelId: "loggingPanel",
|
||||
},
|
||||
};
|
||||
|
||||
const specializedEditorRegistry: SpecializedEditorDescriptor[] = [
|
||||
{
|
||||
fileId: "cfg.std.logging",
|
||||
viewId: "logging",
|
||||
label: "Ouvrir l'éditeur Logging",
|
||||
},
|
||||
];
|
||||
|
||||
export function registeredViewIds(): ViewId[] {
|
||||
return Object.keys(viewRegistry) as ViewId[];
|
||||
}
|
||||
|
||||
export function isViewId(value: string): value is ViewId {
|
||||
return Object.prototype.hasOwnProperty.call(viewRegistry, value);
|
||||
}
|
||||
|
||||
export function specializedEditorForFileId(fileId: string): SpecializedEditorDescriptor | null {
|
||||
return specializedEditorRegistry.find(descriptor => descriptor.fileId === fileId) ?? null;
|
||||
}
|
||||
|
||||
export function requestViewActivation(viewId: ViewId): void {
|
||||
window.dispatchEvent(new CustomEvent("ksp:activate-view", { detail: { viewId } }));
|
||||
}
|
||||
113
crates/ksp-app-config-desk/frontend/ts/splash.ts
Normal file
113
crates/ksp-app-config-desk/frontend/ts/splash.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/splash.ts
|
||||
// version: 3
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { SplashOrderDto } from "./bindings/ksp_app_config_desk/splash/SplashOrderDto.ts";
|
||||
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
installFrontendConsoleBridge("splash");
|
||||
|
||||
const defaultFadeDurationMs = 300;
|
||||
let activeOpacityFrame: number | null = null;
|
||||
|
||||
function normalizeDurationMs(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return defaultFadeDurationMs;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function easeInOut(progress: number): number {
|
||||
if (progress < 0.5) {
|
||||
return 2 * progress * progress;
|
||||
}
|
||||
return 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||||
}
|
||||
|
||||
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
|
||||
frontendTrace("splash", "Splash opacity animation started", { fromOpacity, toOpacity, durationMs });
|
||||
if (activeOpacityFrame !== null) {
|
||||
cancelAnimationFrame(activeOpacityFrame);
|
||||
activeOpacityFrame = null;
|
||||
}
|
||||
element.style.opacity = fromOpacity.toString();
|
||||
element.style.willChange = "opacity";
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>(resolve => {
|
||||
const startedAt = performance.now();
|
||||
const opacityDelta = toOpacity - fromOpacity;
|
||||
const updateOpacity = (currentTime: number): void => {
|
||||
const elapsedMs = currentTime - startedAt;
|
||||
const rawProgress = Math.min(elapsedMs / durationMs, 1);
|
||||
element.style.opacity = (fromOpacity + opacityDelta * easeInOut(rawProgress)).toString();
|
||||
if (rawProgress >= 1) {
|
||||
element.style.opacity = toOpacity.toString();
|
||||
element.style.willChange = "auto";
|
||||
activeOpacityFrame = null;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
};
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
});
|
||||
frontendTrace("splash", "Splash opacity animation completed", { toOpacity, durationMs });
|
||||
}
|
||||
|
||||
function replaceStatus(message: string | null): void {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const status = document.querySelector<HTMLElement>("#splash-status");
|
||||
if (status) {
|
||||
status.textContent = message;
|
||||
frontendTrace("splash", "Splash status replaced", { message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
|
||||
frontendTrace("splash", "Splash order received", { action: order.action });
|
||||
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||
replaceStatus(order.message);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (order.action === "fade_in") {
|
||||
await animateOpacity(container, 0, 1, normalizeDurationMs(order.durationMs));
|
||||
return;
|
||||
}
|
||||
if (order.action === "fade_out") {
|
||||
await animateOpacity(container, 1, 0, normalizeDurationMs(order.durationMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeSplash(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("splash", "Config Desk splash frontend loaded", { windowLabel });
|
||||
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||
if (container) {
|
||||
container.style.opacity = "0";
|
||||
container.style.willChange = "opacity";
|
||||
frontendTrace("splash", "Splash container prepared for managed fade-in");
|
||||
}
|
||||
await listen<SplashOrderDto>("ksp-splash-order", event => {
|
||||
void handleSplashOrder(event.payload);
|
||||
});
|
||||
frontendDebug("splash", "Splash lifecycle listener installed");
|
||||
try {
|
||||
await invokeKsp<void>("splash", "splash_frontend_ready");
|
||||
} catch {
|
||||
replaceStatus("Le lifecycle du splash n'a pas pu démarrer.");
|
||||
if (container) {
|
||||
container.style.opacity = "1";
|
||||
container.style.willChange = "auto";
|
||||
}
|
||||
frontendError("splash", "Splash readiness command failed");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initializeSplash();
|
||||
});
|
||||
BIN
crates/ksp-app-config-desk/icons/favicon.ico
Normal file
BIN
crates/ksp-app-config-desk/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
BIN
crates/ksp-app-config-desk/icons/favicon.png
Normal file
BIN
crates/ksp-app-config-desk/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
31
crates/ksp-app-config-desk/package.json
Normal file
31
crates/ksp-app-config-desk/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ksp-app-config-desk",
|
||||
"private": true,
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||
"@fortawesome/fontawesome-free": "^7.3",
|
||||
"@tauri-apps/api": "^2.11",
|
||||
"bootstrap": "^5.3",
|
||||
"datatables.net-bs5": "^3.0",
|
||||
"datatables.net-select-bs5": "^4.0",
|
||||
"resize-observer-polyfill": "^1.5",
|
||||
"simplebar": "^6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11",
|
||||
"@types/bootstrap": "^5.2",
|
||||
"@types/node": "^26.1",
|
||||
"sass-embedded": "^1.102",
|
||||
"typescript": "^7.0",
|
||||
"vite": "^8.2"
|
||||
}
|
||||
}
|
||||
208
crates/ksp-app-config-desk/src/app_state.rs
Normal file
208
crates/ksp-app-config-desk/src/app_state.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
// file: crates/ksp-app-config-desk/src/app_state.rs
|
||||
// version: 8
|
||||
|
||||
//! Shared backend state owned by the Tauri application.
|
||||
|
||||
/// Shared Config Desk application state managed by Tauri.
|
||||
pub(crate) struct AppState {
|
||||
config_management: ksp_config_lib::ConfigManagement,
|
||||
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
|
||||
splash_settings: crate::SplashSettings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Initializes Config ownership, the Logging runtime and durable application state.
|
||||
pub(crate) fn initialize(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
|
||||
let config_management = crate::config_management(arguments);
|
||||
let config_management = match config_management {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime_identity = crate::launch_identity();
|
||||
let runtime_identity = match runtime_identity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let logging_startup = crate::initialize_logging(&config_management, &runtime_identity);
|
||||
let logging_startup = match logging_startup {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let splash_settings = crate::SplashSettings::load();
|
||||
let splash_settings = match splash_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
error_domain = error.code().domain(),
|
||||
error_code = error.code().code(),
|
||||
"managed splash timings are invalid; using transient in-memory defaults"
|
||||
);
|
||||
crate::SplashSettings::fallback()
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
minimum_ms = splash_settings.minimum_ms(),
|
||||
minimum_source = splash_settings.minimum_source(),
|
||||
fade_ms = splash_settings.fade_ms(),
|
||||
fade_source = splash_settings.fade_source(),
|
||||
expected_backend_lifecycle_ms = splash_settings.expected_backend_lifecycle_ms(),
|
||||
"resolved Config Desk splash timings"
|
||||
);
|
||||
return std::result::Result::Ok(Self {
|
||||
config_management,
|
||||
logging_runtime: std::sync::Mutex::new(LoggingRuntimeState {
|
||||
guard: logging_startup.guard,
|
||||
active_profile_id: logging_startup.active_profile_id,
|
||||
selection_source: logging_startup.selection_source,
|
||||
generation: 1,
|
||||
fallback_active: logging_startup.fallback_active,
|
||||
startup_diagnostic: logging_startup.startup_diagnostic,
|
||||
}),
|
||||
splash_settings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a safe initial frontend snapshot without exposing resolved secret values or arbitrary error context.
|
||||
pub(crate) fn snapshot(&self) -> ksp_core_lib::Result<crate::AppSnapshotDto> {
|
||||
let document_count = self.config_management.engine().registry().descriptors().count();
|
||||
let document_count = u32::try_from(document_count);
|
||||
let document_count = match document_count {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Config registry contains too many descriptors for the desktop DTO")
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let runtime = self.logging_runtime.lock();
|
||||
let runtime = match runtime {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||
"Config Desk Logging runtime state lock is poisoned",
|
||||
));
|
||||
},
|
||||
};
|
||||
let _keep_guard_alive = &runtime.guard;
|
||||
return std::result::Result::Ok(crate::AppSnapshotDto {
|
||||
application_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
config_document_count: document_count,
|
||||
active_logging_profile: runtime.active_profile_id.clone(),
|
||||
logging_generation: runtime.generation,
|
||||
fallback_logging_active: runtime.fallback_active,
|
||||
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the Config management facade owned by the application state.
|
||||
#[must_use]
|
||||
pub(crate) const fn config_management(&self) -> &ksp_config_lib::ConfigManagement {
|
||||
return &self.config_management;
|
||||
}
|
||||
|
||||
/// Replaces the active Logging runtime with freshly resolved settings and advances the runtime generation only after success.
|
||||
pub(crate) fn reinitialize_logging_runtime(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
selection_source: &str,
|
||||
settings: &ksp_logging_lib::LoggingSettings,
|
||||
) -> ksp_core_lib::Result<u32> {
|
||||
let runtime = self.logging_runtime.lock();
|
||||
let mut runtime = match runtime {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||
"Config Desk Logging runtime state lock is poisoned",
|
||||
));
|
||||
},
|
||||
};
|
||||
let reinitialized = ksp_logging_lib::reinitialize(&mut runtime.guard, settings);
|
||||
if let std::result::Result::Err(error) = reinitialized {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
runtime.active_profile_id = std::option::Option::Some(profile_id.to_owned());
|
||||
runtime.selection_source = selection_source.to_owned();
|
||||
runtime.generation = runtime.generation.saturating_add(1);
|
||||
runtime.fallback_active = false;
|
||||
runtime.startup_diagnostic = std::option::Option::None;
|
||||
return std::result::Result::Ok(runtime.generation);
|
||||
}
|
||||
|
||||
/// Returns a safe observable snapshot of the currently active Logging runtime.
|
||||
pub(crate) fn logging_runtime_status(&self) -> ksp_core_lib::Result<crate::LoggingRuntimeStatusDto> {
|
||||
let runtime = self.logging_runtime.lock();
|
||||
let runtime = match runtime {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_LOCK_FAILED,
|
||||
"Config Desk Logging runtime state lock is poisoned",
|
||||
));
|
||||
},
|
||||
};
|
||||
let identity = runtime.guard.runtime_identity();
|
||||
let identity = match identity {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||
"Config Desk Logging runtime is missing its application launch identity",
|
||||
));
|
||||
},
|
||||
};
|
||||
let console_enabled = match runtime.guard.settings().console() {
|
||||
std::option::Option::Some(console) => console.enabled(),
|
||||
std::option::Option::None => false,
|
||||
};
|
||||
let dropped = runtime.guard.dropped_lines();
|
||||
let mut files = std::vec::Vec::<crate::LoggingRuntimeFileDto>::new();
|
||||
for metadata in runtime.guard.active_file_outputs() {
|
||||
files.push(crate::project_logging_file(&metadata));
|
||||
}
|
||||
return std::result::Result::Ok(crate::LoggingRuntimeStatusDto {
|
||||
active_profile: runtime.active_profile_id.clone(),
|
||||
selection_source: runtime.selection_source.clone(),
|
||||
generation: runtime.generation,
|
||||
fallback_active: runtime.fallback_active,
|
||||
application_id: identity.application_id().to_owned(),
|
||||
launch_timestamp: identity.launch_timestamp().to_owned(),
|
||||
console_enabled,
|
||||
dropped_console_lines: crate::count_to_u64(dropped.console()),
|
||||
dropped_file_lines: crate::count_to_u64(dropped.file()),
|
||||
dropped_total_lines: crate::count_to_u64(dropped.total()),
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the resolved splash timings captured during application bootstrap.
|
||||
#[must_use]
|
||||
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
|
||||
return self.splash_settings;
|
||||
}
|
||||
|
||||
/// Marks the one-shot splash lifecycle as started and reports whether this caller won the transition.
|
||||
pub(crate) fn begin_splash_sequence(&self) -> bool {
|
||||
return self
|
||||
.splash_sequence_started
|
||||
.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
||||
.is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
struct LoggingRuntimeState {
|
||||
guard: ksp_logging_lib::LoggingGuard,
|
||||
active_profile_id: std::option::Option<String>,
|
||||
selection_source: String,
|
||||
generation: u32,
|
||||
fallback_active: bool,
|
||||
startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
154
crates/ksp-app-config-desk/src/bootstrap.rs
Normal file
154
crates/ksp-app-config-desk/src/bootstrap.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
// file: crates/ksp-app-config-desk/src/bootstrap.rs
|
||||
// version: 6
|
||||
|
||||
//! Config and Logging bootstrap for the desktop application.
|
||||
|
||||
/// Crate-internal `LoggingStartup` state shared across the owning crate.
|
||||
pub(crate) struct LoggingStartup {
|
||||
/// Stores the guard value for this state.
|
||||
pub(crate) guard: ksp_logging_lib::LoggingGuard,
|
||||
/// Stores the active profile id value for this state.
|
||||
pub(crate) active_profile_id: std::option::Option<String>,
|
||||
/// Stores the selection source value for this state.
|
||||
pub(crate) selection_source: String,
|
||||
/// Stores the fallback active value for this state.
|
||||
pub(crate) fallback_active: bool,
|
||||
/// Stores the startup diagnostic value for this state.
|
||||
pub(crate) startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
|
||||
enum LoggingStartupPlan {
|
||||
Managed {
|
||||
active_profile_id: String,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
},
|
||||
Fallback {
|
||||
initial_error: ksp_core_lib::Error,
|
||||
diagnostic: crate::CommandErrorDto,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
},
|
||||
}
|
||||
|
||||
/// Executes the crate-internal config management operation for the owning module.
|
||||
pub(crate) fn config_management(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_args(arguments);
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::from_args(arguments);
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(engine));
|
||||
}
|
||||
|
||||
/// Executes the crate-internal initialize logging operation for the owning module.
|
||||
pub(crate) fn initialize_logging(
|
||||
management: &ksp_config_lib::ConfigManagement,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let plan = resolve_logging_startup(management);
|
||||
return match plan {
|
||||
LoggingStartupPlan::Managed { active_profile_id, settings } => {
|
||||
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||
match guard {
|
||||
std::result::Result::Ok(guard) => {
|
||||
ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
|
||||
active_profile = active_profile_id.as_str(),
|
||||
"initialized Config Desk logging from managed configuration"
|
||||
);
|
||||
std::result::Result::Ok(LoggingStartup {
|
||||
guard,
|
||||
active_profile_id: std::option::Option::Some(active_profile_id),
|
||||
selection_source: "default_profile".to_owned(),
|
||||
fallback_active: false,
|
||||
startup_diagnostic: std::option::Option::None,
|
||||
})
|
||||
},
|
||||
std::result::Result::Err(error) => initialize_fallback_logging(error, runtime_identity),
|
||||
}
|
||||
},
|
||||
LoggingStartupPlan::Fallback { initial_error, diagnostic, settings } => {
|
||||
initialize_planned_fallback_logging(initial_error, diagnostic, settings, runtime_identity)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn fallback_logging_settings() -> ksp_logging_lib::LoggingSettings {
|
||||
return ksp_logging_lib::LoggingSettings::new(
|
||||
ksp_logging_lib::LogFilterLevel::Info,
|
||||
ksp_logging_lib::SpanEvents::Off,
|
||||
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve_logging_startup(management: &ksp_config_lib::ConfigManagement) -> LoggingStartupPlan {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return fallback_startup_plan(error),
|
||||
};
|
||||
return LoggingStartupPlan::Managed { active_profile_id: resolved.profile_id().to_owned(), settings: resolved.into_settings() };
|
||||
}
|
||||
|
||||
fn fallback_startup_plan(initial_error: ksp_core_lib::Error) -> LoggingStartupPlan {
|
||||
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||
return LoggingStartupPlan::Fallback { initial_error, diagnostic, settings: fallback_logging_settings() };
|
||||
}
|
||||
|
||||
fn initialize_fallback_logging(
|
||||
initial_error: ksp_core_lib::Error,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let diagnostic = crate::CommandErrorDto::from_error(&initial_error);
|
||||
return initialize_planned_fallback_logging(initial_error, diagnostic, fallback_logging_settings(), runtime_identity);
|
||||
}
|
||||
|
||||
fn initialize_planned_fallback_logging(
|
||||
initial_error: ksp_core_lib::Error,
|
||||
diagnostic: crate::CommandErrorDto,
|
||||
settings: ksp_logging_lib::LoggingSettings,
|
||||
runtime_identity: &ksp_logging_lib::LoggingRuntimeIdentity,
|
||||
) -> ksp_core_lib::Result<crate::LoggingStartup> {
|
||||
let guard = ksp_logging_lib::initialize_with_identity(&settings, runtime_identity);
|
||||
let guard = match guard {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(fallback_error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED, "Cannot initialize Config Desk fallback Logging runtime")
|
||||
.with_context("initial_error_domain", initial_error.code().domain())
|
||||
.with_context("initial_error_code", initial_error.code().code())
|
||||
.with_source(fallback_error),
|
||||
);
|
||||
},
|
||||
};
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_BOOTSTRAP,
|
||||
error_domain = diagnostic.domain.as_str(),
|
||||
error_code = diagnostic.code.as_str(),
|
||||
"managed Logging configuration is unavailable; using transient in-memory fallback"
|
||||
);
|
||||
return std::result::Result::Ok(LoggingStartup {
|
||||
guard,
|
||||
active_profile_id: std::option::Option::None,
|
||||
selection_source: "fallback".to_owned(),
|
||||
fallback_active: true,
|
||||
startup_diagnostic: std::option::Option::Some(diagnostic),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/bootstrap.rs"]
|
||||
mod tests;
|
||||
35
crates/ksp-app-config-desk/src/constants.rs
Normal file
35
crates/ksp-app-config-desk/src/constants.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// file: crates/ksp-app-config-desk/src/constants.rs
|
||||
// version: 10
|
||||
|
||||
//! Application-owned tracing targets and domains.
|
||||
|
||||
/// Structured domain used while bootstrapping Config and Logging.
|
||||
pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
|
||||
/// Structured domain for Config document inventory, diagnostics and repair.
|
||||
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
|
||||
/// Structured domain for safe Config environment reports.
|
||||
pub(crate) const TRACING_DOMAIN_ENVIRONMENT: &str = "config.environment";
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
/// Structured domain for typed Logging editor inspection.
|
||||
pub(crate) const TRACING_DOMAIN_LOGGING_EDITOR: &str = "config.logging_editor";
|
||||
/// Structured domain for active Logging runtime metadata and explicit profile application.
|
||||
pub(crate) const TRACING_DOMAIN_LOGGING_RUNTIME: &str = "config.logging_runtime";
|
||||
/// Structured domain used by controlled Logging test events.
|
||||
pub(crate) const TRACING_DOMAIN_LOGGING_TEST: &str = "config.logging_test";
|
||||
/// Structured domain for Config profile selection and provenance inspection.
|
||||
pub(crate) const TRACING_DOMAIN_PROFILES: &str = "config.profiles";
|
||||
/// Structured domain for explicit privileged Secret reveal operations.
|
||||
pub(crate) const TRACING_DOMAIN_SECRETS: &str = "config.secrets";
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||
/// Owning target for backend events emitted by Config Desk.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-app-config-desk";
|
||||
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND: &str = "ksp-app-config-desk.frontend";
|
||||
/// Owning target for main-window frontend events.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND_MAIN: &str = "ksp-app-config-desk.frontend.main";
|
||||
/// Owning target for splash-window frontend events.
|
||||
pub(crate) const TRACING_TARGET_FRONTEND_SPLASH: &str = "ksp-app-config-desk.frontend.splash";
|
||||
/// Dedicated target used by the controlled Logging test panel.
|
||||
pub(crate) const TRACING_TARGET_LOGGING_TEST: &str = "ksp-app-config-desk.logging-test";
|
||||
275
crates/ksp-app-config-desk/src/documents.rs
Normal file
275
crates/ksp-app-config-desk/src/documents.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
// file: crates/ksp-app-config-desk/src/documents.rs
|
||||
// version: 3
|
||||
|
||||
//! Generic Config document inventory, diagnostics and validated repair services.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
const STAGE_EFFECTIVE: &str = "effective";
|
||||
const STAGE_JSON: &str = "json";
|
||||
const STAGE_OTHER: &str = "other";
|
||||
const STAGE_READ: &str = "read";
|
||||
const STAGE_SCHEMA: &str = "schema";
|
||||
const STAGE_SEMANTIC: &str = "semantic";
|
||||
const STAGE_VALID: &str = "valid";
|
||||
const STATUS_INVALID: &str = "invalid";
|
||||
const STATUS_VALID: &str = "valid";
|
||||
|
||||
/// Safe inventory row for one Config document registered by `ksp-config-lib`.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto.ts")]
|
||||
pub(crate) struct ConfigDocumentSummaryDto {
|
||||
/// Stable logical Config identifier.
|
||||
pub(crate) file_id: String,
|
||||
/// Current Config-managed relative filename mapping.
|
||||
pub(crate) filename: String,
|
||||
/// Registered validation schema identifier.
|
||||
pub(crate) schema_file_id: std::option::Option<String>,
|
||||
/// Resolved filesystem path controlled by Config.
|
||||
pub(crate) path: String,
|
||||
/// `valid` or `invalid` according to the backend Config authority.
|
||||
pub(crate) validation_status: String,
|
||||
/// Backend diagnostic stage: valid/read/json/schema/semantic/effective/other.
|
||||
pub(crate) diagnostic_stage: String,
|
||||
/// Safe bounded diagnostic when the document is invalid.
|
||||
pub(crate) diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
|
||||
/// Detailed management view for one registered Config document.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto.ts")]
|
||||
pub(crate) struct ConfigDocumentDetailDto {
|
||||
/// Current validation/inventory summary.
|
||||
pub(crate) summary: ConfigDocumentSummaryDto,
|
||||
/// Raw Config-managed source when the file can be read, even if its JSON/schema/semantics are invalid.
|
||||
pub(crate) source: std::option::Option<String>,
|
||||
}
|
||||
|
||||
/// Result of one validated raw-source repair attempt that reached persistence successfully.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto.ts")]
|
||||
pub(crate) struct ConfigDocumentSaveResultDto {
|
||||
/// Whether persisted source bytes changed.
|
||||
pub(crate) source_changed: bool,
|
||||
/// Whether Config consumers need a reload to observe the changed document.
|
||||
pub(crate) reload_required: bool,
|
||||
/// Fresh detail reloaded after persistence.
|
||||
pub(crate) document: ConfigDocumentDetailDto,
|
||||
}
|
||||
|
||||
/// Safe document-specific command error with backend-owned diagnostic classification.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto.ts")]
|
||||
pub(crate) struct ConfigDocumentErrorDto {
|
||||
/// Backend diagnostic stage associated with the failure.
|
||||
pub(crate) diagnostic_stage: String,
|
||||
/// Bounded KSP error projection without arbitrary context/source values.
|
||||
pub(crate) error: crate::CommandErrorDto,
|
||||
}
|
||||
|
||||
impl ConfigDocumentErrorDto {
|
||||
fn from_error(error: &ksp_core_lib::Error) -> Self {
|
||||
return Self { diagnostic_stage: classify_error(error).to_owned(), error: crate::CommandErrorDto::from_error(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all Config-kind documents from the Config registry and evaluates their current backend validation state.
|
||||
pub(crate) fn document_inventory(state: &crate::AppState) -> std::vec::Vec<ConfigDocumentSummaryDto> {
|
||||
let management = state.config_management();
|
||||
let engine = management.engine();
|
||||
let mut documents = std::vec::Vec::new();
|
||||
for descriptor in engine.registry().descriptors() {
|
||||
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
|
||||
continue;
|
||||
}
|
||||
documents.push(build_summary(management, descriptor));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
document_count = documents.len(),
|
||||
"Config document inventory evaluated"
|
||||
);
|
||||
return documents;
|
||||
}
|
||||
|
||||
/// Loads one Config document detail, including raw source when Config can read it.
|
||||
pub(crate) fn document_detail(state: &crate::AppState, file_id_text: &str) -> std::result::Result<ConfigDocumentDetailDto, ConfigDocumentErrorDto> {
|
||||
let file_id = parse_config_file_id(file_id_text);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let management = state.config_management();
|
||||
let descriptor = management.engine().registry().descriptor(&file_id);
|
||||
let descriptor = match descriptor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
|
||||
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_KIND_INVALID, "requested document is not a Config-kind document");
|
||||
return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error));
|
||||
}
|
||||
let summary = build_summary(management, descriptor);
|
||||
let source = management.read_source(&file_id);
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value.content().to_owned()),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
file_id = file_id.as_str(),
|
||||
validation_status = summary.validation_status.as_str(),
|
||||
diagnostic_stage = summary.diagnostic_stage.as_str(),
|
||||
source_available = source.is_some(),
|
||||
"Config document detail loaded"
|
||||
);
|
||||
return std::result::Result::Ok(ConfigDocumentDetailDto { summary, source });
|
||||
}
|
||||
|
||||
/// Validates and atomically persists one raw Config source candidate, then reloads the document detail.
|
||||
pub(crate) fn save_document_source(
|
||||
state: &crate::AppState,
|
||||
file_id_text: &str,
|
||||
source: &str,
|
||||
) -> std::result::Result<ConfigDocumentSaveResultDto, ConfigDocumentErrorDto> {
|
||||
let file_id = parse_config_file_id(file_id_text);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let report = state.config_management().save_source_candidate(&file_id, source);
|
||||
let report = match report {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let document = crate::document_detail(state, file_id.as_str());
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
file_id = file_id.as_str(),
|
||||
source_changed = report.source_changed(),
|
||||
reload_required = report.reload_required(),
|
||||
"validated Config document source candidate persisted"
|
||||
);
|
||||
return std::result::Result::Ok(ConfigDocumentSaveResultDto {
|
||||
source_changed: report.source_changed(),
|
||||
reload_required: report.reload_required(),
|
||||
document,
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_config_file_id(value: &str) -> ksp_core_lib::Result<ksp_config_lib::ConfigFileId> {
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(value.to_owned());
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(file_id);
|
||||
}
|
||||
|
||||
fn build_summary(management: &ksp_config_lib::ConfigManagement, descriptor: &ksp_config_lib::ConfigFileDescriptor) -> ConfigDocumentSummaryDto {
|
||||
let engine = management.engine();
|
||||
let path = engine.registry().resolve_path(engine.bootstrap(), descriptor.file_id());
|
||||
let path = match path {
|
||||
std::result::Result::Ok(value) => value.to_string_lossy().into_owned(),
|
||||
std::result::Result::Err(_) => descriptor.filename().to_string_lossy().into_owned(),
|
||||
};
|
||||
let validation = validate_effective_document(management, descriptor.file_id());
|
||||
let (validation_status, diagnostic_stage, diagnostic) = match validation {
|
||||
std::result::Result::Ok(()) => (STATUS_VALID.to_owned(), STAGE_VALID.to_owned(), std::option::Option::None),
|
||||
std::result::Result::Err(error) => {
|
||||
(STATUS_INVALID.to_owned(), classify_error(&error).to_owned(), std::option::Option::Some(crate::CommandErrorDto::from_error(&error)))
|
||||
},
|
||||
};
|
||||
return ConfigDocumentSummaryDto {
|
||||
file_id: descriptor.file_id().as_str().to_owned(),
|
||||
filename: descriptor.filename().to_string_lossy().into_owned(),
|
||||
schema_file_id: descriptor.schema_file_id().map(|value| {
|
||||
return value.as_str().to_owned();
|
||||
}),
|
||||
path,
|
||||
validation_status,
|
||||
diagnostic_stage,
|
||||
diagnostic,
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_effective_document(management: &ksp_config_lib::ConfigManagement, file_id: &ksp_config_lib::ConfigFileId) -> ksp_core_lib::Result<()> {
|
||||
let validated = management.engine().load_validated_document(file_id);
|
||||
match validated {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if file_id.as_str() != ksp_config_lib::FILE_ID_STD_LOGGING {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
|
||||
return match resolved {
|
||||
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn classify_error(error: &ksp_core_lib::Error) -> &'static str {
|
||||
let code = error.code();
|
||||
if code == ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED {
|
||||
if error_context_file_id_is_schema(error) {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
return STAGE_READ;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID {
|
||||
if error_context_file_id_is_schema(error) {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
return STAGE_JSON;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_SCHEMA_INVALID || code == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_PROFILE_NOT_FOUND
|
||||
|| code == ksp_config_lib::ERROR_CODE_COMPOSITE_REFERENCE_INVALID
|
||||
{
|
||||
return STAGE_SEMANTIC;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VALUE_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING
|
||||
|| code == ksp_config_lib::ERROR_CODE_DOTENV_FILE_READ_FAILED
|
||||
|| code == ksp_config_lib::ERROR_CODE_DOTENV_SYNTAX_INVALID
|
||||
{
|
||||
return STAGE_EFFECTIVE;
|
||||
}
|
||||
return STAGE_OTHER;
|
||||
}
|
||||
|
||||
fn error_context_file_id_is_schema(error: &ksp_core_lib::Error) -> bool {
|
||||
for field in error.context() {
|
||||
if field.key() == "file_id" {
|
||||
return field.value().starts_with("schema.");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/documents.rs"]
|
||||
mod tests;
|
||||
54
crates/ksp-app-config-desk/src/dto_common.rs
Normal file
54
crates/ksp-app-config-desk/src/dto_common.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
// file: crates/ksp-app-config-desk/src/dto_common.rs
|
||||
// version: 2
|
||||
|
||||
//! Common Tauri DTOs shared by Config Desk commands.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/CommandErrorDto.ts")]
|
||||
pub(crate) struct CommandErrorDto {
|
||||
/// Stable KSP error domain.
|
||||
pub(crate) domain: String,
|
||||
/// Stable KSP error code within the domain.
|
||||
pub(crate) code: String,
|
||||
/// Human-readable error message without arbitrary context fields.
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl CommandErrorDto {
|
||||
/// Builds a bounded safe projection from a KSP error.
|
||||
#[must_use]
|
||||
pub(crate) fn from_error(error: &ksp_core_lib::Error) -> Self {
|
||||
return Self {
|
||||
domain: error.code().domain().to_owned(),
|
||||
code: error.code().code().to_owned(),
|
||||
message: error.message().to_owned(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial application/runtime snapshot exposed to the frontend.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts")]
|
||||
pub(crate) struct AppSnapshotDto {
|
||||
/// Cargo application version.
|
||||
pub(crate) application_version: String,
|
||||
/// Number of files registered by the Config registry.
|
||||
pub(crate) config_document_count: u32,
|
||||
/// Active configured Logging profile, or `None` while the transient fallback runtime is active.
|
||||
pub(crate) active_logging_profile: std::option::Option<String>,
|
||||
/// Monotonic runtime generation, initialized to one after the first Logging runtime install.
|
||||
pub(crate) logging_generation: u32,
|
||||
/// Whether Config Desk had to install its transient in-memory Logging fallback.
|
||||
pub(crate) fallback_logging_active: bool,
|
||||
/// Safe startup diagnostic that caused fallback Logging, when applicable.
|
||||
pub(crate) startup_diagnostic: std::option::Option<CommandErrorDto>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/dto_common.rs"]
|
||||
mod tests;
|
||||
231
crates/ksp-app-config-desk/src/environment.rs
Normal file
231
crates/ksp-app-config-desk/src/environment.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
// file: crates/ksp-app-config-desk/src/environment.rs
|
||||
// version: 4
|
||||
|
||||
//! Safe Config environment reports and `.env` management projections for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Safe desired/effective environment view exposed to the ordinary Config Desk frontend.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/environment/ConfigEnvironmentReportDto.ts")]
|
||||
pub(crate) struct ConfigEnvironmentReportDto {
|
||||
/// Supported KSP/KSPB variable name.
|
||||
pub(crate) variable_name: String,
|
||||
/// Namespace family derived from the variable name without inspecting its value.
|
||||
pub(crate) namespace: String,
|
||||
/// `public`, `internal` or `secret`.
|
||||
pub(crate) sensitivity: String,
|
||||
/// Persisted `.env` value using Config's safe/redacted representation.
|
||||
pub(crate) desired_safe_value: std::option::Option<String>,
|
||||
/// Currently effective process-or-`.env` value using Config's safe/redacted representation.
|
||||
pub(crate) effective_safe_value: std::option::Option<String>,
|
||||
/// Winning external source: `process`, `dotenv`, or `none`.
|
||||
pub(crate) effective_source: String,
|
||||
/// Whether the persisted `.env` value is shadowed by the inherited process environment.
|
||||
pub(crate) shadowed_by_process_environment: bool,
|
||||
}
|
||||
|
||||
/// Safe result metadata for one `.env` mutation.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/environment/ConfigEnvironmentChangeDto.ts")]
|
||||
pub(crate) struct ConfigEnvironmentChangeDto {
|
||||
/// Variable targeted by the operation; never its value.
|
||||
pub(crate) variable_name: String,
|
||||
/// Stable operation label: `set` or `remove`.
|
||||
pub(crate) operation: String,
|
||||
/// Sensitivity classified by Config from the variable namespace.
|
||||
pub(crate) sensitivity: String,
|
||||
/// Whether the persisted `.env` source bytes changed.
|
||||
pub(crate) source_changed: bool,
|
||||
/// Whether the effective process-or-`.env` value changed for the running process.
|
||||
pub(crate) effective_changed: bool,
|
||||
/// Whether the resulting `.env` entry is shadowed by inherited process environment.
|
||||
pub(crate) shadowed_by_process_environment: bool,
|
||||
/// Whether consumers holding an environment snapshot must reload to observe the effective change.
|
||||
pub(crate) reload_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum EnvironmentMutationOperation {
|
||||
Set,
|
||||
Remove,
|
||||
}
|
||||
|
||||
impl EnvironmentMutationOperation {
|
||||
const fn label(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Set => "set",
|
||||
Self::Remove => "remove",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the safe environment report owned by Config.
|
||||
pub(crate) fn environment_report(state: &crate::AppState) -> ksp_core_lib::Result<std::vec::Vec<ConfigEnvironmentReportDto>> {
|
||||
return report_from_management(state.config_management());
|
||||
}
|
||||
|
||||
/// Creates or replaces one `.env` entry exclusively through ConfigManagement.
|
||||
pub(crate) fn set_environment_value(state: &crate::AppState, variable_name: &str, value: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
|
||||
return set_value_from_management(state.config_management(), variable_name, value);
|
||||
}
|
||||
|
||||
/// Removes one `.env` entry exclusively through ConfigManagement.
|
||||
pub(crate) fn remove_environment_value(state: &crate::AppState, variable_name: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
|
||||
return remove_value_from_management(state.config_management(), variable_name);
|
||||
}
|
||||
|
||||
fn report_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<std::vec::Vec<ConfigEnvironmentReportDto>> {
|
||||
let reports = management.environment_report();
|
||||
let reports = match reports {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut result = std::vec::Vec::<ConfigEnvironmentReportDto>::with_capacity(reports.len());
|
||||
let mut process_count = 0usize;
|
||||
let mut dotenv_count = 0usize;
|
||||
let mut shadowed_count = 0usize;
|
||||
let mut secret_count = 0usize;
|
||||
for report in reports {
|
||||
let source = report.effective_source();
|
||||
match source {
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process) => process_count += 1,
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv) => dotenv_count += 1,
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback) | std::option::Option::None => {},
|
||||
}
|
||||
if report.shadowed_by_process_environment() {
|
||||
shadowed_count += 1;
|
||||
}
|
||||
if report.sensitivity() == ksp_config_lib::ConfigSensitivity::Secret {
|
||||
secret_count += 1;
|
||||
}
|
||||
result.push(ConfigEnvironmentReportDto {
|
||||
variable_name: report.variable_name().to_owned(),
|
||||
namespace: namespace_label(report.variable_name()).to_owned(),
|
||||
sensitivity: sensitivity_label(report.sensitivity()).to_owned(),
|
||||
desired_safe_value: report.desired_safe_value().map(str::to_owned),
|
||||
effective_safe_value: report.effective_safe_value().map(str::to_owned),
|
||||
effective_source: environment_source_label(source).to_owned(),
|
||||
shadowed_by_process_environment: report.shadowed_by_process_environment(),
|
||||
});
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_ENVIRONMENT,
|
||||
variable_count = result.len(),
|
||||
process_count,
|
||||
dotenv_count,
|
||||
shadowed_count,
|
||||
secret_count,
|
||||
"Config environment report evaluated"
|
||||
);
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
fn set_value_from_management(
|
||||
management: &ksp_config_lib::ConfigManagement,
|
||||
variable_name: &str,
|
||||
value: &str,
|
||||
) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
|
||||
let sensitivity = mutation_sensitivity(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let report = management.set_dotenv_value(variable_name, value);
|
||||
let report = match report {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(mutation_result(variable_name, EnvironmentMutationOperation::Set, sensitivity, report));
|
||||
}
|
||||
|
||||
fn remove_value_from_management(management: &ksp_config_lib::ConfigManagement, variable_name: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeDto> {
|
||||
let sensitivity = mutation_sensitivity(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let report = management.remove_dotenv_value(variable_name);
|
||||
let report = match report {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(mutation_result(variable_name, EnvironmentMutationOperation::Remove, sensitivity, report));
|
||||
}
|
||||
|
||||
fn mutation_result(
|
||||
variable_name: &str,
|
||||
operation: EnvironmentMutationOperation,
|
||||
sensitivity: ksp_config_lib::ConfigSensitivity,
|
||||
report: ksp_config_lib::ConfigEnvironmentChangeReport,
|
||||
) -> ConfigEnvironmentChangeDto {
|
||||
let result = ConfigEnvironmentChangeDto {
|
||||
variable_name: variable_name.to_owned(),
|
||||
operation: operation.label().to_owned(),
|
||||
sensitivity: sensitivity_label(sensitivity).to_owned(),
|
||||
source_changed: report.source_changed(),
|
||||
effective_changed: report.effective_changed(),
|
||||
shadowed_by_process_environment: report.shadowed_by_process_environment(),
|
||||
reload_required: report.reload_required(),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_ENVIRONMENT,
|
||||
variable_name = result.variable_name.as_str(),
|
||||
operation = result.operation.as_str(),
|
||||
sensitivity = result.sensitivity.as_str(),
|
||||
source_changed = result.source_changed,
|
||||
effective_changed = result.effective_changed,
|
||||
shadowed_by_process_environment = result.shadowed_by_process_environment,
|
||||
reload_required = result.reload_required,
|
||||
"Config .env mutation completed"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
fn mutation_sensitivity(variable_name: &str) -> ksp_core_lib::Result<ksp_config_lib::ConfigSensitivity> {
|
||||
return ksp_config_lib::ConfigSensitivity::from_variable_name(variable_name);
|
||||
}
|
||||
|
||||
const fn environment_source_label(source: std::option::Option<ksp_config_lib::ConfigEnvironmentSource>) -> &'static str {
|
||||
return match source {
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process) => "process",
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv) => "dotenv",
|
||||
std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback) => "fallback",
|
||||
std::option::Option::None => "none",
|
||||
};
|
||||
}
|
||||
|
||||
const fn sensitivity_label(sensitivity: ksp_config_lib::ConfigSensitivity) -> &'static str {
|
||||
return match sensitivity {
|
||||
ksp_config_lib::ConfigSensitivity::Public => "public",
|
||||
ksp_config_lib::ConfigSensitivity::Internal => "internal",
|
||||
ksp_config_lib::ConfigSensitivity::Secret => "secret",
|
||||
};
|
||||
}
|
||||
|
||||
fn namespace_label(variable_name: &str) -> &'static str {
|
||||
if variable_name.starts_with("KSPB_SECRET_") {
|
||||
return "KSPB_SECRET";
|
||||
}
|
||||
if variable_name.starts_with("KSPB_PUBLIC_") {
|
||||
return "KSPB_PUBLIC";
|
||||
}
|
||||
if variable_name.starts_with("KSPB_") {
|
||||
return "KSPB";
|
||||
}
|
||||
if variable_name.starts_with("KSP_SECRET_") {
|
||||
return "KSP_SECRET";
|
||||
}
|
||||
if variable_name.starts_with("KSP_PUBLIC_") {
|
||||
return "KSP_PUBLIC";
|
||||
}
|
||||
return "KSP";
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/environment.rs"]
|
||||
mod tests;
|
||||
42
crates/ksp-app-config-desk/src/errors.rs
Normal file
42
crates/ksp-app-config-desk/src/errors.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
// file: crates/ksp-app-config-desk/src/errors.rs
|
||||
// version: 10
|
||||
|
||||
//! Application-local error codes for the configuration desktop shell.
|
||||
|
||||
/// Shared Config Desk application state is internally inconsistent.
|
||||
pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_invalid");
|
||||
/// Shared Config Desk runtime state cannot be locked safely.
|
||||
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "app_state_lock_failed");
|
||||
/// A Documents command requested a registered file that is not a Config-kind document.
|
||||
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");
|
||||
/// Frontend logging requested an unsupported level.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_level_invalid");
|
||||
/// Frontend logging requested a target outside the application whitelist.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "frontend_log_target_invalid");
|
||||
/// Config Desk could not install the managed Logging runtime or its safe fallback.
|
||||
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_bootstrap_failed");
|
||||
/// Config Desk persisted a Logging candidate but could not restore the previous source after runtime application failed.
|
||||
pub(crate) const ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("config_desk", "logging_source_rollback_failed");
|
||||
/// Controlled Logging test request contains an unsupported or invalid selector.
|
||||
pub(crate) const ERROR_CODE_LOGGING_TEST_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "logging_test_request_invalid");
|
||||
/// A validated Config document does not expose the standard profile contract expected by the Profiles panel.
|
||||
pub(crate) const ERROR_CODE_PROFILE_CONTRACT_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_contract_missing");
|
||||
/// Config profile data could not be projected safely for the frontend.
|
||||
pub(crate) const ERROR_CODE_PROFILE_PROJECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "profile_projection_failed");
|
||||
/// Privileged reveal was requested for a non-Secret KSP/KSPB namespace.
|
||||
pub(crate) const ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_requires_secret");
|
||||
/// Privileged reveal requested an unsupported source selector.
|
||||
pub(crate) const ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "secret_reveal_source_invalid");
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_origin_invalid");
|
||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||
pub(crate) const ERROR_CODE_SPLASH_SETTING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "splash_setting_invalid");
|
||||
/// Tauri runtime assembly or execution failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_RUNTIME_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_runtime_failed");
|
||||
/// A required Tauri window is missing from the configured application runtime.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_missing");
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");
|
||||
205
crates/ksp-app-config-desk/src/frontend_logging.rs
Normal file
205
crates/ksp-app-config-desk/src/frontend_logging.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// file: crates/ksp-app-config-desk/src/frontend_logging.rs
|
||||
// version: 2
|
||||
|
||||
//! KSP-owned bridge for technical log events emitted by Config Desk frontend scripts.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Log payload sent by Config Desk frontend scripts.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/frontend_logging/FrontendLogPayloadDto.ts")]
|
||||
pub(crate) struct FrontendLogPayloadDto {
|
||||
/// Lowercase KSP log level.
|
||||
pub(crate) level: String,
|
||||
/// Whitelisted logical frontend target identifier.
|
||||
pub(crate) target_id: String,
|
||||
/// Rendered technical frontend message.
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FrontendLogLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum FrontendLogTarget {
|
||||
Frontend,
|
||||
Main,
|
||||
Splash,
|
||||
}
|
||||
|
||||
/// Emits one validated frontend event through the KSP Logging facade.
|
||||
pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> {
|
||||
let level = parse_level(payload.level.as_str());
|
||||
let level = match level {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let target = parse_target(payload.target_id.as_str());
|
||||
let target = match target {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
emit_validated_frontend_log(level, target, payload.message.as_str());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn parse_level(level: &str) -> ksp_core_lib::Result<FrontendLogLevel> {
|
||||
return match level.trim().to_ascii_lowercase().as_str() {
|
||||
"trace" => std::result::Result::Ok(FrontendLogLevel::Trace),
|
||||
"debug" => std::result::Result::Ok(FrontendLogLevel::Debug),
|
||||
"info" => std::result::Result::Ok(FrontendLogLevel::Info),
|
||||
"warn" => std::result::Result::Ok(FrontendLogLevel::Warn),
|
||||
"error" => std::result::Result::Ok(FrontendLogLevel::Error),
|
||||
_ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")),
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_target(target_id: &str) -> ksp_core_lib::Result<FrontendLogTarget> {
|
||||
return match target_id.trim().to_ascii_lowercase().as_str() {
|
||||
"frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend),
|
||||
"main" => std::result::Result::Ok(FrontendLogTarget::Main),
|
||||
"splash" => std::result::Result::Ok(FrontendLogTarget::Splash),
|
||||
_ => {
|
||||
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) {
|
||||
return match target {
|
||||
FrontendLogTarget::Frontend => emit_frontend_target(level, message),
|
||||
FrontendLogTarget::Main => emit_main_target(level, message),
|
||||
FrontendLogTarget::Splash => emit_splash_target(level, message),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_frontend_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "frontend",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_main_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_MAIN,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "main",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_splash_target(level: FrontendLogLevel, message: &str) {
|
||||
return match level {
|
||||
FrontendLogLevel::Trace => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Debug => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Info => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Warn => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
FrontendLogLevel::Error => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_FRONTEND_SPLASH,
|
||||
domain = crate::TRACING_DOMAIN_FRONTEND,
|
||||
action = "frontend_log",
|
||||
target_id = "splash",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/frontend_logging.rs"]
|
||||
mod tests;
|
||||
200
crates/ksp-app-config-desk/src/lib.rs
Normal file
200
crates/ksp-app-config-desk/src/lib.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
// file: crates/ksp-app-config-desk/src/lib.rs
|
||||
// version: 16
|
||||
|
||||
//! Tauri desktop application for managing and validating KSP configuration.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod app_state;
|
||||
mod bootstrap;
|
||||
mod constants;
|
||||
mod documents;
|
||||
mod dto_common;
|
||||
mod environment;
|
||||
mod errors;
|
||||
mod frontend_logging;
|
||||
mod logging_editor;
|
||||
mod logging_runtime;
|
||||
mod logging_test;
|
||||
mod profiles;
|
||||
mod secrets;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
mod tw_main;
|
||||
mod tw_splash;
|
||||
|
||||
/// Runs the KSP configuration desktop application.
|
||||
pub use self::tauri::run;
|
||||
|
||||
/// Shared Config Desk application state managed by Tauri.
|
||||
pub(crate) use self::app_state::AppState;
|
||||
/// Crate-internal `LoggingStartup` state shared across the owning crate.
|
||||
pub(crate) use self::bootstrap::LoggingStartup;
|
||||
/// Executes the crate-internal config management operation for the owning module.
|
||||
pub(crate) use self::bootstrap::config_management;
|
||||
/// Executes the crate-internal initialize logging operation for the owning module.
|
||||
pub(crate) use self::bootstrap::initialize_logging;
|
||||
/// Structured domain used while bootstrapping Config and Logging.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
/// Structured domain for Config document inventory, diagnostics and repair.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_DOCUMENTS;
|
||||
/// Structured domain for safe Config environment reports.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_ENVIRONMENT;
|
||||
/// Structured domain used by technical frontend events.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
/// Structured domain for typed Logging editor inspection.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_EDITOR;
|
||||
/// Structured domain for active Logging runtime metadata and explicit profile application.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_RUNTIME;
|
||||
/// Structured domain used by controlled Logging test events.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_LOGGING_TEST;
|
||||
/// Structured domain for Config profile selection and provenance inspection.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_PROFILES;
|
||||
/// Structured domain for explicit privileged Secret reveal operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_SECRETS;
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||
/// Owning target for backend events emitted by Config Desk.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Owning target for generic frontend events emitted through the KSP bridge.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||
/// Owning target for main-window frontend events.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
|
||||
/// Owning target for splash-window frontend events.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
|
||||
/// Dedicated target used by the controlled Logging test panel.
|
||||
pub(crate) use self::constants::TRACING_TARGET_LOGGING_TEST;
|
||||
/// Detailed management view for one registered Config document.
|
||||
pub(crate) use self::documents::ConfigDocumentDetailDto;
|
||||
/// Safe document-specific command error with backend-owned diagnostic classification.
|
||||
pub(crate) use self::documents::ConfigDocumentErrorDto;
|
||||
/// Result of one validated raw-source repair attempt that reached persistence successfully.
|
||||
pub(crate) use self::documents::ConfigDocumentSaveResultDto;
|
||||
/// Safe inventory row for one Config document registered by `ksp-config-lib`.
|
||||
pub(crate) use self::documents::ConfigDocumentSummaryDto;
|
||||
/// Loads one Config document detail, including raw source when Config can read it.
|
||||
pub(crate) use self::documents::document_detail;
|
||||
/// Lists all Config-kind documents from the Config registry and evaluates their current backend validation state.
|
||||
pub(crate) use self::documents::document_inventory;
|
||||
/// Validates and atomically persists one raw Config source candidate, then reloads the document detail.
|
||||
pub(crate) use self::documents::save_document_source;
|
||||
/// Initial application/runtime snapshot exposed to the frontend.
|
||||
pub(crate) use self::dto_common::AppSnapshotDto;
|
||||
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
|
||||
pub(crate) use self::dto_common::CommandErrorDto;
|
||||
/// Safe result metadata for one `.env` mutation.
|
||||
pub(crate) use self::environment::ConfigEnvironmentChangeDto;
|
||||
/// Safe desired/effective environment view exposed to the ordinary Config Desk frontend.
|
||||
pub(crate) use self::environment::ConfigEnvironmentReportDto;
|
||||
/// Returns the safe environment report owned by Config.
|
||||
pub(crate) use self::environment::environment_report;
|
||||
/// Removes one `.env` entry exclusively through ConfigManagement.
|
||||
pub(crate) use self::environment::remove_environment_value;
|
||||
/// Creates or replaces one `.env` entry exclusively through ConfigManagement.
|
||||
pub(crate) use self::environment::set_environment_value;
|
||||
/// Shared Config Desk application state is internally inconsistent.
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
|
||||
/// Shared Config Desk runtime state cannot be locked safely.
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
|
||||
/// A Documents command requested a registered file that is not a Config-kind document.
|
||||
pub(crate) use self::errors::ERROR_CODE_DOCUMENT_KIND_INVALID;
|
||||
/// Frontend logging requested an unsupported level.
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
||||
/// Frontend logging requested a target outside the application whitelist.
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||
/// Config Desk could not install the managed Logging runtime or its safe fallback.
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||
/// Config Desk persisted a Logging candidate but could not restore the previous source after runtime application failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED;
|
||||
/// Controlled Logging test request contains an unsupported or invalid selector.
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_TEST_REQUEST_INVALID;
|
||||
/// A validated Config document does not expose the standard profile contract expected by the Profiles panel.
|
||||
pub(crate) use self::errors::ERROR_CODE_PROFILE_CONTRACT_MISSING;
|
||||
/// Config profile data could not be projected safely for the frontend.
|
||||
pub(crate) use self::errors::ERROR_CODE_PROFILE_PROJECTION_FAILED;
|
||||
/// Privileged reveal was requested for a non-Secret KSP/KSPB namespace.
|
||||
pub(crate) use self::errors::ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET;
|
||||
/// Privileged reveal requested an unsupported source selector.
|
||||
pub(crate) use self::errors::ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID;
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
|
||||
/// Tauri runtime assembly or execution failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
|
||||
/// A required Tauri window is missing from the configured application runtime.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
|
||||
/// Log payload sent by Config Desk frontend scripts.
|
||||
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
|
||||
/// Emits one validated frontend event through the KSP Logging facade.
|
||||
pub(crate) use self::frontend_logging::emit_frontend_log_event;
|
||||
/// Console sink configuration exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingConsoleDto;
|
||||
/// Complete editable Logging candidate accepted from the frontend.
|
||||
pub(crate) use self::logging_editor::LoggingDocumentCandidateDto;
|
||||
/// Complete typed Logging document exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingDocumentDto;
|
||||
/// Result of one validated typed Logging persistence operation.
|
||||
pub(crate) use self::logging_editor::LoggingDocumentSaveResultDto;
|
||||
/// One persistent file sink exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingFileDto;
|
||||
/// One Logging sink selector/filter exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingOutputFilterDto;
|
||||
/// One typed Logging profile exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingProfileDto;
|
||||
/// One global Logging target override exposed to the editor.
|
||||
pub(crate) use self::logging_editor::LoggingTargetFilterDto;
|
||||
/// Loads the typed Logging source through ConfigManagement and projects it for the frontend.
|
||||
pub(crate) use self::logging_editor::logging_document;
|
||||
/// Validates, atomically persists and hot-reloads one typed Logging candidate through ConfigManagement and `ksp-logging-lib`.
|
||||
pub(crate) use self::logging_editor::save_logging_document;
|
||||
/// Metadata for one currently active persistent Logging file output.
|
||||
pub(crate) use self::logging_runtime::LoggingRuntimeFileDto;
|
||||
/// Safe observable state of the currently active KSP Logging runtime.
|
||||
pub(crate) use self::logging_runtime::LoggingRuntimeStatusDto;
|
||||
/// Applies one persisted Logging profile explicitly without changing `default_profile` or the source document.
|
||||
pub(crate) use self::logging_runtime::apply_logging_profile;
|
||||
/// Executes the crate-internal count to u64 operation for the owning module.
|
||||
pub(crate) use self::logging_runtime::count_to_u64;
|
||||
/// Creates the stable runtime identity for this Config Desk process launch.
|
||||
pub(crate) use self::logging_runtime::launch_identity;
|
||||
/// Returns safe metadata for the currently active Logging runtime.
|
||||
pub(crate) use self::logging_runtime::logging_runtime_status;
|
||||
/// Executes the crate-internal project logging file operation for the owning module.
|
||||
pub(crate) use self::logging_runtime::project_logging_file;
|
||||
/// Controlled request emitted through the backend KSP Logging facade.
|
||||
pub(crate) use self::logging_test::LoggingTestRequestDto;
|
||||
/// Safe result metadata for one controlled backend Logging test request.
|
||||
pub(crate) use self::logging_test::LoggingTestResultDto;
|
||||
/// Emits one controlled backend test request through `ksp-logging-lib` only.
|
||||
pub(crate) use self::logging_test::emit_logging_test;
|
||||
/// Safe detailed view of one resolved Config profile.
|
||||
pub(crate) use self::profiles::ConfigProfileDetailDto;
|
||||
/// One Config document that exposes the standard `default_profile` / `profiles` contract.
|
||||
pub(crate) use self::profiles::ConfigProfileDocumentDto;
|
||||
/// Resolves one default or explicitly selected profile into a safe inspection DTO.
|
||||
pub(crate) use self::profiles::profile_detail;
|
||||
/// Lists validated Config documents that expose standard profiles.
|
||||
pub(crate) use self::profiles::profile_inventory;
|
||||
/// Privileged reveal request kept separate from ordinary environment-report DTOs.
|
||||
pub(crate) use self::secrets::SecretRevealRequestDto;
|
||||
/// Privileged reveal response. This type deliberately does not derive `Clone` or `Debug`.
|
||||
pub(crate) use self::secrets::SecretRevealResponseDto;
|
||||
/// Reveals one real Secret value only after the dedicated Tauri command has been explicitly invoked.
|
||||
pub(crate) use self::secrets::reveal_secret;
|
||||
/// Command emitted by Rust to the splash frontend.
|
||||
pub(crate) use self::splash::SplashOrderDto;
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
pub(crate) use self::splash::SplashSettings;
|
||||
/// Resolves the required main window or returns a typed error.
|
||||
pub(crate) use self::tw_main::require_main_window;
|
||||
/// Shows main window.
|
||||
pub(crate) use self::tw_main::show_main_window;
|
||||
/// Resolves the required splash window or returns a typed error.
|
||||
pub(crate) use self::tw_splash::require_splash_window;
|
||||
/// Executes the crate-internal splash frontend ready service operation for the owning module.
|
||||
pub(crate) use self::tw_splash::splash_frontend_ready_service;
|
||||
369
crates/ksp-app-config-desk/src/logging_editor.rs
Normal file
369
crates/ksp-app-config-desk/src/logging_editor.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
// file: crates/ksp-app-config-desk/src/logging_editor.rs
|
||||
// version: 6
|
||||
|
||||
//! Typed Logging document projection and validated persistence for the Config Desk Logging editor.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// One Logging sink selector/filter exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingOutputFilterDto.ts")]
|
||||
pub(crate) struct LoggingOutputFilterDto {
|
||||
/// Filter level source text.
|
||||
pub(crate) level: String,
|
||||
/// Target selectors in source order.
|
||||
pub(crate) targets: std::vec::Vec<String>,
|
||||
/// Structured domain selectors in source order.
|
||||
pub(crate) domains: std::vec::Vec<String>,
|
||||
}
|
||||
|
||||
/// Console sink configuration exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingConsoleDto.ts")]
|
||||
pub(crate) struct LoggingConsoleDto {
|
||||
/// Whether the console sink is enabled.
|
||||
pub(crate) enabled: bool,
|
||||
/// `stdout` or `stderr` source text.
|
||||
pub(crate) output: String,
|
||||
/// Whether ANSI output is requested.
|
||||
pub(crate) ansi: bool,
|
||||
/// Console format source text.
|
||||
pub(crate) format: String,
|
||||
/// Sink-local selectors.
|
||||
pub(crate) filter: crate::LoggingOutputFilterDto,
|
||||
}
|
||||
|
||||
/// One persistent file sink exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingFileDto.ts")]
|
||||
pub(crate) struct LoggingFileDto {
|
||||
/// Stable sink identifier.
|
||||
pub(crate) output_id: String,
|
||||
/// Whether the file sink is enabled.
|
||||
pub(crate) enabled: bool,
|
||||
/// Source path relative to the Logging root.
|
||||
pub(crate) path: String,
|
||||
/// Rotation source text.
|
||||
pub(crate) rotation: String,
|
||||
/// File format source text.
|
||||
pub(crate) format: String,
|
||||
/// Whether ANSI output is requested.
|
||||
pub(crate) ansi: bool,
|
||||
/// Sink-local selectors.
|
||||
pub(crate) filter: crate::LoggingOutputFilterDto,
|
||||
}
|
||||
|
||||
/// One global Logging target override exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingTargetFilterDto.ts")]
|
||||
pub(crate) struct LoggingTargetFilterDto {
|
||||
/// KSP target prefix.
|
||||
pub(crate) target_prefix: String,
|
||||
/// Override level source text.
|
||||
pub(crate) level: String,
|
||||
}
|
||||
|
||||
/// One typed Logging profile exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingProfileDto.ts")]
|
||||
pub(crate) struct LoggingProfileDto {
|
||||
/// Stable profile identifier.
|
||||
pub(crate) profile_id: String,
|
||||
/// Profile-level default filter source text.
|
||||
pub(crate) default_filter: String,
|
||||
/// Span lifecycle source text.
|
||||
pub(crate) span_events: String,
|
||||
/// Console sink configuration.
|
||||
pub(crate) console: crate::LoggingConsoleDto,
|
||||
/// Persistent file sinks in source order.
|
||||
pub(crate) files: std::vec::Vec<crate::LoggingFileDto>,
|
||||
/// Global target overrides in source order.
|
||||
pub(crate) target_filters: std::vec::Vec<crate::LoggingTargetFilterDto>,
|
||||
}
|
||||
|
||||
/// Complete typed Logging document exposed to the editor.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentDto.ts")]
|
||||
pub(crate) struct LoggingDocumentDto {
|
||||
/// Stable Config file identifier.
|
||||
pub(crate) file_id: String,
|
||||
/// Physical path resolved by Config.
|
||||
pub(crate) path: String,
|
||||
/// Source format version.
|
||||
pub(crate) format_version: u32,
|
||||
/// Source Logging root expression/path.
|
||||
pub(crate) logs_directory: String,
|
||||
/// Autonomous default profile identifier.
|
||||
pub(crate) default_profile: String,
|
||||
/// Typed profiles in source order.
|
||||
pub(crate) profiles: std::vec::Vec<crate::LoggingProfileDto>,
|
||||
}
|
||||
|
||||
/// Complete editable Logging candidate accepted from the frontend.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentCandidateDto.ts")]
|
||||
pub(crate) struct LoggingDocumentCandidateDto {
|
||||
/// Source Logging root expression/path.
|
||||
pub(crate) logs_directory: String,
|
||||
/// Autonomous default profile identifier.
|
||||
pub(crate) default_profile: String,
|
||||
/// Typed profiles in source order.
|
||||
pub(crate) profiles: std::vec::Vec<crate::LoggingProfileDto>,
|
||||
}
|
||||
|
||||
/// Result of one validated typed Logging persistence operation.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging/LoggingDocumentSaveResultDto.ts")]
|
||||
pub(crate) struct LoggingDocumentSaveResultDto {
|
||||
/// Whether `std.logging.json` bytes changed.
|
||||
pub(crate) source_changed: bool,
|
||||
/// Whether Config consumers must reload the document.
|
||||
pub(crate) reload_required: bool,
|
||||
/// Whether the active KSP Logging runtime was hot-reloaded from the persisted candidate.
|
||||
pub(crate) runtime_applied: bool,
|
||||
/// Runtime generation after the successful hot reload.
|
||||
pub(crate) logging_generation: u32,
|
||||
/// Profile selected and applied to the runtime.
|
||||
pub(crate) active_profile: String,
|
||||
/// Fresh typed document loaded after successful persistence and runtime application.
|
||||
pub(crate) document: LoggingDocumentDto,
|
||||
}
|
||||
|
||||
/// Loads the typed Logging source through ConfigManagement and projects it for the frontend.
|
||||
pub(crate) fn logging_document(state: &crate::AppState) -> ksp_core_lib::Result<LoggingDocumentDto> {
|
||||
return document_from_management(state.config_management());
|
||||
}
|
||||
|
||||
/// Validates, atomically persists and hot-reloads one typed Logging candidate through ConfigManagement and `ksp-logging-lib`.
|
||||
pub(crate) fn save_logging_document(state: &crate::AppState, candidate: LoggingDocumentCandidateDto) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let previous = state.config_management().read_source(&file_id);
|
||||
let previous = match previous {
|
||||
std::result::Result::Ok(value) => value.content().to_owned(),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let source = config_candidate(candidate);
|
||||
let report = state.config_management().save_logging_document(&source);
|
||||
let report = match report {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let applied = apply_persisted_runtime(state);
|
||||
let (active_profile, logging_generation) = match applied {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return rollback_after_runtime_failure(state.config_management(), &file_id, previous.as_str(), report.source_changed(), error);
|
||||
},
|
||||
};
|
||||
let document = document_from_management(state.config_management());
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_LOGGING_EDITOR,
|
||||
source_changed = report.source_changed(),
|
||||
reload_required = report.reload_required(),
|
||||
runtime_applied = true,
|
||||
logging_generation,
|
||||
active_profile = active_profile.as_str(),
|
||||
profile_count = document.profiles.len(),
|
||||
default_profile = document.default_profile.as_str(),
|
||||
"typed Logging document persistence and runtime hot reload completed"
|
||||
);
|
||||
return std::result::Result::Ok(LoggingDocumentSaveResultDto {
|
||||
source_changed: report.source_changed(),
|
||||
reload_required: report.reload_required(),
|
||||
runtime_applied: true,
|
||||
logging_generation,
|
||||
active_profile,
|
||||
document,
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_persisted_runtime(state: &crate::AppState) -> ksp_core_lib::Result<(String, u32)> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = state.config_management().engine().load_resolved_logging_config(std::option::Option::None, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile_id = resolved.profile_id().to_owned();
|
||||
let settings = resolved.into_settings();
|
||||
let generation = state.reinitialize_logging_runtime(profile_id.as_str(), "default_profile", &settings);
|
||||
return match generation {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok((profile_id, value)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn rollback_after_runtime_failure(
|
||||
management: &ksp_config_lib::ConfigManagement,
|
||||
file_id: &ksp_config_lib::ConfigFileId,
|
||||
previous_source: &str,
|
||||
source_changed: bool,
|
||||
runtime_error: ksp_core_lib::Error,
|
||||
) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
|
||||
if !source_changed {
|
||||
return std::result::Result::Err(runtime_error);
|
||||
}
|
||||
let rollback = management.save_source_candidate(file_id, previous_source);
|
||||
return match rollback {
|
||||
std::result::Result::Ok(_) => std::result::Result::Err(runtime_error),
|
||||
std::result::Result::Err(rollback_error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_LOGGING_SOURCE_ROLLBACK_FAILED,
|
||||
"Logging runtime hot reload failed and the previous persisted source could not be restored",
|
||||
)
|
||||
.with_context("runtime_error_domain", runtime_error.code().domain())
|
||||
.with_context("runtime_error_code", runtime_error.code().code())
|
||||
.with_source(rollback_error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<LoggingDocumentDto> {
|
||||
let source = management.load_logging_document();
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let path = management.engine().registry().resolve_path(management.engine().bootstrap(), &file_id);
|
||||
let path = match path {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut profiles = std::vec::Vec::<crate::LoggingProfileDto>::with_capacity(source.profiles().len());
|
||||
let mut file_count = 0usize;
|
||||
let mut target_filter_count = 0usize;
|
||||
for profile in source.profiles() {
|
||||
file_count += profile.files().len();
|
||||
target_filter_count += profile.target_filters().len();
|
||||
profiles.push(project_profile(profile));
|
||||
}
|
||||
let result = LoggingDocumentDto {
|
||||
file_id: file_id.as_str().to_owned(),
|
||||
path: path.display().to_string(),
|
||||
format_version: source.format_version(),
|
||||
logs_directory: source.logs_directory().to_owned(),
|
||||
default_profile: source.default_profile().to_owned(),
|
||||
profiles,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_LOGGING_EDITOR,
|
||||
file_id = result.file_id.as_str(),
|
||||
profile_count = result.profiles.len(),
|
||||
file_count,
|
||||
target_filter_count,
|
||||
"typed Logging document projected for editor"
|
||||
);
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
fn config_candidate(candidate: LoggingDocumentCandidateDto) -> ksp_config_lib::LoggingConfigDocument {
|
||||
let mut profiles = std::vec::Vec::<ksp_config_lib::LoggingProfileConfig>::with_capacity(candidate.profiles.len());
|
||||
for profile in candidate.profiles {
|
||||
profiles.push(config_profile(profile));
|
||||
}
|
||||
return ksp_config_lib::LoggingConfigDocument::new(candidate.logs_directory, candidate.default_profile, profiles);
|
||||
}
|
||||
|
||||
fn config_profile(profile: crate::LoggingProfileDto) -> ksp_config_lib::LoggingProfileConfig {
|
||||
let mut files = std::vec::Vec::<ksp_config_lib::LoggingFileConfig>::with_capacity(profile.files.len());
|
||||
for file in profile.files {
|
||||
let mut config =
|
||||
ksp_config_lib::LoggingFileConfig::new(file.output_id, file.enabled, file.path, file.rotation, file.format, config_output_filter(file.filter));
|
||||
config.set_ansi(file.ansi);
|
||||
files.push(config);
|
||||
}
|
||||
let mut target_filters = std::vec::Vec::<ksp_config_lib::LoggingTargetFilterConfig>::with_capacity(profile.target_filters.len());
|
||||
for target_filter in profile.target_filters {
|
||||
target_filters.push(ksp_config_lib::LoggingTargetFilterConfig::new(target_filter.target_prefix, target_filter.level));
|
||||
}
|
||||
return ksp_config_lib::LoggingProfileConfig::new(
|
||||
profile.profile_id,
|
||||
profile.default_filter,
|
||||
profile.span_events,
|
||||
ksp_config_lib::LoggingConsoleConfig::new(
|
||||
profile.console.enabled,
|
||||
profile.console.output,
|
||||
profile.console.ansi,
|
||||
profile.console.format,
|
||||
config_output_filter(profile.console.filter),
|
||||
),
|
||||
files,
|
||||
target_filters,
|
||||
);
|
||||
}
|
||||
|
||||
fn config_output_filter(filter: crate::LoggingOutputFilterDto) -> ksp_config_lib::LoggingOutputFilterConfig {
|
||||
return ksp_config_lib::LoggingOutputFilterConfig::new(filter.level, filter.targets, filter.domains);
|
||||
}
|
||||
|
||||
fn project_profile(profile: &ksp_config_lib::LoggingProfileConfig) -> crate::LoggingProfileDto {
|
||||
let mut files = std::vec::Vec::<crate::LoggingFileDto>::with_capacity(profile.files().len());
|
||||
for file in profile.files() {
|
||||
files.push(crate::LoggingFileDto {
|
||||
output_id: file.output_id().to_owned(),
|
||||
enabled: file.enabled(),
|
||||
path: file.path().to_owned(),
|
||||
rotation: file.rotation().to_owned(),
|
||||
format: file.format().to_owned(),
|
||||
ansi: file.ansi(),
|
||||
filter: project_output_filter(file.filter()),
|
||||
});
|
||||
}
|
||||
let mut target_filters = std::vec::Vec::<crate::LoggingTargetFilterDto>::with_capacity(profile.target_filters().len());
|
||||
for target_filter in profile.target_filters() {
|
||||
target_filters.push(crate::LoggingTargetFilterDto { target_prefix: target_filter.target_prefix().to_owned(), level: target_filter.level().to_owned() });
|
||||
}
|
||||
return crate::LoggingProfileDto {
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
default_filter: profile.default_filter().to_owned(),
|
||||
span_events: profile.span_events().to_owned(),
|
||||
console: crate::LoggingConsoleDto {
|
||||
enabled: profile.console().enabled(),
|
||||
output: profile.console().output().to_owned(),
|
||||
ansi: profile.console().ansi(),
|
||||
format: profile.console().format().to_owned(),
|
||||
filter: project_output_filter(profile.console().filter()),
|
||||
},
|
||||
files,
|
||||
target_filters,
|
||||
};
|
||||
}
|
||||
|
||||
fn project_output_filter(filter: &ksp_config_lib::LoggingOutputFilterConfig) -> crate::LoggingOutputFilterDto {
|
||||
return crate::LoggingOutputFilterDto {
|
||||
level: filter.level().to_owned(),
|
||||
targets: filter.targets().to_vec(),
|
||||
domains: filter.domains().to_vec(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/logging_editor.rs"]
|
||||
mod tests;
|
||||
120
crates/ksp-app-config-desk/src/logging_runtime.rs
Normal file
120
crates/ksp-app-config-desk/src/logging_runtime.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
// file: crates/ksp-app-config-desk/src/logging_runtime.rs
|
||||
// version: 4
|
||||
|
||||
//! Runtime Logging metadata, launch identity and explicit profile application for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Metadata for one currently active persistent Logging file output.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeFileDto.ts")]
|
||||
pub(crate) struct LoggingRuntimeFileDto {
|
||||
/// Stable configured output identifier.
|
||||
pub(crate) output_id: String,
|
||||
/// Resolved runtime directory.
|
||||
pub(crate) directory: String,
|
||||
/// Effective per-launch filename prefix passed to the rolling appender.
|
||||
pub(crate) file_name_prefix: String,
|
||||
/// Effective file rotation cadence.
|
||||
pub(crate) rotation: String,
|
||||
}
|
||||
|
||||
/// Safe observable state of the currently active KSP Logging runtime.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_runtime/LoggingRuntimeStatusDto.ts")]
|
||||
pub(crate) struct LoggingRuntimeStatusDto {
|
||||
/// Active profile identifier, or `None` while fallback Logging is active.
|
||||
pub(crate) active_profile: std::option::Option<String>,
|
||||
/// Source that selected the active runtime profile.
|
||||
pub(crate) selection_source: String,
|
||||
/// Monotonic runtime generation.
|
||||
pub(crate) generation: u32,
|
||||
/// Whether transient fallback Logging is active.
|
||||
pub(crate) fallback_active: bool,
|
||||
/// Stable application identifier embedded in launch-specific file names.
|
||||
pub(crate) application_id: String,
|
||||
/// Stable launch timestamp token reused by all hot reloads in this process.
|
||||
pub(crate) launch_timestamp: String,
|
||||
/// Whether the currently active console sink is enabled.
|
||||
pub(crate) console_enabled: bool,
|
||||
/// Cumulative console lines dropped by non-blocking outputs.
|
||||
pub(crate) dropped_console_lines: u64,
|
||||
/// Cumulative file lines dropped by non-blocking outputs.
|
||||
pub(crate) dropped_file_lines: u64,
|
||||
/// Cumulative lines dropped across all outputs.
|
||||
pub(crate) dropped_total_lines: u64,
|
||||
/// Active persistent file outputs and their launch-specific prefixes.
|
||||
pub(crate) files: std::vec::Vec<LoggingRuntimeFileDto>,
|
||||
}
|
||||
|
||||
/// Creates the stable runtime identity for this Config Desk process launch.
|
||||
pub(crate) fn launch_identity() -> ksp_core_lib::Result<ksp_logging_lib::LoggingRuntimeIdentity> {
|
||||
let timestamp = format!("{}-p{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3fZ"), std::process::id());
|
||||
return ksp_logging_lib::LoggingRuntimeIdentity::new(crate::TRACING_TARGET, timestamp);
|
||||
}
|
||||
|
||||
/// Returns safe metadata for the currently active Logging runtime.
|
||||
pub(crate) fn logging_runtime_status(state: &crate::AppState) -> ksp_core_lib::Result<LoggingRuntimeStatusDto> {
|
||||
return state.logging_runtime_status();
|
||||
}
|
||||
|
||||
/// Applies one persisted Logging profile explicitly without changing `default_profile` or the source document.
|
||||
pub(crate) fn apply_logging_profile(state: &crate::AppState, profile_id: &str) -> ksp_core_lib::Result<LoggingRuntimeStatusDto> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = state.config_management().engine().load_resolved_logging_config(std::option::Option::Some(profile_id), &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let active_profile = resolved.profile_id().to_owned();
|
||||
let settings = resolved.into_settings();
|
||||
let generation = state.reinitialize_logging_runtime(active_profile.as_str(), "explicit", &settings);
|
||||
if let std::result::Result::Err(error) = generation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_LOGGING_RUNTIME,
|
||||
active_profile = active_profile.as_str(),
|
||||
selection_source = "explicit",
|
||||
"persisted Logging profile applied explicitly to runtime"
|
||||
);
|
||||
return state.logging_runtime_status();
|
||||
}
|
||||
|
||||
/// Executes the crate-internal project logging file operation for the owning module.
|
||||
pub(crate) fn project_logging_file(metadata: &ksp_logging_lib::RuntimeFileMetadata) -> LoggingRuntimeFileDto {
|
||||
return LoggingRuntimeFileDto {
|
||||
output_id: metadata.output_id().to_owned(),
|
||||
directory: metadata.directory().display().to_string(),
|
||||
file_name_prefix: metadata.file_name_prefix().to_owned(),
|
||||
rotation: rotation_label(metadata.rotation()).to_owned(),
|
||||
};
|
||||
}
|
||||
|
||||
const fn rotation_label(rotation: ksp_logging_lib::FileRotation) -> &'static str {
|
||||
return match rotation {
|
||||
ksp_logging_lib::FileRotation::Never => "never",
|
||||
ksp_logging_lib::FileRotation::Hourly => "hourly",
|
||||
ksp_logging_lib::FileRotation::Daily => "daily",
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes the crate-internal count to u64 operation for the owning module.
|
||||
pub(crate) fn count_to_u64(value: usize) -> u64 {
|
||||
let converted = u64::try_from(value);
|
||||
return match converted {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/logging_runtime.rs"]
|
||||
mod tests;
|
||||
428
crates/ksp-app-config-desk/src/logging_test.rs
Normal file
428
crates/ksp-app-config-desk/src/logging_test.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
// file: crates/ksp-app-config-desk/src/logging_test.rs
|
||||
// version: 2
|
||||
|
||||
//! Controlled Logging test events for validating KSP runtime routing from Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Controlled request emitted through the backend KSP Logging facade.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_test/LoggingTestRequestDto.ts")]
|
||||
pub(crate) struct LoggingTestRequestDto {
|
||||
/// Requested level: trace/debug/info/warn/error/all.
|
||||
pub(crate) level: String,
|
||||
/// Controlled backend target identifier.
|
||||
pub(crate) target_id: String,
|
||||
/// Domain selection mode: absent/known/custom.
|
||||
pub(crate) domain_mode: String,
|
||||
/// Selected known domain or custom domain token.
|
||||
pub(crate) domain: String,
|
||||
/// Human test message emitted as the event body.
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
/// Safe result metadata for one controlled backend Logging test request.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/logging_test/LoggingTestResultDto.ts")]
|
||||
pub(crate) struct LoggingTestResultDto {
|
||||
/// Number of Logging events emitted by the request.
|
||||
pub(crate) emitted_event_count: u32,
|
||||
/// Requested level selector.
|
||||
pub(crate) requested_level: String,
|
||||
/// Effective static KSP target used by the emitted events.
|
||||
pub(crate) target: String,
|
||||
/// Effective domain, or `None` when domain routing is intentionally absent.
|
||||
pub(crate) domain: std::option::Option<String>,
|
||||
/// Logging runtime generation active while the events were emitted.
|
||||
pub(crate) logging_generation: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum LoggingTestLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum LoggingTestTarget {
|
||||
App,
|
||||
Dedicated,
|
||||
}
|
||||
|
||||
/// Emits one controlled backend test request through `ksp-logging-lib` only.
|
||||
pub(crate) fn emit_logging_test(state: &crate::AppState, request: LoggingTestRequestDto) -> ksp_core_lib::Result<LoggingTestResultDto> {
|
||||
let level = parse_level(request.level.as_str());
|
||||
let level = match level {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let target = parse_target(request.target_id.as_str());
|
||||
let target = match target {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let domain = resolve_domain(request.domain_mode.as_str(), request.domain.as_str());
|
||||
let domain = match domain {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let message = validate_message(request.message.as_str());
|
||||
let message = match message {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime_status = state.logging_runtime_status();
|
||||
let runtime_status = match runtime_status {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let emitted_event_count = emit_levels(level, target, domain.as_deref(), message);
|
||||
return std::result::Result::Ok(LoggingTestResultDto {
|
||||
emitted_event_count,
|
||||
requested_level: request.level.trim().to_ascii_lowercase(),
|
||||
target: target_value(target).to_owned(),
|
||||
domain,
|
||||
logging_generation: runtime_status.generation,
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_level(level: &str) -> ksp_core_lib::Result<LoggingTestLevel> {
|
||||
return match level.trim().to_ascii_lowercase().as_str() {
|
||||
"trace" => std::result::Result::Ok(LoggingTestLevel::Trace),
|
||||
"debug" => std::result::Result::Ok(LoggingTestLevel::Debug),
|
||||
"info" => std::result::Result::Ok(LoggingTestLevel::Info),
|
||||
"warn" => std::result::Result::Ok(LoggingTestLevel::Warn),
|
||||
"error" => std::result::Result::Ok(LoggingTestLevel::Error),
|
||||
"all" => std::result::Result::Ok(LoggingTestLevel::All),
|
||||
_ => invalid_request("Logging test level is not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_target(target_id: &str) -> ksp_core_lib::Result<LoggingTestTarget> {
|
||||
return match target_id.trim().to_ascii_lowercase().as_str() {
|
||||
"app" => std::result::Result::Ok(LoggingTestTarget::App),
|
||||
"logging_test" => std::result::Result::Ok(LoggingTestTarget::Dedicated),
|
||||
_ => invalid_request("Logging test target is not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
fn resolve_domain(domain_mode: &str, domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
|
||||
return match domain_mode.trim().to_ascii_lowercase().as_str() {
|
||||
"absent" => std::result::Result::Ok(std::option::Option::None),
|
||||
"known" => resolve_known_domain(domain),
|
||||
"custom" => resolve_custom_domain(domain),
|
||||
_ => invalid_request("Logging test domain mode is not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
fn resolve_known_domain(domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
|
||||
return match domain.trim() {
|
||||
crate::TRACING_DOMAIN_LOGGING_TEST | crate::TRACING_DOMAIN_LOGGING_RUNTIME | crate::TRACING_DOMAIN_FRONTEND => {
|
||||
std::result::Result::Ok(std::option::Option::Some(domain.trim().to_owned()))
|
||||
},
|
||||
_ => invalid_request("Logging test known domain is not supported"),
|
||||
};
|
||||
}
|
||||
|
||||
fn resolve_custom_domain(domain: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
|
||||
let domain = domain.trim();
|
||||
if domain.is_empty() || domain.len() > 80 || !domain.chars().all(is_domain_character) {
|
||||
return invalid_request("Logging test custom domain is invalid");
|
||||
}
|
||||
return std::result::Result::Ok(std::option::Option::Some(domain.to_owned()));
|
||||
}
|
||||
|
||||
fn is_domain_character(character: char) -> bool {
|
||||
return character.is_ascii_alphanumeric() || character == '.' || character == '_' || character == '-';
|
||||
}
|
||||
|
||||
fn validate_message(message: &str) -> ksp_core_lib::Result<&str> {
|
||||
let message = message.trim();
|
||||
if message.is_empty() || message.len() > 500 {
|
||||
return invalid_request("Logging test message must contain between 1 and 500 bytes");
|
||||
}
|
||||
return std::result::Result::Ok(message);
|
||||
}
|
||||
|
||||
fn invalid_request<T>(message: &str) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_LOGGING_TEST_REQUEST_INVALID, message));
|
||||
}
|
||||
|
||||
const fn target_value(target: LoggingTestTarget) -> &'static str {
|
||||
return match target {
|
||||
LoggingTestTarget::App => crate::TRACING_TARGET,
|
||||
LoggingTestTarget::Dedicated => crate::TRACING_TARGET_LOGGING_TEST,
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_levels(level: LoggingTestLevel, target: LoggingTestTarget, domain: std::option::Option<&str>, message: &str) -> u32 {
|
||||
return match level {
|
||||
LoggingTestLevel::Trace => {
|
||||
emit_one(LoggingTestLevel::Trace, target, domain, message);
|
||||
1
|
||||
},
|
||||
LoggingTestLevel::Debug => {
|
||||
emit_one(LoggingTestLevel::Debug, target, domain, message);
|
||||
1
|
||||
},
|
||||
LoggingTestLevel::Info => {
|
||||
emit_one(LoggingTestLevel::Info, target, domain, message);
|
||||
1
|
||||
},
|
||||
LoggingTestLevel::Warn => {
|
||||
emit_one(LoggingTestLevel::Warn, target, domain, message);
|
||||
1
|
||||
},
|
||||
LoggingTestLevel::Error => {
|
||||
emit_one(LoggingTestLevel::Error, target, domain, message);
|
||||
1
|
||||
},
|
||||
LoggingTestLevel::All => {
|
||||
emit_one(LoggingTestLevel::Trace, target, domain, message);
|
||||
emit_one(LoggingTestLevel::Debug, target, domain, message);
|
||||
emit_one(LoggingTestLevel::Info, target, domain, message);
|
||||
emit_one(LoggingTestLevel::Warn, target, domain, message);
|
||||
emit_one(LoggingTestLevel::Error, target, domain, message);
|
||||
5
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_one(level: LoggingTestLevel, target: LoggingTestTarget, domain: std::option::Option<&str>, message: &str) {
|
||||
return match target {
|
||||
LoggingTestTarget::App => emit_app_target(level, domain, message),
|
||||
LoggingTestTarget::Dedicated => emit_dedicated_target(level, domain, message),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_target(level: LoggingTestLevel, domain: std::option::Option<&str>, message: &str) {
|
||||
return match level {
|
||||
LoggingTestLevel::Trace => emit_app_trace(domain, message),
|
||||
LoggingTestLevel::Debug => emit_app_debug(domain, message),
|
||||
LoggingTestLevel::Info => emit_app_info(domain, message),
|
||||
LoggingTestLevel::Warn => emit_app_warn(domain, message),
|
||||
LoggingTestLevel::Error => emit_app_error(domain, message),
|
||||
LoggingTestLevel::All => (),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_target(level: LoggingTestLevel, domain: std::option::Option<&str>, message: &str) {
|
||||
return match level {
|
||||
LoggingTestLevel::Trace => emit_dedicated_trace(domain, message),
|
||||
LoggingTestLevel::Debug => emit_dedicated_debug(domain, message),
|
||||
LoggingTestLevel::Info => emit_dedicated_info(domain, message),
|
||||
LoggingTestLevel::Warn => emit_dedicated_warn(domain, message),
|
||||
LoggingTestLevel::Error => emit_dedicated_error(domain, message),
|
||||
LoggingTestLevel::All => (),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_trace(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "trace",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "trace",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_debug(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "debug",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "debug",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_info(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "info",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "info",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_warn(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "warn",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "warn",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_app_error(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "error",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "error",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_trace(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "trace",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "trace",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_debug(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "debug",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "debug",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_info(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "info",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "info",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_warn(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "warn",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "warn",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn emit_dedicated_error(domain: std::option::Option<&str>, message: &str) {
|
||||
return match domain {
|
||||
std::option::Option::Some(domain) => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
domain = domain,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "error",
|
||||
"{message}"
|
||||
),
|
||||
std::option::Option::None => ksp_logging_lib::error!(
|
||||
target: crate::TRACING_TARGET_LOGGING_TEST,
|
||||
action = "logging_test",
|
||||
test_source = "backend",
|
||||
test_level = "error",
|
||||
"{message}"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/logging_test.rs"]
|
||||
mod tests;
|
||||
58
crates/ksp-app-config-desk/src/main.rs
Normal file
58
crates/ksp-app-config-desk/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
// file: crates/ksp-app-config-desk/src/main.rs
|
||||
// version: 3
|
||||
|
||||
//! Binary entry point for the KSP configuration desktop application.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use fs2::FileExt; // rust-rules: trait-import
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
let working_directory = configure_runtime_working_directory();
|
||||
if let std::result::Result::Err(error) = working_directory {
|
||||
eprintln!("cannot configure Config Desk runtime working directory: {error}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
let mut lock_path = std::env::temp_dir();
|
||||
lock_path.push("com_sasedev_ksp_app_config_desk.lock");
|
||||
let lock_file = match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
|
||||
std::result::Result::Ok(file) => file,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("cannot create application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() {
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
eprintln!("another ksp-app-config-desk instance is already running");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
eprintln!("cannot acquire application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
let _lock_file = lock_file;
|
||||
let arguments = std::env::args_os().collect::<std::vec::Vec<std::ffi::OsString>>();
|
||||
let run_result = ksp_app_config_desk_lib::run(arguments.as_slice());
|
||||
return match run_result {
|
||||
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("application error: {error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn configure_runtime_working_directory() -> std::io::Result<()> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
return std::env::set_current_dir(workspace_root);
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
355
crates/ksp-app-config-desk/src/profiles.rs
Normal file
355
crates/ksp-app-config-desk/src/profiles.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
// file: crates/ksp-app-config-desk/src/profiles.rs
|
||||
// version: 4
|
||||
|
||||
//! Safe Config profile inspection and provenance projections for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// One Config document that exposes the standard `default_profile` / `profiles` contract.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileDocumentDto.ts")]
|
||||
pub(crate) struct ConfigProfileDocumentDto {
|
||||
/// Stable Config file identifier.
|
||||
pub(crate) file_id: String,
|
||||
/// Physical path resolved by Config.
|
||||
pub(crate) path: String,
|
||||
/// Autonomous default profile declared by the document.
|
||||
pub(crate) default_profile: String,
|
||||
/// Available unique profile identifiers in source order.
|
||||
pub(crate) profile_ids: std::vec::Vec<String>,
|
||||
}
|
||||
|
||||
/// Top-level origin of one effective profile key.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileValueOriginDto.ts")]
|
||||
pub(crate) struct ConfigProfileValueOriginDto {
|
||||
/// Effective top-level key.
|
||||
pub(crate) key: String,
|
||||
/// `global` or `profile`.
|
||||
pub(crate) origin: String,
|
||||
}
|
||||
|
||||
/// Safe provenance record for one environment contribution in the resolved effective profile.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileEnvironmentProvenanceDto.ts")]
|
||||
pub(crate) struct ConfigProfileEnvironmentProvenanceDto {
|
||||
/// RFC 6901 pointer of the resolved value.
|
||||
pub(crate) json_pointer: String,
|
||||
/// Referenced environment variable name; never its value.
|
||||
pub(crate) variable_name: String,
|
||||
/// Winning source: `process`, `dotenv` or `fallback`.
|
||||
pub(crate) source: String,
|
||||
/// Sensitivity derived from the variable namespace.
|
||||
pub(crate) sensitivity: String,
|
||||
}
|
||||
|
||||
/// Safe detailed view of one resolved Config profile.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/profiles/ConfigProfileDetailDto.ts")]
|
||||
pub(crate) struct ConfigProfileDetailDto {
|
||||
/// Stable Config file identifier.
|
||||
pub(crate) file_id: String,
|
||||
/// Physical path resolved by Config.
|
||||
pub(crate) path: String,
|
||||
/// Autonomous default profile declared by the document.
|
||||
pub(crate) default_profile: String,
|
||||
/// Available profile identifiers.
|
||||
pub(crate) profile_ids: std::vec::Vec<String>,
|
||||
/// Profile selected for this inspection.
|
||||
pub(crate) selected_profile: String,
|
||||
/// `default_profile` or `explicit`.
|
||||
pub(crate) selection_source: String,
|
||||
/// Pretty-printed document-global source values.
|
||||
pub(crate) globals_json: String,
|
||||
/// Pretty-printed selected source profile object.
|
||||
pub(crate) profile_json: String,
|
||||
/// Pretty-printed environment-resolved effective tree using only the safe/redacted representation.
|
||||
pub(crate) effective_safe_json: String,
|
||||
/// Strongest sensitivity present in the resolved effective tree.
|
||||
pub(crate) effective_sensitivity: String,
|
||||
/// Global/Profile provenance for effective top-level keys.
|
||||
pub(crate) value_origins: std::vec::Vec<ConfigProfileValueOriginDto>,
|
||||
/// Environment provenance records without resolved values.
|
||||
pub(crate) environment_provenance: std::vec::Vec<ConfigProfileEnvironmentProvenanceDto>,
|
||||
}
|
||||
|
||||
struct ProfileContract {
|
||||
default_profile: String,
|
||||
profile_ids: std::vec::Vec<String>,
|
||||
}
|
||||
|
||||
/// Lists validated Config documents that expose standard profiles.
|
||||
pub(crate) fn profile_inventory(state: &crate::AppState) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileDocumentDto>> {
|
||||
return inventory_from_management(state.config_management());
|
||||
}
|
||||
|
||||
/// Resolves one default or explicitly selected profile into a safe inspection DTO.
|
||||
pub(crate) fn profile_detail(
|
||||
state: &crate::AppState,
|
||||
file_id: &str,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
) -> ksp_core_lib::Result<ConfigProfileDetailDto> {
|
||||
return detail_from_management(state.config_management(), file_id, requested_profile);
|
||||
}
|
||||
|
||||
fn inventory_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileDocumentDto>> {
|
||||
let mut result = std::vec::Vec::<ConfigProfileDocumentDto>::new();
|
||||
for descriptor in management.engine().registry().descriptors() {
|
||||
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
|
||||
continue;
|
||||
}
|
||||
let document = management.engine().load_validated_document(descriptor.file_id());
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let contract = profile_contract(&document);
|
||||
let contract = match contract {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => continue,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
result.push(ConfigProfileDocumentDto {
|
||||
file_id: descriptor.file_id().as_str().to_owned(),
|
||||
path: document.path().to_string_lossy().into_owned(),
|
||||
default_profile: contract.default_profile,
|
||||
profile_ids: contract.profile_ids,
|
||||
});
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_PROFILES,
|
||||
profile_document_count = result.len(),
|
||||
"Config profile document inventory evaluated"
|
||||
);
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
fn detail_from_management(
|
||||
management: &ksp_config_lib::ConfigManagement,
|
||||
file_id: &str,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
) -> ksp_core_lib::Result<ConfigProfileDetailDto> {
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(file_id.to_owned());
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let document = management.engine().load_validated_document(&file_id);
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let contract = profile_contract(&document);
|
||||
let contract = match contract {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_CONTRACT_MISSING, "Config document does not expose the standard profile contract")
|
||||
.with_context("file_id", file_id.as_str()),
|
||||
);
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().load_resolved_profile(&file_id, requested_profile);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let effective = resolved.resolve_effective_environment_detailed(&environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let globals_json = pretty_json(resolved.globals());
|
||||
let globals_json = match globals_json {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile_json = pretty_json(resolved.profile());
|
||||
let profile_json = match profile_json {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let effective_safe_json = serde_json::to_string_pretty(effective.safe_value());
|
||||
let effective_safe_json = match effective_safe_json {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Cannot serialize safe effective Config profile").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let value_origins = value_origins(&resolved);
|
||||
let environment_provenance = environment_provenance(&effective);
|
||||
let environment_provenance = match environment_provenance {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let selection_source = selection_source_label(resolved.selection_source()).to_owned();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_PROFILES,
|
||||
file_id = file_id.as_str(),
|
||||
profile_id = resolved.profile_id(),
|
||||
selection_source = selection_source.as_str(),
|
||||
effective_sensitivity = sensitivity_label(effective.sensitivity()),
|
||||
environment_provenance_count = environment_provenance.len(),
|
||||
"Config profile detail resolved"
|
||||
);
|
||||
return std::result::Result::Ok(ConfigProfileDetailDto {
|
||||
file_id: file_id.as_str().to_owned(),
|
||||
path: resolved.path().to_string_lossy().into_owned(),
|
||||
default_profile: contract.default_profile,
|
||||
profile_ids: contract.profile_ids,
|
||||
selected_profile: resolved.profile_id().to_owned(),
|
||||
selection_source,
|
||||
globals_json,
|
||||
profile_json,
|
||||
effective_safe_json,
|
||||
effective_sensitivity: sensitivity_label(effective.sensitivity()).to_owned(),
|
||||
value_origins,
|
||||
environment_provenance,
|
||||
});
|
||||
}
|
||||
|
||||
fn profile_contract(document: &ksp_config_lib::ConfigJsonDocument) -> ksp_core_lib::Result<std::option::Option<ProfileContract>> {
|
||||
let root = match document.value().as_object() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let default_profile = root.get("default_profile");
|
||||
let profiles = root.get("profiles");
|
||||
if default_profile.is_none() && profiles.is_none() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let default_profile = match default_profile.and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => value.to_owned(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile document has no readable default_profile")
|
||||
.with_context("file_id", document.file_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let profiles = match profiles.and_then(serde_json::Value::as_array) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile document has no readable profiles array")
|
||||
.with_context("file_id", document.file_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut profile_ids = std::vec::Vec::<String>::new();
|
||||
for profile in profiles {
|
||||
let profile_id = profile
|
||||
.as_object()
|
||||
.and_then(|value| {
|
||||
return value.get("profile_id");
|
||||
})
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let profile_id = match profile_id {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Validated Config profile entry has no readable profile_id")
|
||||
.with_context("file_id", document.file_id().as_str()),
|
||||
);
|
||||
},
|
||||
};
|
||||
profile_ids.push(profile_id.to_owned());
|
||||
}
|
||||
return std::result::Result::Ok(std::option::Option::Some(ProfileContract { default_profile, profile_ids }));
|
||||
}
|
||||
|
||||
fn pretty_json(value: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<String> {
|
||||
let serialized = serde_json::to_string_pretty(value);
|
||||
return match serialized {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_PROJECTION_FAILED, "Cannot serialize Config profile source projection").with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn value_origins(resolved: &ksp_config_lib::ResolvedConfigProfile) -> std::vec::Vec<ConfigProfileValueOriginDto> {
|
||||
let mut result = std::vec::Vec::<ConfigProfileValueOriginDto>::new();
|
||||
for key in resolved.effective().keys() {
|
||||
let origin = resolved.origin(key.as_str());
|
||||
let origin = match origin {
|
||||
std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global) => "global",
|
||||
std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile) => "profile",
|
||||
std::option::Option::None => "unknown",
|
||||
};
|
||||
result.push(ConfigProfileValueOriginDto { key: key.clone(), origin: origin.to_owned() });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn environment_provenance(effective: &ksp_config_lib::ResolvedConfigJson) -> ksp_core_lib::Result<std::vec::Vec<ConfigProfileEnvironmentProvenanceDto>> {
|
||||
let mut result = std::vec::Vec::<ConfigProfileEnvironmentProvenanceDto>::new();
|
||||
for (json_pointer, provenance) in effective.provenance() {
|
||||
for segment in provenance {
|
||||
let variable_name = segment.variable_name();
|
||||
let variable_name = match variable_name {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let source = segment.environment_source();
|
||||
let source = match source {
|
||||
std::option::Option::Some(value) => environment_source_label(value),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let sensitivity = ksp_config_lib::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
result.push(ConfigProfileEnvironmentProvenanceDto {
|
||||
json_pointer: json_pointer.clone(),
|
||||
variable_name: variable_name.to_owned(),
|
||||
source: source.to_owned(),
|
||||
sensitivity: sensitivity_label(sensitivity).to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
|
||||
const fn selection_source_label(source: ksp_config_lib::ConfigProfileSelectionSource) -> &'static str {
|
||||
return match source {
|
||||
ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile => "default_profile",
|
||||
ksp_config_lib::ConfigProfileSelectionSource::Explicit => "explicit",
|
||||
ksp_config_lib::ConfigProfileSelectionSource::Composite => "composite",
|
||||
};
|
||||
}
|
||||
|
||||
const fn environment_source_label(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
|
||||
return match source {
|
||||
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
|
||||
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
|
||||
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
const fn sensitivity_label(sensitivity: ksp_config_lib::ConfigSensitivity) -> &'static str {
|
||||
return match sensitivity {
|
||||
ksp_config_lib::ConfigSensitivity::Public => "public",
|
||||
ksp_config_lib::ConfigSensitivity::Internal => "internal",
|
||||
ksp_config_lib::ConfigSensitivity::Secret => "secret",
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/profiles.rs"]
|
||||
mod tests;
|
||||
105
crates/ksp-app-config-desk/src/secrets.rs
Normal file
105
crates/ksp-app-config-desk/src/secrets.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
// file: crates/ksp-app-config-desk/src/secrets.rs
|
||||
// version: 2
|
||||
|
||||
//! Privileged, explicitly requested Config Secret reveal boundary for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Privileged reveal request kept separate from ordinary environment-report DTOs.
|
||||
#[derive(serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/secrets/SecretRevealRequestDto.ts")]
|
||||
pub(crate) struct SecretRevealRequestDto {
|
||||
/// Secret KSP/KSPB variable to reveal.
|
||||
pub(crate) variable_name: String,
|
||||
/// Requested source: `effective` or `dotenv`.
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
/// Privileged reveal response. This type deliberately does not derive `Clone` or `Debug`.
|
||||
#[derive(serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/secrets/SecretRevealResponseDto.ts")]
|
||||
pub(crate) struct SecretRevealResponseDto {
|
||||
/// Secret variable that was explicitly requested.
|
||||
pub(crate) variable_name: String,
|
||||
/// Source that was explicitly requested.
|
||||
pub(crate) source: String,
|
||||
/// Real value when the requested source contains one; never include this field in logs or ordinary application state.
|
||||
pub(crate) value: std::option::Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum SecretRevealSource {
|
||||
Effective,
|
||||
DotEnv,
|
||||
}
|
||||
|
||||
impl SecretRevealSource {
|
||||
fn parse(value: &str) -> ksp_core_lib::Result<Self> {
|
||||
return match value {
|
||||
"effective" => std::result::Result::Ok(Self::Effective),
|
||||
"dotenv" => std::result::Result::Ok(Self::DotEnv),
|
||||
_ => std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_SECRET_REVEAL_SOURCE_INVALID,
|
||||
"Secret reveal source must be effective or dotenv",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
const fn label(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Effective => "effective",
|
||||
Self::DotEnv => "dotenv",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Reveals one real Secret value only after the dedicated Tauri command has been explicitly invoked.
|
||||
pub(crate) fn reveal_secret(state: &crate::AppState, request: SecretRevealRequestDto) -> ksp_core_lib::Result<SecretRevealResponseDto> {
|
||||
return reveal_from_management(state.config_management(), request);
|
||||
}
|
||||
|
||||
fn reveal_from_management(management: &ksp_config_lib::ConfigManagement, request: SecretRevealRequestDto) -> ksp_core_lib::Result<SecretRevealResponseDto> {
|
||||
let source = validate_request(request.variable_name.as_str(), request.source.as_str());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let value = match source {
|
||||
SecretRevealSource::Effective => management.reveal_effective_environment_value(request.variable_name.as_str()),
|
||||
SecretRevealSource::DotEnv => management.reveal_dotenv_value(request.variable_name.as_str()),
|
||||
};
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_SECRETS,
|
||||
variable_name = request.variable_name.as_str(),
|
||||
source = source.label(),
|
||||
value_present = value.is_some(),
|
||||
"privileged Config Secret reveal completed"
|
||||
);
|
||||
return std::result::Result::Ok(SecretRevealResponseDto { variable_name: request.variable_name, source: source.label().to_owned(), value });
|
||||
}
|
||||
|
||||
fn validate_request(variable_name: &str, source: &str) -> ksp_core_lib::Result<SecretRevealSource> {
|
||||
let sensitivity = ksp_config_lib::ConfigSensitivity::from_variable_name(variable_name);
|
||||
let sensitivity = match sensitivity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if sensitivity != ksp_config_lib::ConfigSensitivity::Secret {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_SECRET_REVEAL_REQUIRES_SECRET,
|
||||
"Privileged reveal is restricted to KSP/KSPB Secret namespaces",
|
||||
));
|
||||
}
|
||||
return SecretRevealSource::parse(source);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/secrets.rs"]
|
||||
mod tests;
|
||||
172
crates/ksp-app-config-desk/src/splash.rs
Normal file
172
crates/ksp-app-config-desk/src/splash.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
// file: crates/ksp-app-config-desk/src/splash.rs
|
||||
// version: 5
|
||||
|
||||
//! Common splash settings and frontend event contracts for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
const DEFAULT_SPLASH_FADE_MS: u32 = 300;
|
||||
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
|
||||
const ENV_SPLASH_FADE_MS: &str = "KSP_DESK_SPLASH_FADE_MS";
|
||||
const ENV_SPLASH_MINIMUM_MS: &str = "KSP_DESK_SPLASH_MINIMUM_MS";
|
||||
const MAX_SPLASH_FADE_MS: u32 = 10_000;
|
||||
const MAX_SPLASH_MINIMUM_MS: u64 = 60_000;
|
||||
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SplashSettings {
|
||||
minimum_ms: u64,
|
||||
fade_ms: u32,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
fade_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
}
|
||||
|
||||
impl SplashSettings {
|
||||
/// Resolves splash timings through the Config-owned environment snapshot.
|
||||
pub(crate) fn load() -> ksp_core_lib::Result<Self> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let minimum = environment.resolve_variable(ENV_SPLASH_MINIMUM_MS, std::option::Option::Some("1200"));
|
||||
let minimum = match minimum {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade = environment.resolve_variable(ENV_SPLASH_FADE_MS, std::option::Option::Some("300"));
|
||||
let fade = match fade {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let minimum_ms = parse_u64_setting(ENV_SPLASH_MINIMUM_MS, minimum.value(), MAX_SPLASH_MINIMUM_MS);
|
||||
let minimum_ms = match minimum_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_ms = parse_u32_setting(ENV_SPLASH_FADE_MS, fade.value(), MAX_SPLASH_FADE_MS);
|
||||
let fade_ms = match fade_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self { minimum_ms, fade_ms, minimum_source: minimum.source(), fade_source: fade.source() });
|
||||
}
|
||||
|
||||
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
|
||||
#[must_use]
|
||||
pub(crate) const fn fallback() -> Self {
|
||||
return Self {
|
||||
minimum_ms: DEFAULT_SPLASH_MINIMUM_MS,
|
||||
fade_ms: DEFAULT_SPLASH_FADE_MS,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
fade_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the minimum visible duration after splash frontend readiness.
|
||||
#[must_use]
|
||||
pub(crate) const fn minimum_ms(self) -> u64 {
|
||||
return self.minimum_ms;
|
||||
}
|
||||
|
||||
/// Returns the fade duration used for both fade-in and fade-out.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_ms(self) -> u32 {
|
||||
return self.fade_ms;
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the minimum duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn minimum_source(self) -> &'static str {
|
||||
return environment_source_code(self.minimum_source);
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the fade duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_source(self) -> &'static str {
|
||||
return environment_source_code(self.fade_source);
|
||||
}
|
||||
|
||||
/// Returns the minimum backend lifecycle duration from readiness until main activation.
|
||||
#[must_use]
|
||||
pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 {
|
||||
return self.minimum_ms + u64::from(self.fade_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Command emitted by Rust to the splash frontend.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/splash/SplashOrderDto.ts")]
|
||||
pub(crate) struct SplashOrderDto {
|
||||
/// Splash action name.
|
||||
pub(crate) action: String,
|
||||
/// Optional status text.
|
||||
pub(crate) message: std::option::Option<String>,
|
||||
/// Optional animation duration in milliseconds.
|
||||
pub(crate) duration_ms: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl SplashOrderDto {
|
||||
/// Creates a new `SplashOrderDto` value.
|
||||
#[must_use]
|
||||
pub(crate) fn new(action: &str, message: std::option::Option<&str>, duration_ms: std::option::Option<u32>) -> Self {
|
||||
return Self { action: action.to_owned(), message: message.map(std::string::ToString::to_string), duration_ms };
|
||||
}
|
||||
}
|
||||
|
||||
const fn environment_source_code(source: ksp_config_lib::ConfigEnvironmentSource) -> &'static str {
|
||||
return match source {
|
||||
ksp_config_lib::ConfigEnvironmentSource::Process => "process",
|
||||
ksp_config_lib::ConfigEnvironmentSource::DotEnv => "dotenv",
|
||||
ksp_config_lib::ConfigEnvironmentSource::Fallback => "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_u64_setting(variable_name: &str, value: &str, maximum: u64) -> ksp_core_lib::Result<u64> {
|
||||
let parsed = value.parse::<u64>();
|
||||
let parsed = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed > maximum {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_context("maximum_ms", maximum.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
fn parse_u32_setting(variable_name: &str, value: &str, maximum: u32) -> ksp_core_lib::Result<u32> {
|
||||
let parsed = value.parse::<u32>();
|
||||
let parsed = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration is not a valid unsigned integer")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if parsed > maximum {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_SETTING_INVALID, "KSP desk splash duration exceeds the allowed bound")
|
||||
.with_context("variable_name", variable_name)
|
||||
.with_context("maximum_ms", maximum.to_string()),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/splash.rs"]
|
||||
mod tests;
|
||||
252
crates/ksp-app-config-desk/src/tauri.rs
Normal file
252
crates/ksp-app-config-desk/src/tauri.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
// file: crates/ksp-app-config-desk/src/tauri.rs
|
||||
// version: 16
|
||||
|
||||
//! Tauri runtime assembly for the KSP configuration desktop application.
|
||||
|
||||
/// Runs the configuration desktop application.
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> {
|
||||
let app_state = crate::AppState::initialize(arguments);
|
||||
let app_state = match app_state {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut builder = tauri::Builder::default();
|
||||
builder = configure_state(builder, app_state);
|
||||
builder = configure_plugins(builder);
|
||||
builder = configure_commands(builder);
|
||||
builder = configure_setup(builder);
|
||||
let run_result = builder.run(tauri::generate_context!());
|
||||
return match run_result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot run KSP Config Desk Tauri runtime")
|
||||
.with_context("tauri_error", error.to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn configure_state(builder: tauri::Builder<tauri::Wry>, app_state: crate::AppState) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.manage(app_state);
|
||||
}
|
||||
|
||||
fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
let tracing_plugin = tauri_plugin_tracing::Builder::new().build::<tauri::Wry>();
|
||||
return builder.plugin(tracing_plugin);
|
||||
}
|
||||
|
||||
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
|
||||
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.invoke_handler(tauri::generate_handler![
|
||||
get_app_snapshot,
|
||||
get_config_documents,
|
||||
get_config_document_detail,
|
||||
save_config_document_source,
|
||||
get_config_profile_documents,
|
||||
get_config_profile_detail,
|
||||
get_environment_report,
|
||||
set_environment_value,
|
||||
remove_environment_value,
|
||||
reveal_environment_value,
|
||||
get_logging_document,
|
||||
get_logging_runtime_status,
|
||||
apply_logging_profile,
|
||||
save_logging_document,
|
||||
emit_logging_test,
|
||||
emit_frontend_log,
|
||||
splash_frontend_ready
|
||||
]);
|
||||
}
|
||||
|
||||
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.setup(|app| {
|
||||
let splash = crate::require_splash_window(app);
|
||||
if let std::result::Result::Err(error) = splash {
|
||||
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||
}
|
||||
let main = crate::require_main_window(app);
|
||||
if let std::result::Result::Err(error) = main {
|
||||
return std::result::Result::Err(std::boxed::Box::new(error));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_app_snapshot(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::AppSnapshotDto, crate::CommandErrorDto> {
|
||||
let snapshot = state.snapshot();
|
||||
return match snapshot {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_documents(state: tauri::State<'_, crate::AppState>) -> std::vec::Vec<crate::ConfigDocumentSummaryDto> {
|
||||
return crate::document_inventory(&state);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_document_detail(
|
||||
file_id: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigDocumentDetailDto, crate::ConfigDocumentErrorDto> {
|
||||
return crate::document_detail(&state, file_id.as_str());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config_document_source(
|
||||
file_id: String,
|
||||
source: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigDocumentSaveResultDto, crate::ConfigDocumentErrorDto> {
|
||||
return crate::save_document_source(&state, file_id.as_str(), source.as_str());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_profile_documents(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<std::vec::Vec<crate::ConfigProfileDocumentDto>, crate::CommandErrorDto> {
|
||||
let result = crate::profile_inventory(&state);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_profile_detail(
|
||||
file_id: String,
|
||||
profile_id: std::option::Option<String>,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigProfileDetailDto, crate::CommandErrorDto> {
|
||||
let result = crate::profile_detail(&state, file_id.as_str(), profile_id.as_deref());
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_environment_report(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<std::vec::Vec<crate::ConfigEnvironmentReportDto>, crate::CommandErrorDto> {
|
||||
let result = crate::environment_report(&state);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_environment_value(
|
||||
variable_name: String,
|
||||
value: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigEnvironmentChangeDto, crate::CommandErrorDto> {
|
||||
let result = crate::set_environment_value(&state, variable_name.as_str(), value.as_str());
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_environment_value(
|
||||
variable_name: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigEnvironmentChangeDto, crate::CommandErrorDto> {
|
||||
let result = crate::remove_environment_value(&state, variable_name.as_str());
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn reveal_environment_value(
|
||||
request: crate::SecretRevealRequestDto,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::SecretRevealResponseDto, crate::CommandErrorDto> {
|
||||
let result = crate::reveal_secret(&state, request);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_logging_document(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::LoggingDocumentDto, crate::CommandErrorDto> {
|
||||
let result = crate::logging_document(&state);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_logging_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::LoggingRuntimeStatusDto, crate::CommandErrorDto> {
|
||||
let result = crate::logging_runtime_status(&state);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn apply_logging_profile(
|
||||
profile_id: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::LoggingRuntimeStatusDto, crate::CommandErrorDto> {
|
||||
let result = crate::apply_logging_profile(&state, profile_id.as_str());
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_logging_document(
|
||||
candidate: crate::LoggingDocumentCandidateDto,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::LoggingDocumentSaveResultDto, crate::CommandErrorDto> {
|
||||
let result = crate::save_logging_document(&state, candidate);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_logging_test(
|
||||
request: crate::LoggingTestRequestDto,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::LoggingTestResultDto, crate::CommandErrorDto> {
|
||||
let result = crate::emit_logging_test(&state, request);
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn splash_frontend_ready(
|
||||
app: tauri::AppHandle,
|
||||
webview_window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
};
|
||||
}
|
||||
53
crates/ksp-app-config-desk/src/tw_main.rs
Normal file
53
crates/ksp-app-config-desk/src/tw_main.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// file: crates/ksp-app-config-desk/src/tw_main.rs
|
||||
// version: 4
|
||||
|
||||
//! Tauri-window helpers for the Config Desk main window.
|
||||
|
||||
use tauri::Manager; // rust-rules: trait-import
|
||||
|
||||
/// Crate-internal `WINDOW_LABEL_MAIN` constant.
|
||||
pub(crate) const WINDOW_LABEL_MAIN: &str = "main";
|
||||
|
||||
/// Resolves the required main window or returns a typed error.
|
||||
pub(crate) fn require_main_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||
let window = manager.get_webview_window(WINDOW_LABEL_MAIN);
|
||||
return match window {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk main window is missing")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Shows main window.
|
||||
pub(crate) fn show_main_window(app: &tauri::AppHandle) -> ksp_core_lib::Result<()> {
|
||||
let window = crate::require_main_window(app);
|
||||
let window = match window {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let shown = window.show();
|
||||
if let std::result::Result::Err(error) = shown {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot show Config Desk main window")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
let focused = window.set_focus();
|
||||
if let std::result::Result::Err(error) = focused {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot focus Config Desk main window")
|
||||
.with_context("window_label", WINDOW_LABEL_MAIN)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_MAIN,
|
||||
"main window shown and focused"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
140
crates/ksp-app-config-desk/src/tw_splash.rs
Normal file
140
crates/ksp-app-config-desk/src/tw_splash.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
// file: crates/ksp-app-config-desk/src/tw_splash.rs
|
||||
// version: 5
|
||||
|
||||
//! Tauri-window lifecycle for the Config Desk splash window.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use tauri::Manager; // rust-rules: trait-import
|
||||
|
||||
/// Crate-internal `WINDOW_LABEL_SPLASH` constant.
|
||||
pub(crate) const WINDOW_LABEL_SPLASH: &str = "splash";
|
||||
|
||||
const SPLASH_EVENT_NAME: &str = "ksp-splash-order";
|
||||
|
||||
/// Resolves the required splash window or returns a typed error.
|
||||
pub(crate) fn require_splash_window(manager: &impl Manager<tauri::Wry>) -> ksp_core_lib::Result<tauri::WebviewWindow> {
|
||||
let window = manager.get_webview_window(WINDOW_LABEL_SPLASH);
|
||||
return match window {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_MISSING, "Config Desk splash window is missing")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes the crate-internal splash frontend ready service operation for the owning module.
|
||||
pub(crate) async fn splash_frontend_ready_service(
|
||||
app: tauri::AppHandle,
|
||||
invoking_window: tauri::WebviewWindow,
|
||||
state: &crate::AppState,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
if invoking_window.label() != WINDOW_LABEL_SPLASH {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_SPLASH_ORIGIN_INVALID, "Splash readiness may only originate from the splash window")
|
||||
.with_context("window_label", invoking_window.label()),
|
||||
);
|
||||
}
|
||||
if !state.begin_splash_sequence() {
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
"duplicate splash frontend readiness ignored"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let settings = state.splash_settings();
|
||||
let lifecycle_started = std::time::Instant::now();
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
minimum_ms = settings.minimum_ms(),
|
||||
minimum_source = settings.minimum_source(),
|
||||
fade_ms = settings.fade_ms(),
|
||||
fade_source = settings.fade_source(),
|
||||
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||
"splash frontend readiness accepted; starting splash to main lifecycle"
|
||||
);
|
||||
let fade_in = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_in", std::option::Option::Some("Initialisation de KSP Config Desk..."), std::option::Option::Some(settings.fade_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = fade_in {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let minimum_wait_started = std::time::Instant::now();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(settings.minimum_ms())).await;
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
configured_wait_ms = settings.minimum_ms(),
|
||||
actual_wait_ms = minimum_wait_started.elapsed().as_secs_f64() * 1000.0,
|
||||
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash minimum wait completed"
|
||||
);
|
||||
let fade_out = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_out", std::option::Option::Some("Initialisation terminée."), std::option::Option::Some(settings.fade_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = fade_out {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let fade_wait_started = std::time::Instant::now();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(u64::from(settings.fade_ms()))).await;
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
configured_wait_ms = settings.fade_ms(),
|
||||
actual_wait_ms = fade_wait_started.elapsed().as_secs_f64() * 1000.0,
|
||||
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash fade-out wait completed"
|
||||
);
|
||||
let show_main = crate::show_main_window(&app);
|
||||
if let std::result::Result::Err(error) = show_main {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let destroyed = invoking_window.destroy();
|
||||
if let std::result::Result::Err(error) = destroyed {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot destroy Config Desk splash window")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||
actual_backend_lifecycle_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash window destroyed after main activation"
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn emit_order(window: &tauri::WebviewWindow, order: crate::SplashOrderDto) -> ksp_core_lib::Result<()> {
|
||||
let action = order.action.clone();
|
||||
let emitted = window.emit(SPLASH_EVENT_NAME, order);
|
||||
return match emitted {
|
||||
std::result::Result::Ok(()) => {
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = WINDOW_LABEL_SPLASH,
|
||||
action = action.as_str(),
|
||||
"splash order emitted"
|
||||
);
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED, "Cannot emit Config Desk splash order")
|
||||
.with_context("window_label", WINDOW_LABEL_SPLASH)
|
||||
.with_context("action", action)
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
50
crates/ksp-app-config-desk/tauri.conf.json
Normal file
50
crates/ksp-app-config-desk/tauri.conf.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP Config Desk",
|
||||
"version": "0.1.4",
|
||||
"identifier": "com.sasedev.ksp-app-config-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1430",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "splash",
|
||||
"url": "splash.html",
|
||||
"title": "Chargement — KSP Config Desk",
|
||||
"width": 960,
|
||||
"height": 637,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"alwaysOnTop": true
|
||||
},
|
||||
{
|
||||
"label": "main",
|
||||
"url": "main.html",
|
||||
"title": "KSP Config Desk",
|
||||
"width": 1500,
|
||||
"height": 960,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 768,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/favicon.png",
|
||||
"icons/favicon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
72
crates/ksp-app-config-desk/tests/desktop_contract.rs
Normal file
72
crates/ksp-app-config-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 2
|
||||
|
||||
//! Desktop build/shell contract audits for Config Desk.
|
||||
|
||||
fn app_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
|
||||
fn read_text(path: &std::path::Path) -> String {
|
||||
let source = std::fs::read_to_string(path);
|
||||
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||
return match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn read_json(path: &std::path::Path) -> serde_json::Value {
|
||||
let source = read_text(path);
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(source.as_str());
|
||||
assert!(parsed.is_ok(), "unable to parse {}", path.display());
|
||||
return match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => serde_json::Value::Null,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_and_frontend_build_contracts_remain_explicit() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
assert_eq!(tauri.pointer("/build/devUrl").and_then(serde_json::Value::as_str), std::option::Option::Some("http://localhost:1430"));
|
||||
assert_eq!(tauri.pointer("/build/beforeDevCommand").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run dev"));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build"));
|
||||
let windows = tauri.pointer("/app/windows").and_then(serde_json::Value::as_array);
|
||||
assert!(windows.is_some());
|
||||
let windows = match windows {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(windows.len(), 2);
|
||||
let labels = windows.iter().filter_map(|window| return window.get("label").and_then(serde_json::Value::as_str)).collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(labels, ["splash", "main"]);
|
||||
let main = windows.iter().find(|window| return window.get("label").and_then(serde_json::Value::as_str) == std::option::Option::Some("main"));
|
||||
assert!(main.is_some());
|
||||
if let std::option::Option::Some(main) = main {
|
||||
assert_eq!(main.get("visible").and_then(serde_json::Value::as_bool), std::option::Option::Some(false));
|
||||
}
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
let build = package.pointer("/scripts/build").and_then(serde_json::Value::as_str);
|
||||
let check = package.pointer("/scripts/check").and_then(serde_json::Value::as_str);
|
||||
assert!(build.is_some_and(|value| return value.contains("tsc") && value.contains("vite build")));
|
||||
assert_eq!(check, std::option::Option::Some("tsc --noEmit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_registry_owns_view_activation_and_specialized_editor_dispatch() {
|
||||
let root = app_root();
|
||||
let registry = read_text(root.join("frontend/ts/shell_registry.ts").as_path());
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
let documents = read_text(root.join("frontend/ts/documents.ts").as_path());
|
||||
let html = read_text(root.join("frontend/main.html").as_path());
|
||||
assert!(registry.contains("export const viewRegistry"));
|
||||
assert!(registry.contains("fileId: \"cfg.std.logging\""));
|
||||
assert!(registry.contains("viewId: \"logging\""));
|
||||
assert!(main.contains("registeredViewIds()"));
|
||||
assert!(main.contains("ksp:activate-view"));
|
||||
assert!(documents.contains("specializedEditorForFileId"));
|
||||
assert!(documents.contains("requestViewActivation"));
|
||||
assert!(html.contains("id=\"openSpecializedEditor\""));
|
||||
}
|
||||
80
crates/ksp-app-config-desk/tests/desktop_security.rs
Normal file
80
crates/ksp-app-config-desk/tests/desktop_security.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_security.rs
|
||||
// version: 1
|
||||
|
||||
//! Desktop security/ownership regression audits local to Config Desk.
|
||||
|
||||
fn app_root() -> std::path::PathBuf {
|
||||
return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
}
|
||||
|
||||
fn collect_files(directory: &std::path::Path, extension: &str, files: &mut std::vec::Vec<std::path::PathBuf>) {
|
||||
let entries = std::fs::read_dir(directory);
|
||||
let entries = match entries {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files(path.as_path(), extension, files);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some(extension) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(path: &std::path::Path) -> String {
|
||||
let source = std::fs::read_to_string(path);
|
||||
assert!(source.is_ok(), "unable to read {}", path.display());
|
||||
return match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_avoids_native_dialogs_and_persistent_secret_storage() {
|
||||
let root = app_root();
|
||||
let mut typescript = std::vec::Vec::new();
|
||||
collect_files(root.join("frontend/ts").as_path(), "ts", &mut typescript);
|
||||
assert!(!typescript.is_empty());
|
||||
for path in typescript {
|
||||
let source = read_text(path.as_path());
|
||||
for forbidden in ["window.alert(", "window.confirm(", "window.prompt("] {
|
||||
assert!(!source.contains(forbidden), "{} uses forbidden browser-native dialog {forbidden}", path.display());
|
||||
}
|
||||
}
|
||||
let secret_reveal = read_text(root.join("frontend/ts/secret_reveal.ts").as_path());
|
||||
assert!(!secret_reveal.contains("localStorage"));
|
||||
assert!(!secret_reveal.contains("sessionStorage"));
|
||||
assert!(secret_reveal.contains("clearTransientSecretReveal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tauri_commands_and_logging_adapters_remain_on_owned_boundaries() {
|
||||
let root = app_root();
|
||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("tauri-plugin-tracing"));
|
||||
assert!(!manifest.contains("tauri-plugin-log"));
|
||||
let source_root = root.join("src");
|
||||
let mut rust_files = std::vec::Vec::new();
|
||||
collect_files(source_root.as_path(), "rs", &mut rust_files);
|
||||
let mut command_attribute_count = 0usize;
|
||||
for path in rust_files {
|
||||
let source = read_text(path.as_path());
|
||||
let count = source.matches("#[tauri::command]").count();
|
||||
if path.file_name().and_then(std::ffi::OsStr::to_str) == std::option::Option::Some("tauri.rs") {
|
||||
command_attribute_count += count;
|
||||
} else {
|
||||
assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display());
|
||||
}
|
||||
}
|
||||
assert!(command_attribute_count > 0);
|
||||
}
|
||||
14
crates/ksp-app-config-desk/tests/public_api.rs
Normal file
14
crates/ksp-app-config-desk/tests/public_api.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// file: crates/ksp-app-config-desk/tests/public_api.rs
|
||||
// version: 1
|
||||
|
||||
//! Public API checks for the KSP configuration desktop application library.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[test]
|
||||
fn run_entry_point_is_available_from_crate_root() {
|
||||
let run_entry_point: fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()> = ksp_app_config_desk_lib::run;
|
||||
assert_eq!(std::mem::size_of_val(&run_entry_point), std::mem::size_of::<fn(&[std::ffi::OsString]) -> ksp_core_lib::Result<()>>());
|
||||
}
|
||||
31
crates/ksp-app-config-desk/tsconfig.json
Normal file
31
crates/ksp-app-config-desk/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"frontend",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
117
crates/ksp-app-config-desk/unit_tests/bootstrap.rs
Normal file
117
crates/ksp-app-config-desk/unit_tests/bootstrap.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/bootstrap.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn fallback_logging_is_console_only_and_bounded_to_info() {
|
||||
let settings = super::fallback_logging_settings();
|
||||
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
|
||||
assert_eq!(settings.span_events(), ksp_logging_lib::SpanEvents::Off);
|
||||
assert!(settings.files().is_empty());
|
||||
let console = settings.console();
|
||||
assert!(console.is_some());
|
||||
if let std::option::Option::Some(console) = console {
|
||||
assert!(console.enabled());
|
||||
assert_eq!(console.output(), ksp_logging_lib::ConsoleOutput::Stderr);
|
||||
assert_eq!(console.format(), ksp_logging_lib::LogFormat::Human);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_logging_source_is_planned_as_transient_fallback_before_subscriber_installation() {
|
||||
let fixture = BootstrapFixture::new("invalid-source");
|
||||
assert!(fixture.is_ok());
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let malformed = std::fs::write(fixture.config_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME), "{ invalid json");
|
||||
assert!(malformed.is_ok());
|
||||
let management = fixture.management();
|
||||
assert!(management.is_ok());
|
||||
let management = match management {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let plan = super::resolve_logging_startup(&management);
|
||||
let fallback = matches!(&plan, super::LoggingStartupPlan::Fallback { .. });
|
||||
assert!(fallback, "invalid Logging source must produce a fallback startup plan");
|
||||
if let super::LoggingStartupPlan::Fallback { diagnostic, settings, .. } = plan {
|
||||
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
|
||||
assert!(settings.files().is_empty());
|
||||
assert!(!diagnostic.domain.is_empty());
|
||||
assert!(!diagnostic.code.is_empty());
|
||||
assert!(!diagnostic.message.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapFixture {
|
||||
root: std::path::PathBuf,
|
||||
config_root: std::path::PathBuf,
|
||||
schema_root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl BootstrapFixture {
|
||||
fn new(label: &str) -> ksp_core_lib::Result<Self> {
|
||||
let root = std::env::temp_dir().join(std::format!("ksp-config-desk-{label}-{}", std::process::id()));
|
||||
let cleanup = std::fs::remove_dir_all(root.as_path());
|
||||
match cleanup {
|
||||
std::result::Result::Ok(()) | std::result::Result::Err(_) => {},
|
||||
}
|
||||
let config_root = root.join("config");
|
||||
let schema_root = root.join("schemas");
|
||||
let create_config = std::fs::create_dir_all(config_root.as_path());
|
||||
if let std::result::Result::Err(error) = create_config {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot create bootstrap test config root").with_source(error),
|
||||
);
|
||||
}
|
||||
let create_schema = std::fs::create_dir_all(schema_root.as_path());
|
||||
if let std::result::Result::Err(error) = create_schema {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot create bootstrap test schema root").with_source(error),
|
||||
);
|
||||
}
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = match workspace.parent().and_then(std::path::Path::parent) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(
|
||||
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||
"Cannot resolve workspace root for bootstrap test",
|
||||
));
|
||||
},
|
||||
};
|
||||
let source_schema = workspace.join(ksp_config_lib::DEFAULT_SCHEMA_PATH).join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
|
||||
let destination_schema = schema_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
|
||||
let copied = std::fs::copy(source_schema.as_path(), destination_schema.as_path());
|
||||
if let std::result::Result::Err(error) = copied {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Cannot copy bootstrap test Logging schema").with_source(error),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(Self { root, config_root, schema_root });
|
||||
}
|
||||
|
||||
fn management(&self) -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(self.config_root.as_path(), self.schema_root.as_path());
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Drop for BootstrapFixture {
|
||||
fn drop(&mut self) {
|
||||
let cleanup = std::fs::remove_dir_all(self.root.as_path());
|
||||
match cleanup {
|
||||
std::result::Result::Ok(()) | std::result::Result::Err(_) => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/ksp-app-config-desk/unit_tests/documents.rs
Normal file
30
crates/ksp-app-config-desk/unit_tests/documents.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/documents.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn diagnostic_classifier_distinguishes_json_schema_semantic_and_effective_errors() {
|
||||
let json = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "json");
|
||||
assert_eq!(super::classify_error(&json), super::STAGE_JSON);
|
||||
let schema = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "schema");
|
||||
assert_eq!(super::classify_error(&schema), super::STAGE_SCHEMA);
|
||||
let semantic = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "semantic");
|
||||
assert_eq!(super::classify_error(&semantic), super::STAGE_SEMANTIC);
|
||||
let effective = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective");
|
||||
assert_eq!(super::classify_error(&effective), super::STAGE_EFFECTIVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_source_read_errors_are_classified_as_schema_failures() {
|
||||
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED, "missing schema").with_context("file_id", "schema.std.logging");
|
||||
assert_eq!(super::classify_error(&error), super::STAGE_SCHEMA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_error_projection_does_not_expose_arbitrary_error_context() {
|
||||
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "invalid source").with_context("detail", "sensitive-canary");
|
||||
let projection = crate::ConfigDocumentErrorDto::from_error(&error);
|
||||
assert_eq!(projection.diagnostic_stage, super::STAGE_JSON);
|
||||
assert_eq!(projection.error.domain, "config");
|
||||
assert_eq!(projection.error.code, "json_syntax_invalid");
|
||||
assert_eq!(projection.error.message, "invalid source");
|
||||
}
|
||||
11
crates/ksp-app-config-desk/unit_tests/dto_common.rs
Normal file
11
crates/ksp-app-config-desk/unit_tests/dto_common.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/dto_common.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn command_error_projection_excludes_context_values() {
|
||||
let error = ksp_core_lib::Error::new(ksp_core_lib::ErrorCode::new("test", "failure"), "safe message").with_context("secret_canary", "must-not-be-exported");
|
||||
let dto = crate::CommandErrorDto::from_error(&error);
|
||||
assert_eq!(dto.domain, "test");
|
||||
assert_eq!(dto.code, "failure");
|
||||
assert_eq!(dto.message, "safe message");
|
||||
}
|
||||
91
crates/ksp-app-config-desk/unit_tests/environment.rs
Normal file
91
crates/ksp-app-config-desk/unit_tests/environment.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/environment.rs
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn namespace_projection_distinguishes_ksp_and_kspb_sensitivity_families() {
|
||||
assert_eq!(super::namespace_label("KSP_LOGS_DIRECTORY"), "KSP");
|
||||
assert_eq!(super::namespace_label("KSP_PUBLIC_RPC_URL"), "KSP_PUBLIC");
|
||||
assert_eq!(super::namespace_label("KSP_SECRET_API_KEY"), "KSP_SECRET");
|
||||
assert_eq!(super::namespace_label("KSPB_WORKER_MODE"), "KSPB");
|
||||
assert_eq!(super::namespace_label("KSPB_PUBLIC_ENDPOINT"), "KSPB_PUBLIC");
|
||||
assert_eq!(super::namespace_label("KSPB_SECRET_TOKEN"), "KSPB_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_labels_cover_config_sources_and_sensitivities() {
|
||||
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Process)), "process");
|
||||
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::DotEnv)), "dotenv");
|
||||
assert_eq!(super::environment_source_label(std::option::Option::Some(ksp_config_lib::ConfigEnvironmentSource::Fallback)), "fallback");
|
||||
assert_eq!(super::environment_source_label(std::option::Option::None), "none");
|
||||
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Public), "public");
|
||||
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Internal), "internal");
|
||||
assert_eq!(super::sensitivity_label(ksp_config_lib::ConfigSensitivity::Secret), "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_sensitivity_is_owned_by_config_namespace_rules() {
|
||||
let public = super::mutation_sensitivity("KSP_PUBLIC_TEST_VALUE");
|
||||
assert!(public.is_ok());
|
||||
if let std::result::Result::Ok(value) = public {
|
||||
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Public);
|
||||
}
|
||||
let secret = super::mutation_sensitivity("KSP_SECRET_TEST_VALUE");
|
||||
assert!(secret.is_ok());
|
||||
if let std::result::Result::Ok(value) = secret {
|
||||
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Secret);
|
||||
}
|
||||
let internal = super::mutation_sensitivity("KSPB_TEST_VALUE");
|
||||
assert!(internal.is_ok());
|
||||
if let std::result::Result::Ok(value) = internal {
|
||||
assert_eq!(value, ksp_config_lib::ConfigSensitivity::Internal);
|
||||
}
|
||||
assert!(super::mutation_sensitivity("EXTERNAL_TEST_VALUE").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_operation_labels_are_stable() {
|
||||
assert_eq!(super::EnvironmentMutationOperation::Set.label(), "set");
|
||||
assert_eq!(super::EnvironmentMutationOperation::Remove.label(), "remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_environment_report_is_safe_and_deterministic_when_present() {
|
||||
let management = committed_management();
|
||||
assert!(management.is_ok(), "committed management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let report = super::report_from_management(&management);
|
||||
assert!(report.is_ok(), "environment report should project safely: {report:?}");
|
||||
if let std::result::Result::Ok(report) = report {
|
||||
let mut previous = std::option::Option::<&str>::None;
|
||||
for entry in &report {
|
||||
if let std::option::Option::Some(previous) = previous {
|
||||
assert!(previous <= entry.variable_name.as_str());
|
||||
}
|
||||
if entry.sensitivity == "secret" {
|
||||
if let std::option::Option::Some(value) = entry.desired_safe_value.as_deref() {
|
||||
assert_eq!(value, ksp_config_lib::REDACTED_CONFIG_VALUE);
|
||||
}
|
||||
if let std::option::Option::Some(value) = entry.effective_safe_value.as_deref() {
|
||||
assert_eq!(value, ksp_config_lib::REDACTED_CONFIG_VALUE);
|
||||
}
|
||||
}
|
||||
previous = std::option::Option::Some(entry.variable_name.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
|
||||
}
|
||||
40
crates/ksp-app-config-desk/unit_tests/frontend_logging.rs
Normal file
40
crates/ksp-app-config-desk/unit_tests/frontend_logging.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/frontend_logging.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn frontend_log_levels_are_explicit_and_case_insensitive() {
|
||||
assert!(matches!(super::parse_level(" TRACE "), std::result::Result::Ok(super::FrontendLogLevel::Trace)));
|
||||
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::FrontendLogLevel::Debug)));
|
||||
assert!(matches!(super::parse_level("Info"), std::result::Result::Ok(super::FrontendLogLevel::Info)));
|
||||
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::FrontendLogLevel::Warn)));
|
||||
assert!(matches!(super::parse_level("ERROR"), std::result::Result::Ok(super::FrontendLogLevel::Error)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_frontend_log_level_is_rejected() {
|
||||
let result = super::parse_level("notice");
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_log_targets_are_whitelisted() {
|
||||
assert!(matches!(super::parse_target("frontend"), std::result::Result::Ok(super::FrontendLogTarget::Frontend)));
|
||||
assert!(matches!(super::parse_target(" MAIN "), std::result::Result::Ok(super::FrontendLogTarget::Main)));
|
||||
assert!(matches!(super::parse_target("Splash"), std::result::Result::Ok(super::FrontendLogTarget::Splash)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_frontend_log_target_is_rejected() {
|
||||
let result = super::parse_target("ksp-app-config-desk.test.logging");
|
||||
assert!(result.is_err());
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID);
|
||||
}
|
||||
225
crates/ksp-app-config-desk/unit_tests/logging_editor.rs
Normal file
225
crates/ksp-app-config-desk/unit_tests/logging_editor.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/logging_editor.rs
|
||||
// version: 7
|
||||
|
||||
#[test]
|
||||
fn committed_logging_document_maps_complete_read_only_editor_contract() {
|
||||
let management = committed_management();
|
||||
assert!(management.is_ok(), "committed management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let source = management.load_logging_document();
|
||||
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
|
||||
let document = super::document_from_management(&management);
|
||||
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
|
||||
if let (std::result::Result::Ok(source), std::result::Result::Ok(document)) = (source, document) {
|
||||
assert_eq!(document.file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
assert_eq!(document.format_version, source.format_version());
|
||||
assert_eq!(document.logs_directory, source.logs_directory());
|
||||
assert_eq!(document.default_profile, source.default_profile());
|
||||
assert_eq!(document.profiles.len(), source.profiles().len());
|
||||
for (profile, source_profile) in document.profiles.iter().zip(source.profiles()) {
|
||||
assert_eq!(profile.profile_id, source_profile.profile_id());
|
||||
assert_eq!(profile.default_filter, source_profile.default_filter());
|
||||
assert_eq!(profile.span_events, source_profile.span_events());
|
||||
assert_eq!(profile.console.enabled, source_profile.console().enabled());
|
||||
assert_eq!(profile.console.output, source_profile.console().output());
|
||||
assert_eq!(profile.console.format, source_profile.console().format());
|
||||
assert_eq!(profile.files.len(), source_profile.files().len());
|
||||
assert_eq!(profile.target_filters.len(), source_profile.target_filters().len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_editor_mapping_preserves_multiple_targets_and_domains() {
|
||||
let filter = ksp_config_lib::LoggingOutputFilterConfig::new(
|
||||
"trace",
|
||||
std::vec!["ksp-config-lib".to_owned(), "ksp-app-config-desk".to_owned()],
|
||||
std::vec!["config".to_owned(), "frontend".to_owned()],
|
||||
);
|
||||
let mapped = super::project_output_filter(&filter);
|
||||
assert_eq!(mapped.level, "trace");
|
||||
assert_eq!(mapped.targets, std::vec!["ksp-config-lib".to_owned(), "ksp-app-config-desk".to_owned()]);
|
||||
assert_eq!(mapped.domains, std::vec!["config".to_owned(), "frontend".to_owned()]);
|
||||
}
|
||||
|
||||
fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_candidate_round_trips_through_typed_config_contract() {
|
||||
let management = committed_management();
|
||||
assert!(management.is_ok(), "committed management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let document = super::document_from_management(&management);
|
||||
assert!(document.is_ok(), "Logging editor document should load: {document:?}");
|
||||
if let std::result::Result::Ok(document) = document {
|
||||
let candidate = crate::LoggingDocumentCandidateDto {
|
||||
logs_directory: document.logs_directory.clone(),
|
||||
default_profile: document.default_profile.clone(),
|
||||
profiles: document.profiles.clone(),
|
||||
};
|
||||
let config = super::config_candidate(candidate);
|
||||
assert_eq!(config.logs_directory(), document.logs_directory);
|
||||
assert_eq!(config.default_profile(), document.default_profile);
|
||||
assert_eq!(config.profiles().len(), document.profiles.len());
|
||||
for (config_profile, document_profile) in config.profiles().iter().zip(document.profiles.iter()) {
|
||||
assert_eq!(config_profile.profile_id(), document_profile.profile_id);
|
||||
assert_eq!(config_profile.files().len(), document_profile.files.len());
|
||||
for (config_file, document_file) in config_profile.files().iter().zip(document_profile.files.iter()) {
|
||||
assert_eq!(config_file.output_id(), document_file.output_id);
|
||||
assert_eq!(config_file.ansi(), document_file.ansi);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_candidate_preserves_multi_file_and_target_filter_shape() {
|
||||
let profile = crate::LoggingProfileDto {
|
||||
profile_id: "clone_test".to_owned(),
|
||||
default_filter: "info".to_owned(),
|
||||
span_events: "full".to_owned(),
|
||||
console: crate::LoggingConsoleDto {
|
||||
enabled: true,
|
||||
output: "stdout".to_owned(),
|
||||
ansi: false,
|
||||
format: "human".to_owned(),
|
||||
filter: crate::LoggingOutputFilterDto { level: "info".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
|
||||
},
|
||||
files: std::vec![
|
||||
crate::LoggingFileDto {
|
||||
output_id: "file.one".to_owned(),
|
||||
enabled: true,
|
||||
path: "one.log".to_owned(),
|
||||
rotation: "daily".to_owned(),
|
||||
format: "human".to_owned(),
|
||||
ansi: false,
|
||||
filter: crate::LoggingOutputFilterDto { level: "debug".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
|
||||
},
|
||||
crate::LoggingFileDto {
|
||||
output_id: "file.two".to_owned(),
|
||||
enabled: false,
|
||||
path: "two.jsonl".to_owned(),
|
||||
rotation: "hourly".to_owned(),
|
||||
format: "json".to_owned(),
|
||||
ansi: false,
|
||||
filter: crate::LoggingOutputFilterDto {
|
||||
level: "error".to_owned(),
|
||||
targets: std::vec!["ksp-config-lib".to_owned()],
|
||||
domains: std::vec!["config".to_owned()],
|
||||
},
|
||||
},
|
||||
],
|
||||
target_filters: std::vec![crate::LoggingTargetFilterDto { target_prefix: "ksp-app-config-desk".to_owned(), level: "trace".to_owned() }],
|
||||
};
|
||||
let config = super::config_profile(profile);
|
||||
assert_eq!(config.profile_id(), "clone_test");
|
||||
assert_eq!(config.files().len(), 2);
|
||||
assert_eq!(config.files()[1].rotation(), "hourly");
|
||||
assert_eq!(config.target_filters().len(), 1);
|
||||
assert_eq!(config.target_filters()[0].level(), "trace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_failure_rollback_restores_previous_logging_source() {
|
||||
let fixture = rollback_fixture();
|
||||
let fixture = match fixture {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let (root, management, file_id, previous) = fixture;
|
||||
let mut candidate = management.load_logging_document();
|
||||
assert!(candidate.is_ok(), "rollback fixture should load: {candidate:?}");
|
||||
if let std::result::Result::Ok(candidate) = candidate.as_mut() {
|
||||
candidate.set_logs_directory(format!("temporary-runtime-failure-{}", std::process::id()));
|
||||
}
|
||||
if let std::result::Result::Ok(candidate) = candidate {
|
||||
let saved = management.save_logging_document(&candidate);
|
||||
assert!(saved.is_ok(), "rollback fixture mutation should persist: {saved:?}");
|
||||
if let std::result::Result::Ok(saved) = saved {
|
||||
let runtime_error = ksp_core_lib::Error::new(ksp_logging_lib::ERROR_CODE_RELOAD_FAILED, "synthetic runtime reload failure");
|
||||
let rolled_back = super::rollback_after_runtime_failure(&management, &file_id, previous.as_str(), saved.source_changed(), runtime_error);
|
||||
assert!(rolled_back.is_err(), "runtime failure should remain visible after successful source rollback");
|
||||
if let std::result::Result::Err(error) = rolled_back {
|
||||
assert_eq!(error.code(), ksp_logging_lib::ERROR_CODE_RELOAD_FAILED);
|
||||
}
|
||||
let restored = std::fs::read_to_string(root.join("config").join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME)).ok();
|
||||
assert_eq!(restored, std::option::Option::Some(previous));
|
||||
}
|
||||
}
|
||||
let cleanup = std::fs::remove_dir_all(root.as_path());
|
||||
assert!(cleanup.is_ok(), "rollback fixture should cleanup: {cleanup:?}");
|
||||
}
|
||||
|
||||
fn rollback_fixture() -> ksp_core_lib::Result<(std::path::PathBuf, ksp_config_lib::ConfigManagement, ksp_config_lib::ConfigFileId, String)> {
|
||||
let root = std::env::temp_dir().join(format!("ksp-config-desk-logging-rollback-{}", std::process::id()));
|
||||
let cleanup = std::fs::remove_dir_all(root.as_path());
|
||||
if let std::result::Result::Err(error) = cleanup
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot cleanup previous rollback fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let config_root = root.join("config");
|
||||
let schema_root = config_root.join("schemas");
|
||||
let create = std::fs::create_dir_all(schema_root.as_path());
|
||||
if let std::result::Result::Err(error) = create {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot create rollback fixture").with_source(error));
|
||||
}
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let source_config = workspace.join("config").join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME);
|
||||
let source_schema = workspace.join("config/schemas").join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
|
||||
let config_path = config_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_FILENAME);
|
||||
let schema_path = schema_root.join(ksp_config_lib::DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
|
||||
let copied = std::fs::copy(source_config.as_path(), config_path.as_path());
|
||||
if let std::result::Result::Err(error) = copied {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback Config fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let copied = std::fs::copy(source_schema.as_path(), schema_path.as_path());
|
||||
if let std::result::Result::Err(error) = copied {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot copy rollback schema fixture").with_source(error),
|
||||
);
|
||||
}
|
||||
let previous = std::fs::read_to_string(config_path.as_path());
|
||||
let previous = match previous {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "cannot read rollback fixture").with_source(error));
|
||||
},
|
||||
};
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(config_root.as_path(), schema_root.as_path());
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let management = ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry));
|
||||
return std::result::Result::Ok((root, management, file_id, previous));
|
||||
}
|
||||
26
crates/ksp-app-config-desk/unit_tests/logging_runtime.rs
Normal file
26
crates/ksp-app-config-desk/unit_tests/logging_runtime.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/logging_runtime.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn launch_identity_is_safe_and_contains_application_and_process_identity() {
|
||||
let identity = crate::launch_identity();
|
||||
assert!(identity.is_ok());
|
||||
if let std::result::Result::Ok(identity) = identity {
|
||||
assert_eq!(identity.application_id(), crate::TRACING_TARGET);
|
||||
assert!(identity.launch_timestamp().contains("Z-p"));
|
||||
assert!(identity.launch_timestamp().ends_with(std::process::id().to_string().as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_rotation_labels_are_stable() {
|
||||
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Never), "never");
|
||||
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Hourly), "hourly");
|
||||
assert_eq!(super::rotation_label(ksp_logging_lib::FileRotation::Daily), "daily");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_count_projection_is_non_lossy_for_normal_values() {
|
||||
assert_eq!(crate::count_to_u64(0), 0);
|
||||
assert_eq!(crate::count_to_u64(42), 42);
|
||||
}
|
||||
49
crates/ksp-app-config-desk/unit_tests/logging_test.rs
Normal file
49
crates/ksp-app-config-desk/unit_tests/logging_test.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/logging_test.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn controlled_logging_test_levels_include_all_five_levels_and_batch_mode() {
|
||||
assert!(matches!(super::parse_level("trace"), std::result::Result::Ok(super::LoggingTestLevel::Trace)));
|
||||
assert!(matches!(super::parse_level("debug"), std::result::Result::Ok(super::LoggingTestLevel::Debug)));
|
||||
assert!(matches!(super::parse_level("info"), std::result::Result::Ok(super::LoggingTestLevel::Info)));
|
||||
assert!(matches!(super::parse_level("warn"), std::result::Result::Ok(super::LoggingTestLevel::Warn)));
|
||||
assert!(matches!(super::parse_level("error"), std::result::Result::Ok(super::LoggingTestLevel::Error)));
|
||||
assert!(matches!(super::parse_level("all"), std::result::Result::Ok(super::LoggingTestLevel::All)));
|
||||
assert!(super::parse_level("fatal").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controlled_logging_test_targets_are_static_ksp_targets() {
|
||||
assert!(matches!(super::parse_target("app"), std::result::Result::Ok(super::LoggingTestTarget::App)));
|
||||
assert!(matches!(super::parse_target("logging_test"), std::result::Result::Ok(super::LoggingTestTarget::Dedicated)));
|
||||
assert_eq!(super::target_value(super::LoggingTestTarget::App), crate::TRACING_TARGET);
|
||||
assert_eq!(super::target_value(super::LoggingTestTarget::Dedicated), crate::TRACING_TARGET_LOGGING_TEST);
|
||||
assert!(super::parse_target("external").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_test_domain_modes_cover_absent_known_and_bounded_custom_values() {
|
||||
let absent = super::resolve_domain("absent", "ignored");
|
||||
assert!(matches!(absent, std::result::Result::Ok(std::option::Option::None)));
|
||||
let known = super::resolve_domain("known", crate::TRACING_DOMAIN_LOGGING_TEST);
|
||||
assert!(matches!(
|
||||
known.as_ref(),
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) if value == crate::TRACING_DOMAIN_LOGGING_TEST
|
||||
));
|
||||
let custom = super::resolve_domain("custom", "custom.route_1-test");
|
||||
assert!(matches!(
|
||||
custom.as_ref(),
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) if value == "custom.route_1-test"
|
||||
));
|
||||
assert!(super::resolve_domain("custom", "custom route").is_err());
|
||||
assert!(super::resolve_domain("custom", "").is_err());
|
||||
assert!(super::resolve_domain("known", "unknown.domain").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_test_message_is_required_and_bounded() {
|
||||
let message = super::validate_message(" test ");
|
||||
assert!(matches!(message, std::result::Result::Ok("test")));
|
||||
assert!(super::validate_message(" ").is_err());
|
||||
assert!(super::validate_message("x".repeat(501).as_str()).is_err());
|
||||
}
|
||||
87
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
87
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
// version: 5
|
||||
|
||||
#[test]
|
||||
fn profile_inventory_exposes_registered_standard_profile_documents() {
|
||||
let management = fixture_management();
|
||||
assert!(management.is_ok(), "fixture management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let inventory = super::inventory_from_management(&management);
|
||||
assert!(inventory.is_ok(), "profile inventory should resolve: {inventory:?}");
|
||||
if let std::result::Result::Ok(inventory) = inventory {
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_LOGGING;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_TRANSPORT;
|
||||
}));
|
||||
for document in inventory {
|
||||
assert!(!document.default_profile.is_empty());
|
||||
assert!(document.profile_ids.iter().any(|profile_id| -> bool {
|
||||
return profile_id == &document.default_profile;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_profile_detail_uses_safe_effective_value_and_dotenv_provenance() {
|
||||
let management = fixture_management();
|
||||
assert!(management.is_ok(), "fixture management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let source = management.load_logging_document();
|
||||
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
|
||||
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::None);
|
||||
assert!(detail.is_ok(), "default profile should inspect safely: {detail:?}");
|
||||
if let (std::result::Result::Ok(source), std::result::Result::Ok(detail)) = (source, detail) {
|
||||
assert_eq!(detail.selected_profile, source.default_profile());
|
||||
assert_eq!(detail.selection_source, "default_profile");
|
||||
assert!(detail.value_origins.iter().any(|entry| {
|
||||
return entry.key == "logs_directory" && entry.origin == "global";
|
||||
}));
|
||||
assert!(detail.value_origins.iter().any(|entry| {
|
||||
return entry.key == "default_filter" && entry.origin == "profile";
|
||||
}));
|
||||
assert!(detail.environment_provenance.iter().any(|entry| {
|
||||
return entry.json_pointer == "/logs_directory" && entry.variable_name == "KSP_LOGS_DIRECTORY" && entry.sensitivity == "internal";
|
||||
}));
|
||||
assert!(!detail.effective_safe_json.contains("KSP_SECRET_"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_inspection_reports_explicit_selection_source() {
|
||||
let management = fixture_management();
|
||||
assert!(management.is_ok(), "fixture management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let source = management.load_logging_document();
|
||||
assert!(source.is_ok(), "typed Logging source should load: {source:?}");
|
||||
if let std::result::Result::Ok(source) = source {
|
||||
let profile_id = source.default_profile().to_owned();
|
||||
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::Some(profile_id.as_str()));
|
||||
assert!(detail.is_ok(), "explicit profile should inspect: {detail:?}");
|
||||
if let std::result::Result::Ok(detail) = detail {
|
||||
assert_eq!(detail.selected_profile, profile_id);
|
||||
assert_eq!(detail.selection_source, "explicit");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManagement> {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let fixture_root = workspace.join("crates/ksp-config-lib/unit_tests/fixtures");
|
||||
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(fixture_root, workspace.join("config/schemas"));
|
||||
let bootstrap = match bootstrap {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
let registry = match registry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(ksp_config_lib::ConfigManagement::new(ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry)));
|
||||
}
|
||||
32
crates/ksp-app-config-desk/unit_tests/secrets.rs
Normal file
32
crates/ksp-app-config-desk/unit_tests/secrets.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/secrets.rs
|
||||
// version: 2
|
||||
|
||||
#[test]
|
||||
fn reveal_policy_accepts_only_secret_names_and_supported_sources() {
|
||||
let effective = super::validate_request("KSP_SECRET_TEST_VALUE", "effective");
|
||||
assert!(effective.is_ok());
|
||||
if let std::result::Result::Ok(source) = effective {
|
||||
assert_eq!(source.label(), "effective");
|
||||
}
|
||||
let dotenv = super::validate_request("KSPB_SECRET_TEST_VALUE", "dotenv");
|
||||
assert!(dotenv.is_ok());
|
||||
if let std::result::Result::Ok(source) = dotenv {
|
||||
assert_eq!(source.label(), "dotenv");
|
||||
}
|
||||
assert!(super::validate_request("KSP_PUBLIC_TEST_VALUE", "effective").is_err());
|
||||
assert!(super::validate_request("KSP_TEST_VALUE", "effective").is_err());
|
||||
assert!(super::validate_request("EXTERNAL_SECRET_VALUE", "effective").is_err());
|
||||
assert!(super::validate_request("KSP_SECRET_TEST_VALUE", "unknown").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_response_type_does_not_require_debug_or_clone_contracts() {
|
||||
let response = crate::SecretRevealResponseDto {
|
||||
variable_name: "KSP_SECRET_TEST_VALUE".to_owned(),
|
||||
source: "dotenv".to_owned(),
|
||||
value: std::option::Option::Some("secret-canary".to_owned()),
|
||||
};
|
||||
assert_eq!(response.variable_name, "KSP_SECRET_TEST_VALUE");
|
||||
assert_eq!(response.source, "dotenv");
|
||||
assert_eq!(response.value.as_deref(), std::option::Option::Some("secret-canary"));
|
||||
}
|
||||
37
crates/ksp-app-config-desk/unit_tests/splash.rs
Normal file
37
crates/ksp-app-config-desk/unit_tests/splash.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/splash.rs
|
||||
// version: 3
|
||||
|
||||
#[test]
|
||||
fn fallback_splash_settings_are_short_and_ordered() {
|
||||
let settings = crate::SplashSettings::fallback();
|
||||
assert_eq!(settings.minimum_ms(), 1200);
|
||||
assert_eq!(settings.fade_ms(), 300);
|
||||
assert_eq!(settings.minimum_source(), "fallback");
|
||||
assert_eq!(settings.fade_source(), "fallback");
|
||||
assert_eq!(settings.expected_backend_lifecycle_ms(), 1500);
|
||||
assert!(settings.minimum_ms() >= u64::from(settings.fade_ms()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_order_contract_keeps_action_message_and_duration() {
|
||||
let order = crate::SplashOrderDto::new("fade_out", std::option::Option::Some("done"), std::option::Option::Some(300));
|
||||
assert_eq!(order.action, "fade_out");
|
||||
assert_eq!(order.message.as_deref(), std::option::Option::Some("done"));
|
||||
assert_eq!(order.duration_ms, std::option::Option::Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_duration_parser_rejects_invalid_or_unbounded_values() {
|
||||
let malformed = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "invalid", 60_000);
|
||||
let oversized_minimum = super::parse_u64_setting("KSP_DESK_SPLASH_MINIMUM_MS", "60001", 60_000);
|
||||
let oversized_fade = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_MS", "10001", 10_000);
|
||||
assert!(malformed.is_err());
|
||||
assert!(oversized_minimum.is_err());
|
||||
assert!(oversized_fade.is_err());
|
||||
if let std::result::Result::Err(error) = oversized_minimum {
|
||||
assert!(error.context().iter().any(|field| return field.key() == "maximum_ms" && field.value() == "60000"));
|
||||
}
|
||||
if let std::result::Result::Err(error) = oversized_fade {
|
||||
assert!(error.context().iter().any(|field| return field.key() == "maximum_ms" && field.value() == "10000"));
|
||||
}
|
||||
}
|
||||
72
crates/ksp-app-config-desk/vite.config.ts
Normal file
72
crates/ksp-app-config-desk/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// file: crates/ksp-app-config-desk/vite.config.ts
|
||||
// version: 2
|
||||
|
||||
import { NodePackageImporter } from "sass-embedded";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite";
|
||||
|
||||
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
|
||||
const frontendDist = normalizePath(resolve(appRoot, "../../../builds/khadhroony-solana-project/ksp-app-config-desk/dist"));
|
||||
const devHost = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
root: frontendRoot,
|
||||
publicDir: false,
|
||||
input: {
|
||||
main: normalizePath(resolve(frontendRoot, "main.html")),
|
||||
splash: normalizePath(resolve(frontendRoot, "splash.html")),
|
||||
},
|
||||
build: {
|
||||
outDir: frontendDist,
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: true,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
entryFileNames: "js/[name]-[hash].js",
|
||||
chunkFileNames: "js/chunks/[name]-[hash].js",
|
||||
assetFileNames: assetInfo => {
|
||||
const originalName = assetInfo.names[0] ?? "";
|
||||
const extension = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase();
|
||||
if (extension === "css") {
|
||||
return "css/[name]-[hash][extname]";
|
||||
}
|
||||
if (["eot", "otf", "ttf", "woff", "woff2"].includes(extension)) {
|
||||
return "fonts/[name]-[hash][extname]";
|
||||
}
|
||||
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(extension)) {
|
||||
return "imgs/[name][extname]";
|
||||
}
|
||||
return "otherassets/[name][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
quietDeps: true,
|
||||
silenceDeprecations: ["import", "color-functions", "global-builtin"],
|
||||
verbose: false,
|
||||
importers: [new NodePackageImporter()],
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1430,
|
||||
strictPort: true,
|
||||
host: devHost || false,
|
||||
ws: {
|
||||
protocol: "ws",
|
||||
host: devHost || "localhost",
|
||||
port: 1431,
|
||||
},
|
||||
watch: {
|
||||
ignored: ["**/src/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-config-lib/Cargo.toml
|
||||
# version: 3
|
||||
# version: 6
|
||||
|
||||
[package]
|
||||
name = "ksp-config-lib"
|
||||
@@ -10,9 +10,13 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
serde.workspace = true
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/README.md -->
|
||||
<!-- version: 1 -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# ksp-config-lib
|
||||
|
||||
@@ -12,7 +12,7 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
`ksp-config-lib` possède :
|
||||
|
||||
- le bootstrap non récursif `config/` / `config/schemas/` et les overrides `--cfgpath` / `--schemapath` ;
|
||||
- le registre logique `file_id -> filename` et les overrides `--filemap=<file_id>=<filename>` ;
|
||||
- le registre logique `file_id -> filename`, son inventaire public read-only ordonné par `file_id` et les overrides `--filemap=<file_id>=<filename>` ;
|
||||
- la lecture JSON et la validation JSON Schema Draft 2020-12 ;
|
||||
- les invariants sémantiques KSP des documents connus ;
|
||||
- les globals, `default_profile`, profils nommés et leur provenance ;
|
||||
@@ -23,20 +23,27 @@ La crate centralise les documents JSON, leurs schemas, les profils et compositio
|
||||
- la classification `Public`, `Internal`, `Secret` ;
|
||||
- les représentations réelle et sûre/redacted ainsi que la provenance des valeurs résolues ;
|
||||
- l'adapter du document Logging effectif vers `ksp_logging_lib::LoggingSettings` ;
|
||||
- la surface de management pour inspecter les sources, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||
- l'adapter du document HTTP Transport effectif vers `ksp_onchain_transport_lib::HttpTransportSettings`, y compris redaction/provenance des URLs `KSP_SECRET_*` ;
|
||||
- la surface de management pour inspecter et réparer les sources Config enregistrées, modifier `std.logging.json`, consulter les rapports d'environnement, révéler explicitement une valeur réelle et modifier `.env` ;
|
||||
- les écritures atomiques JSON/`.env` et la protection des permissions `.env` ;
|
||||
- les audits workspace empêchant les bypass d'ownership Config et les oublis dans `.env.example`.
|
||||
|
||||
## Ressources gérées dans `0.1.3`
|
||||
## Ressources gérées
|
||||
|
||||
Le registre par défaut connaît :
|
||||
|
||||
```text
|
||||
cfg.std.logging -> config/std.logging.json
|
||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||
schema.composite -> config/schemas/composite.schema.json
|
||||
cfg.std.logging -> config/std.logging.json
|
||||
cfg.std.transport -> config/std.transport.json
|
||||
schema.std.logging -> config/schemas/std.logging.schema.json
|
||||
schema.std.transport -> config/schemas/std.transport.schema.json
|
||||
schema.composite -> config/schemas/composite.schema.json
|
||||
```
|
||||
|
||||
`ConfigFileRegistry::descriptors()` expose ces descripteurs en lecture seule et dans un ordre déterministe par `file_id`. Une application de management peut ainsi découvrir les fichiers connus sans maintenir une liste parallèle ni dépendre de leurs filenames physiques.
|
||||
|
||||
`ConfigManagement::read_source()` permet d'inspecter le texte brut d'un document Config enregistré même lorsque ce document est invalide. `save_source_candidate()` complète cette frontière : le candidat brut est parsé, validé contre son schema et les invariants sémantiques KSP, puis persisté atomiquement uniquement après validation complète. Le `file_id` doit appartenir au registre et désigner un document Config ; aucun path arbitraire n'est accepté.
|
||||
|
||||
`config/examples/composite.example.json` démontre le format composite sans créer de composite runtime fictif.
|
||||
|
||||
Le fichier local d'environnement est :
|
||||
@@ -55,15 +62,15 @@ Les autres crates et applications KSP ne doivent pas :
|
||||
- parser ou écrire directement `.env` ;
|
||||
- ouvrir directement les documents Config connus par leur filename physique ;
|
||||
- réimplémenter la sélection de profils, les compositions ou les placeholders ;
|
||||
- reconstruire elles-mêmes la configuration Logging depuis le JSON.
|
||||
- reconstruire elles-mêmes la configuration Logging ou Transport depuis le JSON.
|
||||
|
||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result` et de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`.
|
||||
`ksp-config-lib` dépend de `ksp-core-lib` pour `Error`/`Result`, de `ksp-logging-lib` pour les événements Config utiles et le contrat `LoggingSettings`, et de `ksp-onchain-transport-lib` pour construire le contrat runtime Transport dans la direction Config -> Transport.
|
||||
|
||||
La dépendance inverse est interdite : `ksp-core-lib` et `ksp-logging-lib` ne dépendent pas de Config.
|
||||
La dépendance inverse est interdite : `ksp-core-lib`, `ksp-logging-lib` et `ksp-onchain-transport-lib` ne dépendent pas de Config.
|
||||
|
||||
Config ne possède pas le `LoggingGuard`. L'application ou le service qui orchestre le runtime construit la configuration effective puis possède le lifecycle `ksp_logging_lib::initialize/reinitialize`.
|
||||
|
||||
Tauri et les DTO TS-RS restent hors de cette crate. La future `ksp-app-config-desk` doit rester une frontière applicative mince au-dessus des APIs Config.
|
||||
Tauri et les DTO TS-RS restent hors de cette crate. `ksp-app-config-desk` reste une frontière applicative mince au-dessus des APIs Config et découvre les documents standards via le registre Config sans déplacer leur logique métier dans l'application.
|
||||
|
||||
## Secrets
|
||||
|
||||
@@ -71,12 +78,13 @@ Un secret reste accessible au runtime ou au management lorsqu'un consumer autori
|
||||
|
||||
Les méthodes `reveal_*` constituent un opt-in explicite au réel. L'authentification/autorisation de l'utilisateur humain appartient à l'application appelante et les valeurs retournées par ces méthodes ne doivent jamais être journalisées.
|
||||
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective.
|
||||
Le document Logging refuse les valeurs de sensibilité `Secret` dans sa configuration effective. Le document Transport les accepte pour les URLs endpoint : la valeur réelle est transmise au runtime légitime, tandis que la projection sûre et les `Debug` restent redacted.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — construction du moteur, résolution runtime et management ;
|
||||
- [`TODO.md`](TODO.md) — points explicitement différés après `0.1.3` ;
|
||||
- [`../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](../../docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan historique détaillé de la fondation Config ;
|
||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — premier document standard concret ;
|
||||
- [`../../config/std.logging.json`](../../config/std.logging.json) — document standard Logging ;
|
||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — document standard HTTP Transport ;
|
||||
- [`../../.env.example`](../../.env.example) — inventaire versionné des variables d'environnement runtime.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/TODO.md -->
|
||||
<!-- version: 1 -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# TODO ksp-config-lib
|
||||
|
||||
@@ -9,7 +9,13 @@ Aucun TODO fonctionnel bloquant n'est ouvert pour la fondation Config `0.1.3`.
|
||||
|
||||
Les responsabilités prévues pour cette release sont implémentées et couvertes par les tests : bootstrap, registre `file_id`, JSON/JSON Schema, profils, composites, environnement process/`.env`, placeholders, sensibilité/provenance, adapter Logging, management/persistence et audits d'ownership.
|
||||
|
||||
## Reporté explicitement à `0.1.4`
|
||||
## Extensions bornées révélées par `0.1.4`
|
||||
|
||||
`0.1.4-pre.002` ajoute l'inventaire public read-only de `ConfigFileRegistry`, nécessaire au shell Documents de `ksp-app-config-desk`. Le registre reste propriétaire des descripteurs ; l'application n'entretient pas de liste parallèle de `file_id`.
|
||||
|
||||
`0.1.4-pre.003` ferme la seconde lacune révélée pendant `pre.001` : `ConfigManagement::save_source_candidate()` permet de soumettre le texte corrigé d'un `file_id` Config connu, de le parser et de le valider entièrement, puis de le persister atomiquement sans accès filesystem direct de l'application. Les deux extensions Config préalables au shell desktop sont donc traitées.
|
||||
|
||||
## Validation desktop de `0.1.4`
|
||||
|
||||
La validation applicative desktop appartient à `ksp-app-config-desk` :
|
||||
|
||||
@@ -25,11 +31,15 @@ La validation applicative desktop appartient à `ksp-app-config-desk` :
|
||||
|
||||
Ces points ne nécessitent pas de duplication de logique dans `ksp-config-lib`; toute lacune réelle révélée par l'application ouvrira un delta Config explicite.
|
||||
|
||||
## Extension `0.2.1` — Transport HTTP
|
||||
|
||||
`0.2.1-pre.006` introduit le premier nouveau domaine standard depuis Logging : `std.transport.json`, son schema, son exemple, son enregistrement et l'adapter Config -> `HttpTransportSettings`. Cette extension confirme que les nouveaux domaines restent ajoutés à la demande d'un consumer réel, sans transformer Config en propriétaire du runtime Transport.
|
||||
|
||||
## Futur, uniquement au besoin
|
||||
|
||||
Les capacités suivantes sont différées jusqu'à l'apparition de composants réels :
|
||||
|
||||
- nouveaux documents `std.<domain>.json` et schemas associés ;
|
||||
- documents `std.<domain>.json` supplémentaires et schemas associés ;
|
||||
- descriptors `cfg.composite.<consumer>` pour de vrais consumers ;
|
||||
- contrats typés de management supplémentaires pour les nouveaux documents ;
|
||||
- watcher filesystem/reload automatique si une application ou un service démontre le besoin ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-config-lib/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Utilisation de ksp-config-lib
|
||||
|
||||
@@ -29,10 +29,27 @@ Les arguments compris par Config sont :
|
||||
--cfgpath=/path/to/config
|
||||
--schemapath=/path/to/schemas
|
||||
--filemap=cfg.std.logging=my-logging.json
|
||||
--filemap=cfg.std.transport=my-transport.json
|
||||
```
|
||||
|
||||
`cfgpath` et `schemapath` ne sont jamais lus depuis JSON, `.env` ou une variable KSP : cette règle évite un bootstrap récursif.
|
||||
|
||||
### 1.1 Inventorier les fichiers enregistrés
|
||||
|
||||
Le registre expose une vue read-only déterministe des descripteurs connus :
|
||||
|
||||
```rust
|
||||
for descriptor in registry.descriptors() {
|
||||
let file_id = descriptor.file_id().as_str();
|
||||
let kind = descriptor.kind();
|
||||
let filename = descriptor.filename();
|
||||
let schema_file_id = descriptor.schema_file_id();
|
||||
let _ = (file_id, kind, filename, schema_file_id);
|
||||
}
|
||||
```
|
||||
|
||||
L'ordre est celui des `file_id`. La vue reflète les éventuels overrides `--filemap` déjà appliqués tout en conservant le kind et l'association de schema. Elle permet notamment à une application de management de construire sa liste de documents/schemas sans dupliquer le registre dans sa propre couche.
|
||||
|
||||
## 2. Charger et valider un document connu
|
||||
|
||||
Les consumers utilisent un `file_id` logique :
|
||||
@@ -106,6 +123,23 @@ Un `logs_directory` relatif est ancré sur le current working directory du proce
|
||||
|
||||
Les `files[].path` restent relatifs sous le root Logging, y compris après interpolation.
|
||||
|
||||
### 4.1 Construire le Transport HTTP depuis Config
|
||||
|
||||
Config possède également l'adapter du document `std.transport` vers le contrat runtime de `ksp-onchain-transport-lib` :
|
||||
|
||||
```rust
|
||||
let transport = match engine.load_resolved_transport_config(std::option::Option::None, &environment) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
let transport_settings = transport.into_settings();
|
||||
```
|
||||
|
||||
Les scalaires `*_ms` restent des valeurs Config et sont convertis en `std::time::Duration` par l'adapter. Les URLs peuvent provenir de `KSP_PUBLIC_*` ou de `KSP_SECRET_*`; dans ce dernier cas la valeur réelle reste disponible au runtime Transport, mais `ResolvedTransportConfig::effective().safe_value()` et les représentations `Debug` sont redacted.
|
||||
|
||||
La dépendance reste unidirectionnelle : Config connaît le contrat Transport pour le construire ; Transport ne connaît ni Config, ni `.env`, ni les variables KSP.
|
||||
|
||||
## 5. Profils et composites
|
||||
|
||||
Pour un document standard profilé :
|
||||
@@ -197,6 +231,7 @@ Ce guide reste volontairement indépendant des numéros de release. Les contrats
|
||||
| Environnement | `ConfigEnvironment`, `ConfigEnvironmentSource`, `ConfigEnvironmentValue`, `DEFAULT_DOTENV_PATH`, `DEFAULT_DOTENV_EXAMPLE_PATH` | §3, §7–8 |
|
||||
| Sensibilité/provenance | `ConfigSensitivity`, `ConfigValueProvenance`, `ResolvedConfigText`, `ResolvedConfigJson`, `REDACTED_CONFIG_VALUE` | §3 |
|
||||
| Logging effectif | `ResolvedLoggingConfig` | §4 |
|
||||
| Transport effectif | `ResolvedTransportConfig` | §4.1 |
|
||||
| Management | `ConfigManagement`, `ConfigManagedSource`, `ConfigDocumentChangeReport`, `ConfigEnvironmentReport`, `ConfigEnvironmentChangeReport` | §6–7 |
|
||||
| Source Logging typée | `LoggingConfigDocument`, `LoggingProfileConfig`, `LoggingConsoleConfig`, `LoggingFileConfig`, `LoggingOutputFilterConfig`, `LoggingTargetFilterConfig` | §6 et exemple ci-dessous |
|
||||
| Erreurs Config | constantes `ERROR_CODE_*` réexportées par la crate | exemple ci-dessous |
|
||||
@@ -232,7 +267,7 @@ if saved.source_changed() && saved.reload_required() {
|
||||
|
||||
La construction depuis zéro utilise les constructeurs publics `LoggingConfigDocument::new`, `LoggingProfileConfig::new`, `LoggingConsoleConfig::new`, `LoggingFileConfig::new`, `LoggingOutputFilterConfig::new` et `LoggingTargetFilterConfig::new`. Les mêmes contraintes schema/sémantiques sont appliquées au moment de `save_logging_document()`.
|
||||
|
||||
### 10.2 Inspecter un source enregistré sans contourner Config
|
||||
### 10.2 Inspecter et réparer un source enregistré sans contourner Config
|
||||
|
||||
```rust
|
||||
let source = match management.read_source(&file_id) {
|
||||
@@ -245,7 +280,31 @@ let managed_path = source.path();
|
||||
let raw_content = source.content();
|
||||
```
|
||||
|
||||
Cette API est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
|
||||
Cette lecture est notamment destinée à une UI de réparation lorsque le document n'est plus validable. Elle n'autorise pas la lecture d'un chemin arbitraire.
|
||||
|
||||
Après édition du texte brut, le candidat est soumis à Config :
|
||||
|
||||
```rust
|
||||
let saved = match management.save_source_candidate(&file_id, edited_source.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
|
||||
if saved.source_changed() && saved.reload_required() {
|
||||
// Reload the affected Config consumer through the application lifecycle.
|
||||
}
|
||||
```
|
||||
|
||||
`save_source_candidate()` :
|
||||
|
||||
- accepte uniquement un `file_id` enregistré de kind `Config` ;
|
||||
- parse le candidat comme JSON ;
|
||||
- valide le schema enregistré et les invariants sémantiques KSP ;
|
||||
- ne remplace aucune donnée lorsque l'une de ces validations échoue ;
|
||||
- persiste atomiquement le texte brut validé sans reformattage implicite ;
|
||||
- retourne `ConfigDocumentChangeReport` pour distinguer un changement réel d'un candidat identique.
|
||||
|
||||
Le path reste résolu exclusivement par `ConfigFileRegistry`; l'appelant ne fournit jamais de path physique.
|
||||
|
||||
### 10.3 Exploiter les rapports `.env`
|
||||
|
||||
@@ -284,4 +343,3 @@ if let std::result::Result::Err(error) = loaded {
|
||||
```
|
||||
|
||||
Le message/context d'erreur reste destiné au diagnostic ; l'identité machine-readable passe par `ErrorCode`.
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// file: crates/ksp-config-lib/src/bootstrap.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/// Default root containing KSP runtime configuration documents.
|
||||
pub const DEFAULT_CFG_PATH: &str = "config";
|
||||
/// Default root containing KSP JSON schemas.
|
||||
pub const DEFAULT_SCHEMA_PATH: &str = "config/schemas";
|
||||
/// Bootstrap argument used to replace the configuration document root.
|
||||
pub const ARG_CFG_PATH: &str = "--cfgpath";
|
||||
/// Bootstrap argument used to replace the schema root.
|
||||
pub const ARG_SCHEMA_PATH: &str = "--schemapath";
|
||||
/// Default root containing KSP runtime configuration documents.
|
||||
pub const DEFAULT_CFG_PATH: &str = "config";
|
||||
/// Default root containing KSP JSON schemas.
|
||||
pub const DEFAULT_SCHEMA_PATH: &str = "config/schemas";
|
||||
|
||||
/// Non-recursive bootstrap options required before Config can resolve any managed document.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/composite.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/// One resolved document component selected by a composite profile.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -118,6 +118,7 @@ struct CompositeDocumentReferenceSource {
|
||||
profile_id: std::option::Option<String>,
|
||||
}
|
||||
|
||||
/// Validates composite document contract.
|
||||
pub(crate) fn validate_composite_document_contract(engine: &crate::ConfigDocumentEngine, document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||
if !document.file_id().as_str().starts_with("cfg.composite.") {
|
||||
return std::result::Result::Ok(());
|
||||
@@ -198,12 +199,8 @@ fn resolve_composite_document(
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = crate::profile::load_resolved_profile_with_source(
|
||||
engine,
|
||||
&file_id,
|
||||
reference.profile_id.as_deref(),
|
||||
crate::ConfigProfileSelectionSource::Composite,
|
||||
);
|
||||
let resolved =
|
||||
crate::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -262,8 +259,7 @@ fn validate_reference(
|
||||
"referenced file_id is not a Config document",
|
||||
));
|
||||
}
|
||||
let resolved =
|
||||
crate::profile::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
|
||||
let resolved = crate::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite);
|
||||
if let std::result::Result::Err(error) = resolved {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
7
crates/ksp-config-lib/src/constants.rs
Normal file
7
crates/ksp-config-lib/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: crates/ksp-config-lib/src/constants.rs
|
||||
// version: 1
|
||||
|
||||
//! Config-owned tracing constants.
|
||||
|
||||
/// Owning tracing target for events emitted by the Config crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "ksp-config-lib";
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/document.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -85,6 +85,22 @@ impl ConfigDocumentEngine {
|
||||
return self.validate_document(document, &schema_file_id);
|
||||
}
|
||||
|
||||
/// Validates source candidate.
|
||||
pub(crate) fn validate_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||
let path = self.registry.resolve_path(&self.bootstrap, file_id);
|
||||
let path = match path {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let value = serde_json::from_str::<serde_json::Value>(source);
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(json_syntax_error(file_id, path.as_path(), error)),
|
||||
};
|
||||
return self.validate_candidate(file_id, value);
|
||||
}
|
||||
|
||||
/// Validates candidate.
|
||||
pub(crate) fn validate_candidate(&self, file_id: &crate::ConfigFileId, value: serde_json::Value) -> ksp_core_lib::Result<ConfigJsonDocument> {
|
||||
let descriptor = self.registry.descriptor(file_id);
|
||||
let descriptor = match descriptor {
|
||||
@@ -242,11 +258,11 @@ fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument)
|
||||
}
|
||||
|
||||
fn validate_document_semantics(engine: &ConfigDocumentEngine, document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||
let profile_validation = crate::profile::validate_document_profile_contract(document);
|
||||
let profile_validation = crate::validate_document_profile_contract(document);
|
||||
if let std::result::Result::Err(error) = profile_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let composite_validation = crate::composite::validate_composite_document_contract(engine, document);
|
||||
let composite_validation = crate::validate_composite_document_contract(engine, document);
|
||||
if let std::result::Result::Err(error) = composite_validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// file: crates/ksp-config-lib/src/environment.rs
|
||||
// version: 4
|
||||
|
||||
/// Default local environment file read by Config from the process launch directory.
|
||||
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||
// version: 8
|
||||
|
||||
/// Versioned environment contract template expected at the repository/runtime root.
|
||||
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
|
||||
/// Default local environment file read by Config from the process launch directory.
|
||||
pub const DEFAULT_DOTENV_PATH: &str = ".env";
|
||||
|
||||
const LOGGING_TARGET: &str = "ksp-config-lib";
|
||||
const LOGGING_DOMAIN: &str = "config.environment";
|
||||
|
||||
/// Source that supplied one resolved Config environment variable.
|
||||
@@ -231,6 +229,7 @@ impl ConfigEnvironment {
|
||||
};
|
||||
}
|
||||
|
||||
/// Loads from dotenv path.
|
||||
pub(crate) fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
|
||||
let process = collect_process_environment(std::env::vars_os());
|
||||
let process = match process {
|
||||
@@ -245,57 +244,24 @@ impl ConfigEnvironment {
|
||||
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
|
||||
}
|
||||
|
||||
/// Returns the current process values.
|
||||
pub(crate) const fn process_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||
return &self.process;
|
||||
}
|
||||
|
||||
/// Returns the current dotenv values.
|
||||
pub(crate) const fn dotenv_values(&self) -> &std::collections::BTreeMap<String, String> {
|
||||
return &self.dotenv;
|
||||
}
|
||||
|
||||
/// Builds `ConfigEnvironment` from maps.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
|
||||
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
|
||||
where
|
||||
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
|
||||
{
|
||||
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||
for (name, value) in values {
|
||||
let name = match name.to_str() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !has_supported_namespace(name) {
|
||||
continue;
|
||||
}
|
||||
let validation = validate_supported_variable_name(name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let value = value.into_string();
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
|
||||
};
|
||||
output.insert(name.to_owned(), value);
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||
let content = std::fs::read_to_string(path);
|
||||
let content = match content {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
|
||||
};
|
||||
return parse_dotenv_content(path, content.as_str());
|
||||
}
|
||||
|
||||
/// Executes the crate-internal parse dotenv content operation for the owning module.
|
||||
pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||
for (line_index, raw_line) in content.lines().enumerate() {
|
||||
@@ -343,6 +309,61 @@ pub(crate) fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
/// Validates supported variable name.
|
||||
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
|
||||
if !has_supported_namespace(variable_name) {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
|
||||
}
|
||||
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
|
||||
if variable_name.len() <= prefix_length {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
|
||||
}
|
||||
for byte in variable_name.bytes() {
|
||||
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
|
||||
if !valid {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
|
||||
where
|
||||
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
|
||||
{
|
||||
let mut output = std::collections::BTreeMap::<String, String>::new();
|
||||
for (name, value) in values {
|
||||
let name = match name.to_str() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !has_supported_namespace(name) {
|
||||
continue;
|
||||
}
|
||||
let validation = validate_supported_variable_name(name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let value = value.into_string();
|
||||
let value = match value {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
|
||||
};
|
||||
output.insert(name.to_owned(), value);
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
|
||||
let content = std::fs::read_to_string(path);
|
||||
let content = match content {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
|
||||
};
|
||||
return crate::parse_dotenv_content(path, content.as_str());
|
||||
}
|
||||
|
||||
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
|
||||
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
|
||||
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
|
||||
@@ -517,23 +538,6 @@ fn escape_json_pointer_token(value: &str) -> String {
|
||||
return value.replace('~', "~0").replace('/', "~1");
|
||||
}
|
||||
|
||||
pub(crate) fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
|
||||
if !has_supported_namespace(variable_name) {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
|
||||
}
|
||||
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
|
||||
if variable_name.len() <= prefix_length {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
|
||||
}
|
||||
for byte in variable_name.bytes() {
|
||||
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
|
||||
if !valid {
|
||||
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn has_supported_namespace(variable_name: &str) -> bool {
|
||||
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
|
||||
}
|
||||
@@ -556,7 +560,7 @@ fn is_generic_dotenv_name(variable_name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn emit_missing_variable_warning(variable_name: &str) {
|
||||
ksp_logging_lib::warn!(target: LOGGING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
||||
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
|
||||
}
|
||||
|
||||
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
|
||||
|
||||
@@ -1,68 +1,47 @@
|
||||
// file: crates/ksp-config-lib/src/error.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
/// Error code used when a Config bootstrap argument is missing its value.
|
||||
pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value");
|
||||
|
||||
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
|
||||
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
|
||||
|
||||
/// Error code used when a logical Config file identifier is malformed.
|
||||
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
|
||||
|
||||
/// Error code used when a requested logical Config file identifier is not registered.
|
||||
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
|
||||
|
||||
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
|
||||
|
||||
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
|
||||
|
||||
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
|
||||
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
|
||||
|
||||
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
|
||||
|
||||
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
|
||||
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
|
||||
|
||||
/// Error code used when a Config document does not satisfy its registered JSON Schema.
|
||||
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
|
||||
|
||||
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
|
||||
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
|
||||
|
||||
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
|
||||
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
|
||||
|
||||
/// Error code used when a composite document references an invalid, unknown, or unsupported Config document.
|
||||
pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid");
|
||||
|
||||
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
|
||||
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");
|
||||
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
|
||||
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
|
||||
|
||||
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
|
||||
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
|
||||
|
||||
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
|
||||
|
||||
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
|
||||
|
||||
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
|
||||
|
||||
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
|
||||
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
|
||||
|
||||
/// Error code used when an environment-resolved Config cannot be mapped safely to a runtime consumer contract.
|
||||
pub const ERROR_CODE_EFFECTIVE_CONFIG_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "effective_config_invalid");
|
||||
|
||||
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
|
||||
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");
|
||||
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
|
||||
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
|
||||
/// Error code used when a referenced Config environment variable is absent and has no fallback.
|
||||
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
|
||||
/// Error code used when the same logical Config file identifier is registered more than once.
|
||||
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
|
||||
/// Error code used when a logical Config file identifier is malformed.
|
||||
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
|
||||
/// Error code used when a requested logical Config file identifier is not registered.
|
||||
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
|
||||
/// Error code used when a Config filename mapping or descriptor relation is invalid.
|
||||
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");
|
||||
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
|
||||
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
|
||||
/// Error code used when a Config-managed file contains invalid JSON syntax.
|
||||
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
|
||||
/// Error code used when an explicit Config management operation is unsupported or targets the wrong managed resource kind.
|
||||
pub const ERROR_CODE_MANAGEMENT_OPERATION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "management_operation_invalid");
|
||||
|
||||
/// Error code used when an atomic managed Config or `.env` persistence operation fails before commit.
|
||||
pub const ERROR_CODE_PERSISTENCE_WRITE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "persistence_write_failed");
|
||||
/// Error code used when an explicitly requested Config profile does not exist in a validated document.
|
||||
pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found");
|
||||
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
|
||||
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
|
||||
/// Error code used when a Config document does not satisfy its registered JSON Schema.
|
||||
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// file: crates/ksp-config-lib/src/lib.rs
|
||||
// version: 10
|
||||
// version: 14
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -8,11 +9,12 @@
|
||||
//!
|
||||
//! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and
|
||||
//! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and
|
||||
//! provenance. The standard Logging document maps explicitly to `ksp_logging_lib::LoggingSettings`, while the management surface provides typed Logging
|
||||
//! mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||
//! provenance. Standard Logging and HTTP Transport documents map explicitly to their runtime settings contracts, while the management surface provides
|
||||
//! typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic JSON/`.env` persistence.
|
||||
|
||||
mod bootstrap;
|
||||
mod composite;
|
||||
mod constants;
|
||||
mod document;
|
||||
mod environment;
|
||||
mod error;
|
||||
@@ -22,6 +24,7 @@ mod persistence;
|
||||
mod profile;
|
||||
mod registry;
|
||||
mod sensitivity;
|
||||
mod transport;
|
||||
|
||||
/// Bootstrap argument used to replace the configuration document root.
|
||||
pub use self::bootstrap::ARG_CFG_PATH;
|
||||
@@ -141,12 +144,20 @@ pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME;
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_FILENAME;
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME;
|
||||
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME;
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
pub use self::registry::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME;
|
||||
/// Logical file identifier for the generic composite JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_COMPOSITE;
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||
pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT;
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub use self::registry::FILE_ID_STD_LOGGING;
|
||||
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||
pub use self::registry::FILE_ID_STD_TRANSPORT;
|
||||
/// Sensitivity assigned to one Config value after environment resolution.
|
||||
pub use self::sensitivity::ConfigSensitivity;
|
||||
/// Provenance segment participating in one resolved Config value.
|
||||
@@ -157,3 +168,24 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE;
|
||||
pub use self::sensitivity::ResolvedConfigJson;
|
||||
/// One resolved Config string preserving real/safe representations and provenance.
|
||||
pub use self::sensitivity::ResolvedConfigText;
|
||||
/// Effective standard HTTP Transport configuration mapped to `ksp_onchain_transport_lib::HttpTransportSettings`.
|
||||
pub use self::transport::ResolvedTransportConfig;
|
||||
|
||||
/// Validates composite document contract.
|
||||
pub(crate) use self::composite::validate_composite_document_contract;
|
||||
/// Owning tracing target for events emitted by the Config crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Executes the crate-internal parse dotenv content operation for the owning module.
|
||||
pub(crate) use self::environment::parse_dotenv_content;
|
||||
/// Validates supported variable name.
|
||||
pub(crate) use self::environment::validate_supported_variable_name;
|
||||
/// Executes the crate-internal atomic write operation for the owning module.
|
||||
pub(crate) use self::persistence::atomic_write;
|
||||
/// Executes the crate-internal atomic write private operation for the owning module.
|
||||
pub(crate) use self::persistence::atomic_write_private;
|
||||
/// Loads resolved profile with source.
|
||||
pub(crate) use self::profile::load_resolved_profile_with_source;
|
||||
/// Validates document profile contract.
|
||||
pub(crate) use self::profile::validate_document_profile_contract;
|
||||
/// Executes the crate-internal build registry operation for the owning module.
|
||||
pub(crate) use self::registry::build_registry;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/management.rs
|
||||
// version: 1
|
||||
// version: 6
|
||||
|
||||
/// Raw source of one registered Config document read for explicit management/correction.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
@@ -476,6 +476,17 @@ impl LoggingFileConfig {
|
||||
self.format = value.into();
|
||||
}
|
||||
|
||||
/// Returns whether ANSI output is requested.
|
||||
#[must_use]
|
||||
pub const fn ansi(&self) -> bool {
|
||||
return self.ansi;
|
||||
}
|
||||
|
||||
/// Sets whether ANSI output is requested. Persistence validation currently requires persistent file ANSI to remain disabled.
|
||||
pub fn set_ansi(&mut self, value: bool) {
|
||||
self.ansi = value;
|
||||
}
|
||||
|
||||
/// Returns the output filter.
|
||||
#[must_use]
|
||||
pub const fn filter(&self) -> &LoggingOutputFilterConfig {
|
||||
@@ -625,6 +636,29 @@ impl ConfigManagement {
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates and atomically persists a raw source candidate for one registered Config document.
|
||||
///
|
||||
/// The candidate is parsed, schema-validated and checked against KSP semantic invariants before any destination bytes are replaced. The raw source text is
|
||||
/// preserved exactly when persistence succeeds, allowing a management editor to repair an invalid document without bypassing Config ownership.
|
||||
pub fn save_source_candidate(&self, file_id: &crate::ConfigFileId, source: &str) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
|
||||
let descriptor = self.engine.registry().descriptor(file_id);
|
||||
let descriptor = match descriptor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if descriptor.kind() != crate::ConfigFileKind::Config {
|
||||
return std::result::Result::Err(
|
||||
management_error("management source persistence requires a Config document").with_context("file_id", file_id.as_str()),
|
||||
);
|
||||
}
|
||||
let validated = self.engine.validate_source_candidate(file_id, source);
|
||||
let validated = match validated {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return persist_document_source(file_id, validated.path(), source.as_bytes());
|
||||
}
|
||||
|
||||
/// Loads the validated `std.logging.json` source into its typed management contract.
|
||||
pub fn load_logging_document(&self) -> ksp_core_lib::Result<LoggingConfigDocument> {
|
||||
let file_id = logging_file_id();
|
||||
@@ -675,27 +709,7 @@ impl ConfigManagement {
|
||||
},
|
||||
};
|
||||
serialized.push('\n');
|
||||
let existing = std::fs::read(validated.path());
|
||||
let existing = match existing {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
|
||||
.with_context("file_id", file_id.as_str())
|
||||
.with_context("path", validated.path().to_string_lossy().into_owned())
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if existing.as_slice() == serialized.as_bytes() {
|
||||
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
|
||||
}
|
||||
let write = crate::persistence::atomic_write(validated.path(), serialized.as_bytes());
|
||||
if let std::result::Result::Err(error) = write {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
|
||||
return persist_document_source(&file_id, validated.path(), serialized.as_bytes());
|
||||
}
|
||||
|
||||
/// Returns safe desired/effective/shadow reports for every KSP/KSPB variable present in process or `.env` sources.
|
||||
@@ -713,7 +727,7 @@ impl ConfigManagement {
|
||||
/// Authentication/authorization of the human user belongs to the calling application. Calling this method is the explicit Config boundary that opts into
|
||||
/// real-value access; the returned value must never be logged.
|
||||
pub fn reveal_effective_environment_value(&self, variable_name: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
|
||||
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||
let validation = crate::validate_supported_variable_name(variable_name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -730,7 +744,7 @@ impl ConfigManagement {
|
||||
|
||||
/// Explicitly reveals the real persisted `.env` value for management display/editing.
|
||||
pub fn reveal_dotenv_value(&self, variable_name: &str) -> ksp_core_lib::Result<std::option::Option<String>> {
|
||||
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||
let validation = crate::validate_supported_variable_name(variable_name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -744,7 +758,7 @@ impl ConfigManagement {
|
||||
|
||||
/// Creates or updates one supported KSP/KSPB `.env` entry atomically without mutating the inherited process environment.
|
||||
pub fn set_dotenv_value(&self, variable_name: &str, value: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeReport> {
|
||||
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||
let validation = crate::validate_supported_variable_name(variable_name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -767,7 +781,7 @@ impl ConfigManagement {
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if candidate != content {
|
||||
let write = crate::persistence::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes());
|
||||
let write = crate::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes());
|
||||
match write {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -783,7 +797,7 @@ impl ConfigManagement {
|
||||
|
||||
/// Removes one supported KSP/KSPB `.env` entry atomically without mutating the inherited process environment.
|
||||
pub fn remove_dotenv_value(&self, variable_name: &str) -> ksp_core_lib::Result<ConfigEnvironmentChangeReport> {
|
||||
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||
let validation = crate::validate_supported_variable_name(variable_name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -806,7 +820,7 @@ impl ConfigManagement {
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if candidate != content {
|
||||
let write = crate::persistence::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes());
|
||||
let write = crate::atomic_write_private(self.dotenv_path.as_path(), candidate.as_bytes());
|
||||
match write {
|
||||
std::result::Result::Ok(()) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -824,6 +838,7 @@ impl ConfigManagement {
|
||||
return crate::ConfigEnvironment::load_from_dotenv_path(self.dotenv_path.as_path());
|
||||
}
|
||||
|
||||
/// Executes the crate-internal with dotenv path operation for `ConfigManagement`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_dotenv_path(engine: crate::ConfigDocumentEngine, dotenv_path: std::path::PathBuf) -> Self {
|
||||
return Self { engine, dotenv_path };
|
||||
@@ -907,7 +922,7 @@ fn read_dotenv_source(path: &std::path::Path) -> ksp_core_lib::Result<String> {
|
||||
let content = std::fs::read_to_string(path);
|
||||
return match content {
|
||||
std::result::Result::Ok(value) => {
|
||||
let validation = crate::environment::parse_dotenv_content(path, value.as_str());
|
||||
let validation = crate::parse_dotenv_content(path, value.as_str());
|
||||
match validation {
|
||||
std::result::Result::Ok(_) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
@@ -923,7 +938,7 @@ fn read_dotenv_source(path: &std::path::Path) -> ksp_core_lib::Result<String> {
|
||||
}
|
||||
|
||||
fn update_dotenv_source(path: &std::path::Path, source: &str, variable_name: &str, replacement: std::option::Option<&str>) -> ksp_core_lib::Result<String> {
|
||||
let validation = crate::environment::parse_dotenv_content(path, source);
|
||||
let validation = crate::parse_dotenv_content(path, source);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -953,7 +968,7 @@ fn update_dotenv_source(path: &std::path::Path, source: &str, variable_name: &st
|
||||
if !candidate.is_empty() {
|
||||
candidate.push('\n');
|
||||
}
|
||||
let candidate_validation = crate::environment::parse_dotenv_content(path, candidate.as_str());
|
||||
let candidate_validation = crate::parse_dotenv_content(path, candidate.as_str());
|
||||
return match candidate_validation {
|
||||
std::result::Result::Ok(_) => std::result::Result::Ok(candidate),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
@@ -999,6 +1014,30 @@ fn encode_dotenv_value(value: &str) -> String {
|
||||
return encoded;
|
||||
}
|
||||
|
||||
fn persist_document_source(file_id: &crate::ConfigFileId, path: &std::path::Path, source: &[u8]) -> ksp_core_lib::Result<ConfigDocumentChangeReport> {
|
||||
let existing = std::fs::read(path);
|
||||
let existing = match existing {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::vec::Vec::new(),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "existing Config document cannot be read before persistence")
|
||||
.with_context("file_id", file_id.as_str())
|
||||
.with_context("path", path.to_string_lossy().into_owned())
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if existing.as_slice() == source {
|
||||
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: false, reload_required: false });
|
||||
}
|
||||
let write = crate::atomic_write(path, source);
|
||||
if let std::result::Result::Err(error) = write {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(ConfigDocumentChangeReport { source_changed: true, reload_required: true });
|
||||
}
|
||||
|
||||
fn management_error(reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, "Config management operation is invalid").with_context("reason", reason);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// file: crates/ksp-config-lib/src/persistence.rs
|
||||
// version: 2
|
||||
// version: 4
|
||||
|
||||
static NEXT_TEMPORARY_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// Executes the crate-internal atomic write operation for the owning module.
|
||||
pub(crate) fn atomic_write(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return atomic_write_with_policy(path, content, false);
|
||||
}
|
||||
|
||||
/// Executes the crate-internal atomic write private operation for the owning module.
|
||||
pub(crate) fn atomic_write_private(path: &std::path::Path, content: &[u8]) -> ksp_core_lib::Result<()> {
|
||||
return atomic_write_with_policy(path, content, true);
|
||||
}
|
||||
@@ -99,7 +101,7 @@ fn cleanup_temporary_file(path: &std::path::Path) {
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
ksp_logging_lib::warn!(
|
||||
target: "ksp-config-lib",
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = "config.persistence",
|
||||
path = %path.to_string_lossy(),
|
||||
error = %error,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/profile.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
/// Origin of one top-level value in a resolved standard Config profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -135,6 +135,7 @@ impl crate::ConfigDocumentEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads resolved profile with source.
|
||||
pub(crate) fn load_resolved_profile_with_source(
|
||||
engine: &crate::ConfigDocumentEngine,
|
||||
file_id: &crate::ConfigFileId,
|
||||
@@ -153,6 +154,7 @@ pub(crate) fn load_resolved_profile_with_source(
|
||||
return resolve_document_profile(&document, requested_profile, source);
|
||||
}
|
||||
|
||||
/// Validates document profile contract.
|
||||
pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> {
|
||||
let root = match document.value().as_object() {
|
||||
std::option::Option::Some(value) => value,
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
// file: crates/ksp-config-lib/src/registry.rs
|
||||
// version: 3
|
||||
// version: 7
|
||||
|
||||
/// Bootstrap argument used to replace a known Config filename mapping.
|
||||
pub const ARG_FILE_MAP: &str = "--filemap";
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||
/// Logical file identifier for the generic composite JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||
/// Default physical filename for the generic composite JSON Schema document.
|
||||
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||
/// Default physical filename for the standard Logging configuration document.
|
||||
pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json";
|
||||
/// Default physical filename for the standard Logging JSON Schema document.
|
||||
pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json";
|
||||
/// Default physical filename for the generic composite JSON Schema document.
|
||||
pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json";
|
||||
/// Default physical filename for the standard HTTP Transport configuration document.
|
||||
pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json";
|
||||
/// Default physical filename for the standard HTTP Transport JSON Schema document.
|
||||
pub const DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME: &str = "std.transport.schema.json";
|
||||
/// Logical file identifier for the generic composite JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite";
|
||||
/// Logical file identifier for the standard Logging JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging";
|
||||
/// Logical file identifier for the standard HTTP Transport JSON Schema document.
|
||||
pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport";
|
||||
/// Logical file identifier for the standard Logging configuration document.
|
||||
pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging";
|
||||
/// Logical file identifier for the standard HTTP Transport configuration document.
|
||||
pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport";
|
||||
|
||||
/// Stable logical identifier for a Config-managed file.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -81,6 +89,7 @@ impl ConfigFileDescriptor {
|
||||
return self.schema_file_id.as_ref();
|
||||
}
|
||||
|
||||
/// Creates a new `ConfigFileDescriptor` value.
|
||||
pub(crate) fn new(
|
||||
file_id: &'static str,
|
||||
kind: ConfigFileKind,
|
||||
@@ -135,13 +144,29 @@ impl ConfigFileRegistry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transport = ConfigFileDescriptor::new(
|
||||
FILE_ID_STD_TRANSPORT,
|
||||
ConfigFileKind::Config,
|
||||
DEFAULT_STD_TRANSPORT_FILENAME,
|
||||
std::option::Option::Some(FILE_ID_SCHEMA_STD_TRANSPORT),
|
||||
);
|
||||
let transport = match transport {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transport_schema =
|
||||
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_TRANSPORT, ConfigFileKind::Schema, DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, std::option::Option::None);
|
||||
let transport_schema = match transport_schema {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let composite_schema =
|
||||
ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None);
|
||||
let composite_schema = match composite_schema {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return build_registry([logging, logging_schema, composite_schema]);
|
||||
return crate::build_registry([logging, logging_schema, transport, transport_schema, composite_schema]);
|
||||
}
|
||||
|
||||
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
|
||||
@@ -165,6 +190,13 @@ impl ConfigFileRegistry {
|
||||
return std::result::Result::Ok(registry);
|
||||
}
|
||||
|
||||
/// Iterates over all registered descriptors in deterministic logical `file_id` order.
|
||||
///
|
||||
/// The returned view is read-only and reflects filename overrides already applied to this registry.
|
||||
pub fn descriptors(&self) -> impl std::iter::Iterator<Item = &ConfigFileDescriptor> + '_ {
|
||||
return self.descriptors.values();
|
||||
}
|
||||
|
||||
/// Returns the descriptor associated with a known logical file identifier.
|
||||
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
|
||||
return match self.descriptors.get(file_id) {
|
||||
@@ -218,6 +250,7 @@ impl ConfigFileRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the crate-internal build registry operation for the owning module.
|
||||
pub(crate) fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
|
||||
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
|
||||
for descriptor in descriptors {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/src/sensitivity.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
/// Replacement used for secret environment fragments in safe diagnostic representations.
|
||||
pub const REDACTED_CONFIG_VALUE: &str = "********";
|
||||
@@ -18,7 +18,7 @@ pub enum ConfigSensitivity {
|
||||
impl ConfigSensitivity {
|
||||
/// Classifies one supported KSP/KSPB environment variable by its namespace.
|
||||
pub fn from_variable_name(variable_name: &str) -> ksp_core_lib::Result<Self> {
|
||||
let validation = crate::environment::validate_supported_variable_name(variable_name);
|
||||
let validation = crate::validate_supported_variable_name(variable_name);
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
@@ -103,6 +103,7 @@ pub struct ResolvedConfigText {
|
||||
}
|
||||
|
||||
impl ResolvedConfigText {
|
||||
/// Creates a new `ResolvedConfigText` value.
|
||||
pub(crate) fn new(value: String, safe_value: String, sensitivity: ConfigSensitivity, provenance: std::vec::Vec<ConfigValueProvenance>) -> Self {
|
||||
return Self { value, safe_value, sensitivity, provenance };
|
||||
}
|
||||
@@ -153,6 +154,7 @@ pub struct ResolvedConfigJson {
|
||||
}
|
||||
|
||||
impl ResolvedConfigJson {
|
||||
/// Creates a new `ResolvedConfigJson` value.
|
||||
pub(crate) fn new(
|
||||
value: serde_json::Value,
|
||||
safe_value: serde_json::Value,
|
||||
|
||||
316
crates/ksp-config-lib/src/transport.rs
Normal file
316
crates/ksp-config-lib/src/transport.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
// file: crates/ksp-config-lib/src/transport.rs
|
||||
// version: 1
|
||||
|
||||
/// Effective standard HTTP Transport configuration resolved from Config and mapped to the Transport runtime contract.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ResolvedTransportConfig {
|
||||
file_id: crate::ConfigFileId,
|
||||
source_path: std::path::PathBuf,
|
||||
profile_id: String,
|
||||
selection_source: crate::ConfigProfileSelectionSource,
|
||||
effective: crate::ResolvedConfigJson,
|
||||
settings: ksp_onchain_transport_lib::HttpTransportSettings,
|
||||
}
|
||||
|
||||
impl ResolvedTransportConfig {
|
||||
/// Returns the logical Config document identifier used by this runtime configuration.
|
||||
#[must_use]
|
||||
pub const fn file_id(&self) -> &crate::ConfigFileId {
|
||||
return &self.file_id;
|
||||
}
|
||||
|
||||
/// Returns the physical source Config document path.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &std::path::Path {
|
||||
return self.source_path.as_path();
|
||||
}
|
||||
|
||||
/// Returns the selected standard Transport profile identifier.
|
||||
#[must_use]
|
||||
pub fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Returns the source that selected the standard Transport profile.
|
||||
#[must_use]
|
||||
pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource {
|
||||
return self.selection_source;
|
||||
}
|
||||
|
||||
/// Returns the detailed environment-resolved effective Config view.
|
||||
///
|
||||
/// The real tree is available to legitimate runtime consumers. The safe tree redacts values originating from `KSP_SECRET_*` or `KSPB_SECRET_*`
|
||||
/// placeholders and is the only representation used by this type's [`std::fmt::Debug`] implementation.
|
||||
#[must_use]
|
||||
pub const fn effective(&self) -> &crate::ResolvedConfigJson {
|
||||
return &self.effective;
|
||||
}
|
||||
|
||||
/// Returns the validated runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub const fn settings(&self) -> &ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return &self.settings;
|
||||
}
|
||||
|
||||
/// Consumes this resolved Config and returns the mapped runtime HTTP Transport settings.
|
||||
#[must_use]
|
||||
pub fn into_settings(self) -> ksp_onchain_transport_lib::HttpTransportSettings {
|
||||
return self.settings;
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedTransportConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("ResolvedTransportConfig")
|
||||
.field("file_id", &self.file_id)
|
||||
.field("source_path", &self.source_path)
|
||||
.field("profile_id", &self.profile_id)
|
||||
.field("selection_source", &self.selection_source)
|
||||
.field("effective", &self.effective)
|
||||
.finish_non_exhaustive();
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ConfigDocumentEngine {
|
||||
/// Loads the standard HTTP Transport document, selects a profile, resolves environment placeholders and maps it to Transport runtime settings.
|
||||
///
|
||||
/// `requested_profile = None` uses the document `default_profile`; `Some(profile_id)` requests an explicit profile. Secret endpoint URLs are allowed
|
||||
/// because the Transport URL wrapper owns runtime redaction. Invalid environment-resolved values are reported as
|
||||
/// [`crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID`] without copying endpoint URL values into ordinary error context.
|
||||
pub fn load_resolved_transport_config(
|
||||
&self,
|
||||
requested_profile: std::option::Option<&str>,
|
||||
environment: &crate::ConfigEnvironment,
|
||||
) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_TRANSPORT);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = self.load_resolved_profile(&file_id, requested_profile);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return resolve_transport_profile(&profile, environment);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveTransportSource {
|
||||
format_version: u32,
|
||||
profile_id: String,
|
||||
retry: EffectiveRetrySource,
|
||||
endpoints: std::vec::Vec<EffectiveEndpointSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveRetrySource {
|
||||
max_retries: u32,
|
||||
initial_backoff_ms: u64,
|
||||
max_backoff_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveEndpointSource {
|
||||
name: String,
|
||||
enabled: bool,
|
||||
provider: String,
|
||||
cluster: String,
|
||||
url: String,
|
||||
connect_timeout_ms: u64,
|
||||
request_timeout_ms: u64,
|
||||
max_idle_connections_per_host: std::option::Option<usize>,
|
||||
roles: std::vec::Vec<EffectiveRoleSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveRoleSource {
|
||||
role: String,
|
||||
enabled: bool,
|
||||
request_kinds: std::vec::Vec<String>,
|
||||
priority: u32,
|
||||
limits: EffectiveLimitsSource,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EffectiveLimitsSource {
|
||||
requests_per_second: std::option::Option<u32>,
|
||||
burst_capacity: std::option::Option<u32>,
|
||||
max_concurrent_requests: std::option::Option<u32>,
|
||||
pause_after_rate_limit_ms: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
fn resolve_transport_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<ResolvedTransportConfig> {
|
||||
let effective = profile.resolve_effective_environment_detailed(environment);
|
||||
let effective = match effective {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let source = serde_json::from_value::<EffectiveTransportSource>(effective.value().clone());
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
effective_error(profile, "effective Transport Config cannot be decoded into the runtime adapter contract").with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
if source.format_version != 1 {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Transport format_version is unsupported"));
|
||||
}
|
||||
if source.profile_id != profile.profile_id() {
|
||||
return std::result::Result::Err(effective_error(profile, "effective Transport profile_id does not match the selected profile"));
|
||||
}
|
||||
let retry = ksp_onchain_transport_lib::HttpRetrySettings::new(
|
||||
source.retry.max_retries,
|
||||
std::time::Duration::from_millis(source.retry.initial_backoff_ms),
|
||||
std::time::Duration::from_millis(source.retry.max_backoff_ms),
|
||||
);
|
||||
let endpoints = map_endpoints(source.endpoints, profile);
|
||||
let endpoints = match endpoints {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(endpoints, retry);
|
||||
let validation = settings.validate();
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(transport_contract_error(profile, "effective Transport settings fail the Transport runtime contract", &error));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
profile_id = profile.profile_id(),
|
||||
endpoint_count = settings.endpoints().len(),
|
||||
"mapped standard Transport Config to runtime settings"
|
||||
);
|
||||
return std::result::Result::Ok(ResolvedTransportConfig {
|
||||
file_id: profile.file_id().clone(),
|
||||
source_path: profile.path().to_path_buf(),
|
||||
profile_id: profile.profile_id().to_owned(),
|
||||
selection_source: profile.selection_source(),
|
||||
effective,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
fn map_endpoints(
|
||||
sources: std::vec::Vec<EffectiveEndpointSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointSettings>> {
|
||||
let mut endpoints = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let endpoint_name = source.name.clone();
|
||||
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse(source.url);
|
||||
let url = match url {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
transport_contract_error(profile, "effective endpoint URL is invalid", &error).with_context("endpoint_name", endpoint_name),
|
||||
);
|
||||
},
|
||||
};
|
||||
let roles = map_roles(source.roles, profile, endpoint_name.as_str());
|
||||
let roles = match roles {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
endpoints.push(ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||
source.name,
|
||||
source.enabled,
|
||||
ksp_onchain_transport_lib::HttpProviderName::new(source.provider),
|
||||
ksp_onchain_transport_lib::HttpClusterName::new(source.cluster),
|
||||
url,
|
||||
std::time::Duration::from_millis(source.connect_timeout_ms),
|
||||
std::time::Duration::from_millis(source.request_timeout_ms),
|
||||
source.max_idle_connections_per_host,
|
||||
roles,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(endpoints);
|
||||
}
|
||||
|
||||
fn map_roles(
|
||||
sources: std::vec::Vec<EffectiveRoleSource>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<ksp_onchain_transport_lib::HttpEndpointRoleSettings>> {
|
||||
let mut roles = std::vec::Vec::<ksp_onchain_transport_lib::HttpEndpointRoleSettings>::with_capacity(sources.len());
|
||||
for source in sources {
|
||||
let requests_per_second = map_non_zero(source.limits.requests_per_second, profile, "limits.requests_per_second", endpoint_name);
|
||||
let requests_per_second = match requests_per_second {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let burst_capacity = map_non_zero(source.limits.burst_capacity, profile, "limits.burst_capacity", endpoint_name);
|
||||
let burst_capacity = match burst_capacity {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let max_concurrent_requests = map_non_zero(source.limits.max_concurrent_requests, profile, "limits.max_concurrent_requests", endpoint_name);
|
||||
let max_concurrent_requests = match max_concurrent_requests {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pause_after_rate_limit = match source.limits.pause_after_rate_limit_ms {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(std::time::Duration::from_millis(value)),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let limits = ksp_onchain_transport_lib::HttpRoleLimits::new(requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit);
|
||||
let mut request_kinds = std::vec::Vec::<ksp_onchain_transport_lib::HttpRequestKind>::with_capacity(source.request_kinds.len());
|
||||
for request_kind in source.request_kinds {
|
||||
request_kinds.push(ksp_onchain_transport_lib::HttpRequestKind::new(request_kind));
|
||||
}
|
||||
roles.push(ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||
ksp_onchain_transport_lib::HttpRoleName::new(source.role),
|
||||
source.enabled,
|
||||
request_kinds,
|
||||
source.priority,
|
||||
limits,
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(roles);
|
||||
}
|
||||
|
||||
fn map_non_zero(
|
||||
value: std::option::Option<u32>,
|
||||
profile: &crate::ResolvedConfigProfile,
|
||||
field: &'static str,
|
||||
endpoint_name: &str,
|
||||
) -> ksp_core_lib::Result<std::option::Option<std::num::NonZeroU32>> {
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let non_zero = std::num::NonZeroU32::new(value);
|
||||
return match non_zero {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
effective_error(profile, "effective Transport role limit must be greater than zero")
|
||||
.with_context("field", field)
|
||||
.with_context("endpoint_name", endpoint_name),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn transport_contract_error(profile: &crate::ResolvedConfigProfile, reason: &'static str, transport_error: &ksp_core_lib::Error) -> ksp_core_lib::Error {
|
||||
return effective_error(profile, reason)
|
||||
.with_context("transport_error_domain", transport_error.code().domain())
|
||||
.with_context("transport_error_code", transport_error.code().code());
|
||||
}
|
||||
|
||||
fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract")
|
||||
.with_context("file_id", profile.file_id().as_str())
|
||||
.with_context("profile_id", profile.profile_id())
|
||||
.with_context("reason", reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/transport.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-config-lib/tests/ownership.rs
|
||||
// version: 1
|
||||
// version: 3
|
||||
|
||||
//! Workspace ownership audits for KSP application configuration boundaries.
|
||||
|
||||
@@ -94,6 +94,7 @@ fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSe
|
||||
if let std::result::Result::Ok(candidate) = candidate
|
||||
&& candidate.len() > prefix_length
|
||||
&& !candidate.ends_with('_')
|
||||
&& !is_environment_namespace_label(candidate)
|
||||
{
|
||||
names.insert(candidate.to_owned());
|
||||
}
|
||||
@@ -101,6 +102,10 @@ fn collect_environment_names(source: &str, names: &mut std::collections::BTreeSe
|
||||
}
|
||||
}
|
||||
|
||||
fn is_environment_namespace_label(candidate: &str) -> bool {
|
||||
return matches!(candidate, "KSP_PUBLIC" | "KSP_SECRET" | "KSPB_PUBLIC" | "KSPB_SECRET");
|
||||
}
|
||||
|
||||
fn dotenv_example_assignments(source: &str) -> std::collections::BTreeMap<String, usize> {
|
||||
let mut assignments = std::collections::BTreeMap::<String, usize>::new();
|
||||
for (line_index, line) in source.lines().enumerate() {
|
||||
@@ -234,13 +239,44 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() {
|
||||
std::result::Result::Ok(value) => non_comment_source(value.as_str()),
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
for token in ["\".env\"", "\"std.logging.json\"", "\"std.logging.schema.json\"", "\"composite.schema.json\""] {
|
||||
for token in [
|
||||
"\".env\"",
|
||||
"\"std.logging.json\"",
|
||||
"\"std.logging.schema.json\"",
|
||||
"\"std.transport.json\"",
|
||||
"\"std.transport.schema.json\"",
|
||||
"\"composite.schema.json\"",
|
||||
] {
|
||||
assert!(!source.contains(token), "{} hardcodes Config-managed physical resource {token}; use ksp-config-lib contracts", rust_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() {
|
||||
let source = r#"
|
||||
const KSP_PUBLIC_LABEL: &str = "KSP_PUBLIC";
|
||||
const KSP_SECRET_LABEL: &str = "KSP_SECRET";
|
||||
const KSPB_PUBLIC_LABEL: &str = "KSPB_PUBLIC";
|
||||
const KSPB_SECRET_LABEL: &str = "KSPB_SECRET";
|
||||
const KSP_PUBLIC_RPC_URL: &str = "KSP_PUBLIC_RPC_URL";
|
||||
const KSP_SECRET_API_KEY: &str = "KSP_SECRET_API_KEY";
|
||||
const KSPB_PUBLIC_ENDPOINT: &str = "KSPB_PUBLIC_ENDPOINT";
|
||||
const KSPB_SECRET_TOKEN: &str = "KSPB_SECRET_TOKEN";
|
||||
"#;
|
||||
let mut names = std::collections::BTreeSet::<String>::new();
|
||||
collect_environment_names(source, &mut names);
|
||||
assert!(!names.contains("KSP_PUBLIC"));
|
||||
assert!(!names.contains("KSP_SECRET"));
|
||||
assert!(!names.contains("KSPB_PUBLIC"));
|
||||
assert!(!names.contains("KSPB_SECRET"));
|
||||
assert!(names.contains("KSP_PUBLIC_RPC_URL"));
|
||||
assert!(names.contains("KSP_SECRET_API_KEY"));
|
||||
assert!(names.contains("KSPB_PUBLIC_ENDPOINT"));
|
||||
assert!(names.contains("KSPB_SECRET_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotenv_example_covers_runtime_environment_names_with_comments() {
|
||||
let root = workspace_root();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// file: crates/ksp-config-lib/tests/public_api.rs
|
||||
// version: 10
|
||||
// version: 17
|
||||
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, Logging-adapter and
|
||||
//! management contracts.
|
||||
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity,
|
||||
//! Logging/Transport adapters and management contracts.
|
||||
|
||||
#[test]
|
||||
fn bootstrap_contract_is_available_from_crate_root() {
|
||||
@@ -74,6 +74,28 @@ fn logical_file_registry_is_available_from_crate_root() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_descriptor_inventory_is_available_from_crate_root() {
|
||||
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
|
||||
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
|
||||
if let std::result::Result::Ok(registry) = registry {
|
||||
let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect();
|
||||
assert_eq!(descriptors.len(), 5);
|
||||
assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
assert_eq!(descriptors[0].kind(), ksp_config_lib::ConfigFileKind::Config);
|
||||
assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||
assert_eq!(descriptors[1].kind(), ksp_config_lib::ConfigFileKind::Config);
|
||||
assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE);
|
||||
assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
|
||||
assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
let schema_file_id = descriptors[1].schema_file_id();
|
||||
assert!(schema_file_id.is_some(), "public Transport descriptor should preserve schema association");
|
||||
if let std::option::Option::Some(schema_file_id) = schema_file_id {
|
||||
assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_json_document_engine_is_available_from_crate_root() {
|
||||
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
@@ -105,10 +127,13 @@ fn resolved_profile_contract_is_available_from_crate_root() {
|
||||
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}");
|
||||
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
|
||||
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
|
||||
let document = engine.load_validated_document(&file_id);
|
||||
let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None);
|
||||
assert!(document.is_ok(), "public profile resolver should read committed source: {document:?}");
|
||||
assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}");
|
||||
if let std::result::Result::Ok(resolved) = resolved {
|
||||
assert_eq!(resolved.profile_id(), "local_dev");
|
||||
if let (std::result::Result::Ok(document), std::result::Result::Ok(resolved)) = (document, resolved) {
|
||||
let default_profile = document.value().get("default_profile").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(default_profile, std::option::Option::Some(resolved.profile_id()));
|
||||
assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile);
|
||||
assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global));
|
||||
assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile));
|
||||
@@ -193,17 +218,39 @@ fn management_contracts_are_available_from_crate_root() {
|
||||
assert!(logging.is_ok(), "public typed Logging management contract should load committed source: {logging:?}");
|
||||
if let std::result::Result::Ok(mut logging) = logging {
|
||||
assert_eq!(logging.format_version(), 1);
|
||||
assert_eq!(logging.default_profile(), "local_dev");
|
||||
let default_profile = logging.default_profile().to_owned();
|
||||
assert!(!default_profile.is_empty());
|
||||
assert!(!logging.profiles().is_empty());
|
||||
assert!(logging.profiles().iter().any(|profile| return profile.profile_id() == default_profile.as_str()));
|
||||
logging.set_logs_directory("public-api-management-test");
|
||||
assert_eq!(logging.logs_directory(), "public-api-management-test");
|
||||
assert_eq!(logging.profiles().len(), 1);
|
||||
assert_eq!(logging.profiles()[0].profile_id(), "local_dev");
|
||||
if let std::option::Option::Some(profile) = logging.profiles_mut().first_mut()
|
||||
&& let std::option::Option::Some(file) = profile.files_mut().first_mut()
|
||||
{
|
||||
file.set_ansi(false);
|
||||
assert!(!file.ansi());
|
||||
}
|
||||
}
|
||||
}
|
||||
let save_source_candidate: fn(
|
||||
&ksp_config_lib::ConfigManagement,
|
||||
&ksp_config_lib::ConfigFileId,
|
||||
&str,
|
||||
) -> ksp_core_lib::Result<ksp_config_lib::ConfigDocumentChangeReport> = ksp_config_lib::ConfigManagement::save_source_candidate;
|
||||
let reveal_effective: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||
ksp_config_lib::ConfigManagement::reveal_effective_environment_value;
|
||||
let reveal_dotenv: fn(&ksp_config_lib::ConfigManagement, &str) -> ksp_core_lib::Result<std::option::Option<String>> =
|
||||
ksp_config_lib::ConfigManagement::reveal_dotenv_value;
|
||||
let _ = (reveal_effective, reveal_dotenv);
|
||||
let _ = (save_source_candidate, reveal_effective, reveal_dotenv);
|
||||
assert_ne!(ksp_config_lib::ERROR_CODE_MANAGEMENT_OPERATION_INVALID, ksp_config_lib::ERROR_CODE_PERSISTENCE_WRITE_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_adapter_contract_is_available_from_crate_root() {
|
||||
let _loader = ksp_config_lib::ConfigDocumentEngine::load_resolved_transport_config;
|
||||
assert!(std::mem::size_of::<ksp_config_lib::ResolvedTransportConfig>() > 0);
|
||||
assert_eq!(ksp_config_lib::FILE_ID_STD_TRANSPORT, "cfg.std.transport");
|
||||
assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT, "schema.std.transport");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_FILENAME, "std.transport.json");
|
||||
assert_eq!(ksp_config_lib::DEFAULT_STD_TRANSPORT_SCHEMA_FILENAME, "std.transport.schema.json");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user