Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09aacc36ab | |||
| 15b129ad04 | |||
| 6ddf2b4995 | |||
| dcabceb52f | |||
| 51bcf870d3 | |||
| c6c5793c61 | |||
| d98ee736be | |||
| 39dbd86f1e | |||
| c938b2409e | |||
| 5b30bb9948 | |||
| 813a45385a | |||
| 21ed5f88da | |||
| 3198cc5666 | |||
| 4568d94a62 | |||
| d32d8de3a5 | |||
| a9fb6a7ac8 | |||
| 433e69272a | |||
| affa18a6e9 | |||
| 05aec88178 | |||
| b32de48c0b | |||
| 15e3402e1c | |||
| dca0a4b809 | |||
| 20320dba7b | |||
| e4bbc78a41 | |||
| feb9befb35 | |||
| a038194679 | |||
| 3172cda241 | |||
| 61fba107ef | |||
| 835de48cb7 | |||
| 21de590aea | |||
| 3081bd1085 | |||
| bdcab1e1a7 | |||
| d70c3a1672 | |||
| 7cdf5e80c9 | |||
| d8bfd7cd2e | |||
| 9c0d4fc197 | |||
| 7c12ec886b | |||
| 7eb6dec809 | |||
| 68f4384c5b | |||
| 9cc140fb84 | |||
| 3b64d1e0ec | |||
| 56b9ce6abc | |||
| 1c8d69778b | |||
| 8e739b9e55 | |||
| 53dbb5bccd | |||
| 92224e5ac6 | |||
| cbb4e7b0de | |||
| 38fd62c256 | |||
| f2a3ec62aa | |||
| b3363073c4 | |||
| c94a54f3e3 | |||
| 0871b85df9 | |||
| 315e7e67e5 | |||
| df95f2f558 | |||
| f0865d5137 | |||
| 4d77b607ea | |||
| 307711f873 | |||
| 5aa7b45840 | |||
| 3c5786f273 | |||
| 3b4d355537 | |||
| 628b4f12f2 | |||
| 66deaf8245 | |||
| 0e256a8ecf | |||
| 9eb0e19d81 | |||
| 74686892e9 | |||
| 1391858972 | |||
| 98bf88e431 | |||
| f0f444bc86 | |||
| d17161234a | |||
| 93199d1856 | |||
| 6fefc64e75 | |||
| 4c540d67a7 | |||
| b67fa89f44 | |||
| 435126f67a | |||
| 8721e54b18 | |||
| 6e3a0fa034 | |||
| 34637848eb | |||
| 778ea58ee1 | |||
| b0461f15ec | |||
| d5df0fe9af | |||
| ab29dc51bb | |||
| b6908cb573 | |||
| b64a799c85 | |||
| cf4b28df2b | |||
| 9e8fd53291 | |||
| 3c9c1d1349 | |||
| 79fee574d9 | |||
| fca9abb11a | |||
| ba8f42f9b1 | |||
| 362123f357 | |||
| 5c0be421da | |||
| 738b340cc7 | |||
| 92a2c4fff3 | |||
| 946d88322b | |||
| d370027c02 | |||
| bd561cad47 | |||
| 6907e4bbb8 | |||
| c07db925b1 | |||
| 263464ddd7 | |||
| 4b350b4161 | |||
| ecbd6b746d | |||
| 8ba554306a | |||
| 6dcd278459 | |||
| 4dcdbfaa55 | |||
| 42326e7f02 | |||
| 2b7afab663 | |||
| 7d03825ec6 | |||
| 4db4087b8a | |||
| 1a9edd14e1 | |||
| 787df2861f | |||
| 60b346c552 | |||
| f11f0921b0 | |||
| 4c9086e2f0 | |||
| a207cf7161 | |||
| 63e711629f | |||
| 7aa38b0daf | |||
| 2132dfd884 | |||
| 76bfce17c1 | |||
| 6eb4d71043 | |||
| 7eb0381932 | |||
| 3fa4ab251a | |||
| 0e72914237 | |||
| f9f9ac79b6 | |||
| 1a57cdeef0 | |||
| 927b9e4709 | |||
| b7d9e7fd1f | |||
| 136ea6d76e | |||
| 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 |
52
.env.example
52
.env.example
@@ -1,6 +1,56 @@
|
||||
# file: .env.example
|
||||
# version: 1
|
||||
# version: 8
|
||||
|
||||
# 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
|
||||
|
||||
# KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended.
|
||||
# The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable.
|
||||
KSP_WALLETS_DIRECTORY=wallets
|
||||
|
||||
# Optional Wallet Desk password candidates are introduced by the later unlock tranche.
|
||||
# Additional KSP_SECRET_WALLET_PASS_<LABEL> variables may be defined locally; never commit real password values.
|
||||
# KSP_SECRET_WALLET_PASS_WALLET_01='test'
|
||||
# KSP_SECRET_WALLET_PASS_01='test'
|
||||
|
||||
# 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 public Solana Devnet WebSocket endpoint override used by config/std.transport.json.
|
||||
# The committed Transport document falls back to wss://api.devnet.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_DEVNET_WS_URL=wss://api.devnet.solana.com
|
||||
|
||||
# Optional public Solana Mainnet WebSocket endpoint override used by config/std.transport.json and its example.
|
||||
# The committed Transport document falls back to wss://api.mainnet-beta.solana.com when this variable is absent.
|
||||
KSP_PUBLIC_SOLANA_MAINNET_WS_URL=wss://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
|
||||
|
||||
# PublicNode Mainnet personal token used as secret x-token metadata by the committed Mainnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Testnet token is authorized on Mainnet.
|
||||
# KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# PublicNode Testnet personal token used as secret x-token metadata by the committed Testnet Yellowstone gRPC profile.
|
||||
# Keep the real value only in the process environment or local .env; do not assume that a Mainnet token is authorized on Testnet.
|
||||
# KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN=replace-me
|
||||
|
||||
# Helius API key used by the LaserStream WebSocket endpoint in config/examples/std.transport.example.json.
|
||||
# Keep the real credential only in the process environment or local .env; never commit it.
|
||||
# KSP_SECRET_HELIUS_API_KEY=replace-me
|
||||
|
||||
# Fade-in duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
|
||||
# Minimum fully-visible time in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_MINIMUM_MS=1200
|
||||
|
||||
# Fade-out duration in milliseconds used by the common KSP desk splash lifecycle.
|
||||
KSP_DESK_SPLASH_FADE_OUT_MS=300
|
||||
|
||||
5
.gitignore
vendored
5
.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
|
||||
@@ -34,6 +36,7 @@ yarn.lock
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.pydevproject
|
||||
|
||||
# Operating system files
|
||||
.DS_Store
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,10 +1,66 @@
|
||||
<!-- file: CHANGELOG.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 13 -->
|
||||
|
||||
# Changelog KSP
|
||||
|
||||
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.9 — Yellowstone gRPC standard/provider-neutral — 2026-08-24
|
||||
|
||||
`0.2.9` ajoute à `ksp-onchain-transport-lib` un moteur Yellowstone gRPC partagé construit directement sur Tonic/Protobuf, avec `yellowstone-grpc-proto` comme contrat wire upstream et sans `yellowstone-grpc-client` dans le runtime KSP. La surface Solana standard reste provider-neutral et couvre les sept unary retenues (`SubscribeReplayInfo`, `Ping`, `GetLatestBlockhash`, `GetBlockHeight`, `GetSlot`, `IsBlockhashValid`, `GetVersion`), `Subscribe` bidi, les familles accounts/slots/transactions/transaction status/blocks/block meta/entry, les neuf variantes d'update standard retenues, les filtres/common fields actuels, le backpressure, les limites de messages, le half-close/shutdown, ainsi qu'un reconnect/replay prudent depuis le dernier slot observé. Aucune garantie exactly-once, lossless ou d'ordre global gapless n'est introduite ; gaps et duplicates restent observables. `SubscribeDeshred` reste explicitement hors du standard KSP de cette release.
|
||||
|
||||
Config passe à `std.transport` V3, backward-readable avec V1 HTTP et V2 HTTP+WebSocket, et sépare les axes `provider` / `protocol` ainsi que `metadata` / `secret_metadata` sans créer de dépendance Transport -> Config/env. PublicNode constitue la première intégration provider : profils Yellowstone Mainnet et Testnet sur `https://solana-yellowstone-grpc.publicnode.com:443` et `https://solana-testnet-yellowstone-grpc.publicnode.com:443`, avec authentification `x-token` résolue par Config depuis des variables `KSP_SECRET_*`. Le smoke Transport pur reçoit les credentials uniquement par stdin opérateur, n'en journalise aucun et valide en live `Subscribe -> Slot` sur les deux réseaux ; le même personal token a été utilisé avec succès pour Mainnet et Testnet, tout en conservant deux entrées Config distinctes par flexibilité opérationnelle.
|
||||
|
||||
La clôture conserve les surfaces antérieures HTTP (**52 current typed + 14 historiques**), WebSocket Solana standard (**9 familles / 18 opérations**) et Helius LaserStream WebSocket. Le gate final comprend **383/383** tests unit Transport, **49/49** public API, **43/43** release completeness, **4/4** doctests, `cargo test --workspace`, les canaris de dépendances, le smoke PublicNode live **2/2 PASS** et l'inspection des graphes Cargo (`yellowstone-grpc-proto 12.6.0`, `tonic` / `tonic-prost 0.14.6`, `prost` / `prost-types 0.14.4`). `prompts/015-V0_2_10_START_PROMPT.md` ouvre ensuite `0.2.10 — OrbitFlare Yellowstone gRPC` uniquement depuis le tag stable `v0.2.9`, avec audit provider actuel, threat model et sizing en `pre.001` avant toute extension provider lourde.
|
||||
|
||||
## 0.2.8 — Helius LaserStream WebSocket — 2026-08-23
|
||||
|
||||
`0.2.8` étend `ksp-onchain-transport-lib` avec une façade `HeliusLaserStreamWsSession` dédiée qui réutilise le même `WsSession` physique/actor que le WebSocket Solana standard, sans second client, socket, registry ou scheduler. La surface stable Helius réutilise les sept familles standard actuellement retenues (`account`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`) et ajoute l’extension typée `transactionSubscribe` / `transactionUnsubscribe`; `block` et `vote` restent absents de la façade Helius et `slotsUpdates` conserve son statut unstable. Le heartbeat provider est possédé par l’actor partagé et émet un WebSocket Ping control frame toutes les 60 secondes uniquement pour `WsProtocolKind::HeliusLaserStream`.
|
||||
|
||||
La release ajoute le mapping Config V2 `helius_laserstream`, les profils Helius mainnet/devnet et le secret `KSP_SECRET_HELIUS_API_KEY` avec provenance/redaction segmentaire, sans dépendance inverse Transport -> Config ni lecture directe de l’environnement par Transport. Les canaris couvrent erreurs RPC provider, payload oversized, mismatch de notification, reconnect/remap/unsubscribe races, backpressure isolé et diagnostics sans payload brut. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, **9 familles / 18 opérations WebSocket Solana standard**, et la surface Helius `7 standard + transaction`. Le smoke Helius live cross-crates est volontairement reporté vers une future surface d’intégration/orchestration afin de préserver l’ownership Config du secret. Les graphes Cargo finaux n’introduisent aucun SDK Helius/gRPC ni nouvelle duplication bloquante. `prompts/014-V0_2_9_START_PROMPT.md` ouvre ensuite `0.2.9 — Yellowstone gRPC standard/provider-neutral` uniquement depuis le tag stable `v0.2.8`, avec audit service/proto/crates/licences/MSRV/features et sizing strict en `pre.001` avant toute implémentation lourde.
|
||||
|
||||
## 0.2.7 — WebSocket Solana standard — 2026-08-23
|
||||
|
||||
`0.2.7` stabilise dans `ksp-onchain-transport-lib` le moteur WebSocket Solana standard en complément de la surface HTTP déjà complète. La release couvre exactement les **9 familles subscribe + 9 unsubscribe** de l’inventaire officiel ciblé : `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates` et `vote`. Les wrappers sont typés, les IDs KSP de session/subscription restent locaux et stables, les IDs serveur restent internes/remappables, et plusieurs sessions physiques peuvent coexister explicitement sur la même URL sans introduire de pool/scheduler automatique. Les familles `block`, `slotsUpdates` et `vote` restent identifiées comme unstable selon l’audit normatif courant et utilisent le warning KSP centralisé.
|
||||
|
||||
Le lifecycle WebSocket est borné : actor unique propriétaire du socket, pending JSON-RPC et queues de notifications bornés, `WsSession::close().await`, control frames Ping/Pong/Close, reconnect fini avec backoff borné, resubscribe déterministe par ID local, `continuity_gap_count`, isolation des erreurs applicatives et du backpressure par subscription, nettoyage des late ACK/notifications et terminaison one-shot de `signatureSubscribe`. Config passe à `std.transport` V2 pour composer HTTP + WebSocket tout en gardant la lecture V1 HTTP-only ; la direction reste `Config -> Transport`. La compliance finale conserve simultanément **52 méthodes HTTP courantes + 14 historiques**, les canaries Transport (`309` unit, `36` public API, `24` release completeness), le smoke WebSocket Devnet opt-in `slotSubscribe -> notification -> unsubscribe -> close`, les frontières de dépendances et la redaction des URLs/credentials. `prompts/013-V0_2_8_START_PROMPT.md`, renforcé par `0.2.7-pre.014-fix.001`, ouvre ensuite `0.2.8 — Helius LaserStream WebSocket` uniquement depuis le tag stable `v0.2.7`.
|
||||
|
||||
## 0.2.6 — Wallet Desk + `.kspwallet` V2 — 2026-08-22
|
||||
|
||||
`0.2.6` stabilise `ksp-app-wallet-desk` comme seconde application Tauri KSP spécialisée et étend `ksp-wallet-lib` avec le wire binaire `.kspwallet` V2. Wallet Desk compose Config, Wallet, Transport HTTP et Logging sans déplacer leurs responsabilités : inventory root-scoped et symlink-safe, création/import V2 par défaut, ouverture VIEW/OWNER V1/V2, candidats secrets résolus exclusivement par Config, `getBalance` Devnet via Transport, administration alias/notes, rotations OWNER/VIEW, disable/recreate VIEW fort et export Solana CLI JSON/Base58. Les secrets, keypairs, handles, chemins complets import/export et credentials Config restent côté Rust ; le frontend ne reçoit que des projections sûres et utilise des modals Bootstrap pour les opérations privilégiées.
|
||||
|
||||
Le format V1 JSON reste stable et lisible ; V2 ajoute un framing binaire KSP canonique, des IDs numériques, longueurs big-endian et domains/transcripts distincts tout en conservant Argon2id, XChaCha20-Poly1305 et l'autorité Ed25519 OWNER. Les APIs génériques créent/importent explicitement en V2 et auto-détectent V1/V2 à la lecture, tandis que les variantes `_v1`/`_v2` imposent un format exact et que `DEFAULT_WALLET_FORMAT` reste indépendant de `LATEST_SUPPORTED_WALLET_FORMAT`. La migration V1 -> V2 est explicite, OWNER-authentifiée, no-clobber ou atomique in-place avec protection stale-state ; aucune ouverture ne migre silencieusement. La release ferme également le runtime Tauri distribué : les documents Config/schemas sont embarqués comme resources, `ksp-config-lib` prépare une racine KSP user-writable commune sans écraser les Config utilisateur ni embarquer `.env`, et les bundles Linux `.deb`, `.rpm` et `.AppImage` ont été produits avec succès après le gate final `pre.018-fix.001`. Le prompt `prompts/012-V0_2_7_START_PROMPT.md` ouvre `0.2.7 — WebSocket Solana standard` par lectures, audit officiel, threat-model et sizing avant implémentation lourde.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
39
Cargo.toml
39
Cargo.toml
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 61
|
||||
# version: 261
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-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.9"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
@@ -14,14 +14,37 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "^1.0", features = ["derive"] }
|
||||
argon2 = { version = "^0.5", default-features = false }
|
||||
base64 = { version = "^0.23" }
|
||||
chacha20poly1305 = { version = "^0.11", default-features = false }
|
||||
chrono = { version = "^0.4", default-features = false }
|
||||
directories = { version = "^6.0" }
|
||||
ed25519-dalek = { version = "^3.0", default-features = false }
|
||||
fs2 = { version = "^0.4" }
|
||||
futures-util = { version = "^0.3", default-features = false }
|
||||
getrandom = { version = "^0.4", default-features = false }
|
||||
http = { version = "^1.5", default-features = false }
|
||||
jsonschema = { version = "^0.51", default-features = false }
|
||||
reqwest = { version = "^0.13", default-features = false }
|
||||
serde = { version = "^1.0" }
|
||||
serde_json = { version = "^1.0" }
|
||||
jsonschema = { version = "^0.49", 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"] }
|
||||
tauri = { version = "^2.11" }
|
||||
tauri-build = { version = "^2.6" }
|
||||
tauri-plugin-dialog = { version = "^2.7" }
|
||||
tauri-plugin-tracing = { version = "^0.3" }
|
||||
tempfile = { version = "^3.27" }
|
||||
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 }
|
||||
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||
tonic = { version = "^0.14", default-features = false }
|
||||
tonic-prost = { version = "^0.14", default-features = false }
|
||||
ts-rs = { version = "^12.0" }
|
||||
yellowstone-grpc-proto = { version = "^12.6", default-features = false }
|
||||
zeroize = { version = "^1.9" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
199
ROADMAP.md
199
ROADMAP.md
@@ -1,11 +1,9 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 16 -->
|
||||
<!-- version: 86 -->
|
||||
|
||||
# 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,138 @@ 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.
|
||||
- [X] `0.2.6` — `ksp-app-wallet-desk` + `.kspwallet` V2 stables : composition Config/Wallet/HTTP/Logging, lifecycle VIEW/OWNER, balance, administration/import/export, wire binaire V2, APIs multi-version, migration V1 -> V2 explicite et runtime Tauri packagé user-writable validés ; bundles Linux `.deb`/`.rpm`/`.AppImage` produits avant publication. Plan clôturé : `docs/plans/013-V0_2_6_WALLET_DESK_PLAN.md`.
|
||||
- [X] `0.2.7` — WebSocket Solana standard stable : 9 familles subscribe/unsubscribe typées (18/18 opérations), sessions physiques multiples explicites, subscriptions logiques typées, lifecycle/reconnect/resubscribe/backpressure/shutdown bornés, Config V2, non-régression HTTP 52+14, compliance finale, smoke WebSocket Devnet et audit de dépendances validés ; publication `rel.001` et prompt `0.2.8` prêts.
|
||||
- [X] `0.2.8` — Helius LaserStream WebSocket stable : façade provider dédiée sur l’actor WebSocket partagé, sept familles standard réutilisées (`account/logs/program/root/signature/slot/slotsUpdates`) + `transactionSubscribe`/`transactionUnsubscribe`, `block/vote` absents, heartbeat Ping 60 s Helius-only, Config V2/secrets redacted, lifecycle adversarial, compliance HTTP 52+14 / Standard WS 18/18 et graphes Cargo finaux validés ; prompt `0.2.9` prêt.
|
||||
- [X] `0.2.9` — Yellowstone gRPC standard/provider-neutral stable : moteur Tonic/Protobuf KSP partagé, sept unary standard retenues, `Subscribe` bidi et neuf variantes d’update, lifecycle/backpressure/reconnect/replay bornés sans promesse lossless, Config Transport V3 backward V1/V2 avec provider/protocol séparés, profils PublicNode Mainnet/Testnet authentifiés par `x-token`, smoke live `Subscribe -> Slot` 2/2 PASS et graphes Cargo finaux inspectés ; `SubscribeDeshred` reste hors scope.
|
||||
- [ ] `0.2.10` — Ajouter l’intégration Yellowstone gRPC OrbitFlare sur le moteur/standard acquis, avec audit explicite des auth/capabilities/restrictions/extensions et Devnet comme cible gratuite prioritaire.
|
||||
- [ ] `0.2.11` — Ajouter l’intégration Helius LaserStream gRPC sur le moteur Yellowstone partagé, avec audit de compatibilité/replay/auth/extensions, sans confondre cette surface avec LaserStream WebSocket.
|
||||
- [ ] `0.2.12` — Introduire `ksp-offchain-transport-lib` avec un premier lecteur de prix, au minimum SOL/USD et SOL/EUR.
|
||||
- [ ] `0.2.13` — 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.14` — Introduire la première surface de `ksp-interface-lib`, comprenant une API wire publique utilisable par les implémentations officielles et externes.
|
||||
- [ ] `0.2.15` — 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
|
||||
### TODO/IDEAS — providers Yellowstone non planifiés
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] **TODO** — eRPC : réauditer accès, auth/IP policy, capabilities et produits complémentaires avant toute décision d’implémentation.
|
||||
- [ ] **TODO** — Triton : réauditer la frontière Yellowstone upstream / extensions Triton, notamment Deshred et futures extensions.
|
||||
- [ ] **TODO** — Alchemy : réauditer auth, replay, limites et capabilities Yellowstone avant toute intégration.
|
||||
- [ ] **TODO** — QuickNode : réauditer auth, compression, `from_slot`, filtres et limites de plan.
|
||||
- [ ] **TODO** — Chainstack : réauditer networks, auth, add-on et capabilities Yellowstone.
|
||||
- [ ] **IDEAS** — Tatum, Shyft, Solinfra, NodeFlare et autres providers : conserver comme candidats exploratoires sans numéro de release ni engagement d’implémentation.
|
||||
|
||||
## 0.3.x — Données, stockage et acquisition raw
|
||||
### Règles Transport pour toute la série
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.4.x — Baseline Solana, SPL et metadata
|
||||
## Architecture de données — progression canonique
|
||||
|
||||
- [ ] 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é.
|
||||
La chaîne durable cible est :
|
||||
|
||||
## 0.5.x — Anchor et protocoles trading
|
||||
```text
|
||||
RAW -> CORE -> DECODE -> SPECIALIZED
|
||||
```
|
||||
|
||||
- [ ] 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.
|
||||
- **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.6.x — Processing autonome et orchestration
|
||||
## 0.3.x — RAW / acquisition persistée
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] `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.7.x — Trading Intelligence
|
||||
## Série CORE suivante
|
||||
|
||||
- [ ] Statistiques et métriques.
|
||||
- [ ] Features et datasets historiques.
|
||||
- [ ] Signaux et risque.
|
||||
- [ ] 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.
|
||||
|
||||
## Séries DECODE/SPECIALIZED/EXECUTION — progression verticale
|
||||
|
||||
### 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.
|
||||
|
||||
66
config/composite.ksp-app-wallet-desk.json
Normal file
66
config/composite.ksp-app-wallet-desk.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "temporary",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "temporary"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
66
config/examples/composite.ksp-app-wallet-desk.example.json
Normal file
66
config/examples/composite.ksp-app-wallet-desk.example.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"default_profile": "devnet",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "devnet",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "temporary",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "temporary"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"documents": [
|
||||
{
|
||||
"component_id": "logging",
|
||||
"file_id": "cfg.std.logging",
|
||||
"profile_id": "supertrace"
|
||||
},
|
||||
{
|
||||
"component_id": "transport",
|
||||
"file_id": "cfg.std.transport",
|
||||
"profile_id": "devnet_public"
|
||||
},
|
||||
{
|
||||
"component_id": "wallet",
|
||||
"file_id": "cfg.std.wallet",
|
||||
"profile_id": "tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
148
config/examples/std.transport.example.json
Normal file
148
config/examples/std.transport.example.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"retry": {
|
||||
"max_retries": 3,
|
||||
"initial_backoff_ms": 150,
|
||||
"max_backoff_ms": 3000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
},
|
||||
{
|
||||
"name": "mainnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://mainnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}",
|
||||
"session": {
|
||||
"notification_queue_capacity": 512,
|
||||
"max_active_subscriptions": 2048
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "devnet_helius",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "devnet_helius_ws",
|
||||
"enabled": true,
|
||||
"provider": "helius",
|
||||
"cluster": "devnet",
|
||||
"kind": "helius_laserstream",
|
||||
"url": "wss://devnet.helius-rpc.com/?api-key=${KSP_SECRET_HELIUS_API_KEY:-replace-me}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
18
config/examples/std.wallet.example.json
Normal file
18
config/examples/std.wallet.example.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"wallets_directory": "${KSP_WALLETS_DIRECTORY:-wallets}",
|
||||
"default_profile": "tests",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "default"
|
||||
},
|
||||
{
|
||||
"profile_id": "scenarios",
|
||||
"wallets_subdirectory": "scenarios/ephemeral"
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"wallets_subdirectory": "tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
732
config/schemas/std.transport.schema.json
Normal file
732
config/schemas/std.transport.schema.json
Normal file
@@ -0,0 +1,732 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.transport:v3",
|
||||
"title": "KSP standard HTTP + WebSocket + Yellowstone gRPC Transport configuration",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/documentV1"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV2"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/documentV3"
|
||||
}
|
||||
],
|
||||
"$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
|
||||
},
|
||||
"positiveUsize": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpEndpoint": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsReconnect": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"command_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"resubscribe",
|
||||
"command_queue_capacity",
|
||||
"notification_queue_capacity",
|
||||
"max_active_subscriptions",
|
||||
"max_pending_requests",
|
||||
"max_message_size_bytes",
|
||||
"max_frame_size_bytes",
|
||||
"max_write_buffer_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnect"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"command_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/positiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/wsReconnectOverride"
|
||||
},
|
||||
"resubscribe": {
|
||||
"enum": [
|
||||
"never",
|
||||
"active_subscriptions"
|
||||
]
|
||||
},
|
||||
"command_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"notification_queue_capacity": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_active_subscriptions": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_pending_requests": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_message_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_frame_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
},
|
||||
"max_write_buffer_size_bytes": {
|
||||
"$ref": "#/$defs/positiveUsize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"kind",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"solana_standard",
|
||||
"helius_laserstream"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/wsSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV1": {
|
||||
"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/profileV1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV2": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 2
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcPositiveMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"grpcCapacity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65536
|
||||
},
|
||||
"grpcMessageSize": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 536870912
|
||||
},
|
||||
"grpcReconnect": {
|
||||
"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/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcReconnectOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"max_retries": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"initial_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"max_backoff_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connect_timeout_ms",
|
||||
"unary_timeout_ms",
|
||||
"close_timeout_ms",
|
||||
"reconnect",
|
||||
"request_channel_capacity",
|
||||
"update_channel_capacity",
|
||||
"max_inbound_message_size_bytes",
|
||||
"max_outbound_message_size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnect"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcSessionOverride": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"connect_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"unary_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"close_timeout_ms": {
|
||||
"$ref": "#/$defs/grpcPositiveMs"
|
||||
},
|
||||
"reconnect": {
|
||||
"$ref": "#/$defs/grpcReconnectOverride"
|
||||
},
|
||||
"request_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"update_channel_capacity": {
|
||||
"$ref": "#/$defs/grpcCapacity"
|
||||
},
|
||||
"max_inbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
},
|
||||
"max_outbound_message_size_bytes": {
|
||||
"$ref": "#/$defs/grpcMessageSize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcMetadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[0-9a-z_.-]+$"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
}
|
||||
},
|
||||
"grpcEndpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"enabled",
|
||||
"provider",
|
||||
"cluster",
|
||||
"protocol",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"cluster": {
|
||||
"$ref": "#/$defs/descriptor"
|
||||
},
|
||||
"protocol": {
|
||||
"const": "solana_yellowstone"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"secret_metadata": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcMetadata"
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/$defs/grpcSessionOverride"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id",
|
||||
"endpoints",
|
||||
"ws_endpoints"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/httpEndpoint"
|
||||
}
|
||||
},
|
||||
"ws_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/wsEndpoint"
|
||||
}
|
||||
},
|
||||
"grpc_endpoints": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/grpcEndpoint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentV3": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"retry",
|
||||
"ws_defaults",
|
||||
"grpc_defaults",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 3
|
||||
},
|
||||
"retry": {
|
||||
"$ref": "#/$defs/retry"
|
||||
},
|
||||
"ws_defaults": {
|
||||
"$ref": "#/$defs/wsSession"
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"$ref": "#/$defs/grpcSession"
|
||||
},
|
||||
"default_profile": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profileV3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
config/schemas/std.wallet.schema.json
Normal file
54
config/schemas/std.wallet.schema.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:ksp:schema:std.wallet:v1",
|
||||
"title": "KSP standard Wallet configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"format_version",
|
||||
"wallets_directory",
|
||||
"default_profile",
|
||||
"profiles"
|
||||
],
|
||||
"properties": {
|
||||
"format_version": {
|
||||
"const": 1
|
||||
},
|
||||
"wallets_directory": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"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._-]*$"
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile_id"
|
||||
],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/profileId"
|
||||
},
|
||||
"wallets_subdirectory": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,75 @@
|
||||
"default_profile": "local_dev",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "local_dev",
|
||||
"profile_id": "console_error",
|
||||
"default_filter": "error",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "error",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "console_warn",
|
||||
"default_filter": "warn",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "warn",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "console_info",
|
||||
"default_filter": "info",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "console_debug",
|
||||
"default_filter": "debug",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
@@ -22,16 +88,60 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "console_trace",
|
||||
"default_filter": "trace",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "file_info",
|
||||
"default_filter": "info",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": false,
|
||||
"output": "stderr",
|
||||
"ansi": false,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "off",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
"*"
|
||||
],
|
||||
@@ -39,6 +149,63 @@
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "local_dev",
|
||||
"default_filter": "warn",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"output_id": "file.all.info",
|
||||
"enabled": true,
|
||||
"path": "info/ksp-info.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "info",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -61,13 +228,305 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "superdev",
|
||||
"default_filter": "debug",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"output_id": "file.ksp_app_config_desk.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-app-config-desk.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-app-config-desk"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_app_wallet_desk.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-app-wallet-desk.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-app-wallet-desk"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_config_lib.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-config-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-config-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_core_lib.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-core-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-core-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_logging_lib.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-logging-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-logging-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_onchain_transport_lib.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-onchain-transport-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-onchain-transport-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_wallet_lib.debug",
|
||||
"enabled": true,
|
||||
"path": "targets/debug/ksp-wallet-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"ksp-wallet-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"target_filters": []
|
||||
},
|
||||
{
|
||||
"profile_id": "supertrace",
|
||||
"default_filter": "trace",
|
||||
"span_events": "new_and_close",
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"output": "stderr",
|
||||
"ansi": true,
|
||||
"format": "compact",
|
||||
"filter": {
|
||||
"level": "debug",
|
||||
"targets": [
|
||||
"*"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"output_id": "file.ksp_app_config_desk.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-app-config-desk.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-app-config-desk"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_app_wallet_desk.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-app-wallet-desk.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-app-wallet-desk"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_config_lib.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-config-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-config-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_core_lib.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-core-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-core-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_logging_lib.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-logging-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-logging-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_onchain_transport_lib.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-onchain-transport-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-onchain-transport-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_id": "file.ksp_wallet_lib.trace",
|
||||
"enabled": true,
|
||||
"path": "targets/trace/ksp-wallet-lib.log",
|
||||
"rotation": "daily",
|
||||
"format": "human",
|
||||
"ansi": false,
|
||||
"filter": {
|
||||
"level": "trace",
|
||||
"targets": [
|
||||
"ksp-wallet-lib"
|
||||
],
|
||||
"domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"target_filters": []
|
||||
}
|
||||
]
|
||||
}
|
||||
238
config/std.transport.json
Normal file
238
config/std.transport.json
Normal file
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"format_version": 3,
|
||||
"retry": {
|
||||
"max_retries": 2,
|
||||
"initial_backoff_ms": 100,
|
||||
"max_backoff_ms": 2000
|
||||
},
|
||||
"ws_defaults": {
|
||||
"command_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"resubscribe": "active_subscriptions",
|
||||
"command_queue_capacity": 128,
|
||||
"notification_queue_capacity": 256,
|
||||
"max_active_subscriptions": 1024,
|
||||
"max_pending_requests": 128,
|
||||
"max_message_size_bytes": 67108864,
|
||||
"max_frame_size_bytes": 16777216,
|
||||
"max_write_buffer_size_bytes": 1048576
|
||||
},
|
||||
"grpc_defaults": {
|
||||
"connect_timeout_ms": 10000,
|
||||
"unary_timeout_ms": 10000,
|
||||
"close_timeout_ms": 5000,
|
||||
"reconnect": {
|
||||
"max_retries": 5,
|
||||
"initial_backoff_ms": 250,
|
||||
"max_backoff_ms": 5000
|
||||
},
|
||||
"request_channel_capacity": 128,
|
||||
"update_channel_capacity": 256,
|
||||
"max_inbound_message_size_bytes": 67108864,
|
||||
"max_outbound_message_size_bytes": 67108864
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_devnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "devnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_DEVNET_WS_URL:-wss://api.devnet.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_mainnet",
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_mainnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "mainnet-beta",
|
||||
"kind": "solana_standard",
|
||||
"url": "${KSP_PUBLIC_SOLANA_MAINNET_WS_URL:-wss://api.mainnet-beta.solana.com}"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_mainnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "mainnet-beta",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_MAINNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"profile_id": "publicnode_testnet",
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"url": "https://api.testnet.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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ws_endpoints": [
|
||||
{
|
||||
"name": "solana_testnet_public_ws",
|
||||
"enabled": true,
|
||||
"provider": "solana-public",
|
||||
"cluster": "testnet",
|
||||
"kind": "solana_standard",
|
||||
"url": "wss://api.testnet.solana.com"
|
||||
}
|
||||
],
|
||||
"grpc_endpoints": [
|
||||
{
|
||||
"name": "publicnode_solana_testnet_yellowstone",
|
||||
"enabled": true,
|
||||
"provider": "publicnode",
|
||||
"cluster": "testnet",
|
||||
"protocol": "solana_yellowstone",
|
||||
"url": "https://solana-testnet-yellowstone-grpc.publicnode.com:443",
|
||||
"secret_metadata": [
|
||||
{
|
||||
"key": "x-token",
|
||||
"value": "${KSP_SECRET_PUBLICNODE_TESTNET_GRPC_X_TOKEN}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
18
config/std.wallet.json
Normal file
18
config/std.wallet.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"wallets_directory": "${KSP_WALLETS_DIRECTORY:-wallets}",
|
||||
"default_profile": "default",
|
||||
"profiles": [
|
||||
{
|
||||
"profile_id": "default"
|
||||
},
|
||||
{
|
||||
"profile_id": "temporary",
|
||||
"wallets_subdirectory": "temporary"
|
||||
},
|
||||
{
|
||||
"profile_id": "tests",
|
||||
"wallets_subdirectory": "tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
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: 9
|
||||
|
||||
[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]
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
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
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
ts-rs.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
200
crates/ksp-app-config-desk/README.md
Normal file
200
crates/ksp-app-config-desk/README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# `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é
|
||||
|
||||
Le fallback de bootstrap Logging est 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. Aucun script frontend standalone n’est lancé par l’opérateur : Tauri possède le cycle dev/build et déclenche les hooks npm de la crate. Le build Vite/TypeScript de production appartient au `beforeBuildCommand` du `cargo tauri build` crate-local, qui reste la dernière validation après tous les contrôles Rust et le parcours fonctionnel `cargo 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 **crate-local** : l'opérateur peut rester positionné à la racine du workspace, mais la commande exécute d'abord `cd crates/ksp-app-config-desk` puis `cargo tauri dev`/`build`. `-c/--config` n'est pas utilisé comme sélecteur de crate.
|
||||
|
||||
## 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 reste volontairement hors de la surface actuelle : 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
|
||||
|
||||
Le panneau fournit 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` est volontairement lancé 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.
|
||||
|
||||
## Runtime packagé
|
||||
|
||||
Le parcours release ne dépend pas du checkout source ni du répertoire depuis lequel le binaire est lancé. Les documents Config et schemas enregistrés sont embarqués comme resources Tauri. Avant `AppState::initialize`, `tauri.rs` résout le répertoire de resources puis délègue à `ksp-config-lib::prepare_packaged_runtime`. Config résout un répertoire de données utilisateur KSP writable via `ProjectDirs`, y seed les documents Config absents et y synchronise les schemas possédés par le package courant. Le process adopte ensuite ce répertoire writable comme current working directory.
|
||||
|
||||
Les documents Config déjà présents restent user-managed et ne sont jamais écrasés silencieusement lors d'une mise à jour. Les schemas sont resynchronisés à chaque lancement packagé. Le `.env` n'est jamais embarqué ni seedé ; les chemins relatifs `logs/` et `wallets/` restent ancrés dans ce runtime partagé sauf override Config. Config Desk et Wallet Desk utilisent le même identifiant de projet KSP afin de partager cette racine runtime sans dupliquer l'ownership des ressources.
|
||||
|
||||
## 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_IN_MS=300
|
||||
KSP_DESK_SPLASH_FADE_OUT_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.
|
||||
|
||||
Le panneau distingue 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 de release utilise 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`.
|
||||
332
crates/ksp-app-config-desk/USAGE.md
Normal file
332
crates/ksp-app-config-desk/USAGE.md
Normal file
@@ -0,0 +1,332 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 28 -->
|
||||
|
||||
# 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, l'opérateur peut revenir à la racine du workspace, mais chaque commande Tauri entre explicitement dans la crate ciblée avant d'exécuter le CLI.
|
||||
|
||||
## Développement normal
|
||||
|
||||
Le frontend n'est pas lancé directement avec npm. KSP est un workspace Rust multi-app : `-c/--config` ne sélectionne pas une crate Tauri. Depuis la racine du workspace, la crate applicative est donc sélectionnée en changeant explicitement de répertoire :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-config-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
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
|
||||
(cd crates/ksp-app-config-desk && cargo tauri build)
|
||||
```
|
||||
|
||||
`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 reste hors de la surface actuelle ; 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`.
|
||||
|
||||
### Runtime packagé
|
||||
|
||||
En release, le CWD de développement n'est pas réutilisé. Les resources Tauri embarquent les documents Config et schemas enregistrés ; `ksp-config-lib::prepare_packaged_runtime` prépare une racine de données utilisateur KSP writable résolue par `ProjectDirs`, seed les Config uniquement lorsqu'elles sont absentes et synchronise les schemas depuis le package courant. `tauri.rs` active ensuite cette racine comme current working directory avant le bootstrap Config.
|
||||
|
||||
Le `.env` n'est pas inclus dans le bundle. Les Config existantes restent intactes lors d'une mise à jour et les chemins relatifs de logs/wallets sont résolus depuis la racine runtime writable, sauf configuration explicite contraire. Le chemin physique dépend de la plateforme et ne doit pas être codé en dur par l'application ou l'utilisateur.
|
||||
|
||||
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_IN_MS=300
|
||||
KSP_DESK_SPLASH_FADE_OUT_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 `fade_in`, maintient ensuite le splash pleinement visible pendant `minimum`, émet le fade-out, attend `fade_out`, puis active `main`. Ainsi `KSP_DESK_SPLASH_MINIMUM_MS=12000` et `KSP_DESK_SPLASH_FADE_IN_MS=300
|
||||
KSP_DESK_SPLASH_FADE_OUT_MS=3000` donnent environ `15000 ms` de lifecycle backend. Des logs `debug` indiquent source des trois 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 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
|
||||
```
|
||||
|
||||
Aucun script npm de contrôle/dev/build n’est lancé directement par l’opérateur. Tauri possède le cycle frontend et déclenche les hooks npm de la crate.
|
||||
|
||||
Effectuer ensuite le contrôle fonctionnel depuis la racine en lançant Tauri dans la crate ciblée :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-config-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
**Seulement lorsque tous les contrôles précédents sont validés**, exécuter en toute dernière opération :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-config-desk && cargo tauri build)
|
||||
```
|
||||
|
||||
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 |
656
crates/ksp-app-config-desk/frontend/main.html
Normal file
656
crates/ksp-app-config-desk/frontend/main.html
Normal file
@@ -0,0 +1,656 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
|
||||
<!-- version: 18 -->
|
||||
<!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>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main container-fluid p-0">
|
||||
<div class="row g-0 h-100 flex-nowrap">
|
||||
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
|
||||
<div class="app-sidebar-scroll h-100" data-simplebar>
|
||||
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation Config Desk">
|
||||
<button class="nav-link text-start active" type="button" data-view="overview"><i class="fa-solid fa-gauge-high me-2" aria-hidden="true"></i>Vue d'ensemble</button>
|
||||
<button class="nav-link text-start" type="button" data-view="documents"><i class="fa-solid fa-file-code me-2" aria-hidden="true"></i>Documents</button>
|
||||
<button class="nav-link text-start" type="button" data-view="profiles"><i class="fa-solid fa-layer-group me-2" aria-hidden="true"></i>Profils</button>
|
||||
<button class="nav-link text-start" type="button" data-view="environment"><i class="fa-solid fa-terminal me-2" aria-hidden="true"></i>Environnement / .env</button>
|
||||
<button class="nav-link text-start" type="button" data-view="logging"><i class="fa-solid fa-list-ul me-2" aria-hidden="true"></i>Logging</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="col app-content app-scrollable h-100" 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>
|
||||
</section>
|
||||
</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>
|
||||
136
crates/ksp-app-config-desk/frontend/sass/_app.scss
Normal file
136
crates/ksp-app-config-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
|
||||
// version: 7
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
$app-sidebar-width: 250px;
|
||||
|
||||
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-main > .row {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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-sidebar {
|
||||
width: $app-sidebar-width;
|
||||
min-width: $app-sidebar-width;
|
||||
max-width: $app-sidebar-width;
|
||||
}
|
||||
|
||||
.app-sidebar-scroll,
|
||||
.app-content {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sidebar .nav-link {
|
||||
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";
|
||||
137
crates/ksp-app-config-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-config-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/splash.scss
|
||||
// version: 5
|
||||
|
||||
@font-face {
|
||||
font-family: "Dos Amazigh";
|
||||
src: url("../fonts/DOS_Amazigh.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#splash-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
max-width: 100%;
|
||||
height: 637px;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
#splash-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#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);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#debug-info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px 36px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#debug-info::-webkit-scrollbar,
|
||||
#messages-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.splash-message {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
animation: splashMessageFadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.splash-message.info { background: rgba(33, 150, 243, 0.22); }
|
||||
.splash-message.warning { background: rgba(255, 152, 0, 0.22); }
|
||||
.splash-message.error { background: rgba(244, 67, 54, 0.22); }
|
||||
.splash-message.success { background: rgba(76, 175, 80, 0.22); }
|
||||
|
||||
@keyframes splashMessageFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
23
crates/ksp-app-config-desk/frontend/splash.html
Normal file
23
crates/ksp-app-config-desk/frontend/splash.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->
|
||||
<!-- version: 2 -->
|
||||
<!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" style="opacity: 0;">
|
||||
<img id="splash-image" src="imgs/splash.png" alt="Chargement de KSP Config Desk">
|
||||
<div id="app-name">Config Desk</div>
|
||||
<div id="debug-info" aria-live="off" hidden></div>
|
||||
<div id="messages-container" aria-live="polite"></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 } }));
|
||||
}
|
||||
131
crates/ksp-app-config-desk/frontend/ts/splash.ts
Normal file
131
crates/ksp-app-config-desk/frontend/ts/splash.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/splash.ts
|
||||
// version: 4
|
||||
|
||||
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");
|
||||
|
||||
let activeOpacityFrame: number | null = null;
|
||||
|
||||
function easeInOut(value: number): number {
|
||||
return value < 0.5 ? 2 * value * value : 1 - Math.pow(-2 * value + 2, 2) / 2;
|
||||
}
|
||||
|
||||
function normalizeDurationMs(value: number | null): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
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 = durationMs === 0 ? 1 : 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 scrollToLatest(element: HTMLElement): void {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
function addMessage(message: string, status: string | null): void {
|
||||
const container = document.querySelector<HTMLElement>("#messages-container");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const line = document.createElement("div");
|
||||
const safeStatus = status === "warning" || status === "error" || status === "success" ? status : "info";
|
||||
line.className = `splash-message ${safeStatus}`;
|
||||
line.textContent = message;
|
||||
container.appendChild(line);
|
||||
scrollToLatest(container);
|
||||
}
|
||||
|
||||
function addDebugMessage(message: string): void {
|
||||
const container = document.querySelector<HTMLElement>("#debug-info");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
const line = document.createElement("div");
|
||||
line.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
|
||||
container.appendChild(line);
|
||||
scrollToLatest(container);
|
||||
}
|
||||
|
||||
async function handleSplashOrder(order: SplashOrderDto): Promise<void> {
|
||||
frontendTrace("splash", "Splash order received", { action: order.action });
|
||||
const container = document.querySelector<HTMLElement>("#splash-container");
|
||||
if (order.action === "add_message" && order.message) {
|
||||
addMessage(order.message, order.status);
|
||||
return;
|
||||
}
|
||||
if (order.action === "add_debug" && order.message) {
|
||||
addDebugMessage(order.message);
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
addMessage("Le lifecycle du splash n'a pas pu démarrer.", "error");
|
||||
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 |
28
crates/ksp-app-config-desk/package.json
Normal file
28
crates/ksp-app-config-desk/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ksp-app-config-desk",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
210
crates/ksp-app-config-desk/src/app_state.rs
Normal file
210
crates/ksp-app-config-desk/src/app_state.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
// file: crates/ksp-app-config-desk/src/app_state.rs
|
||||
// version: 9
|
||||
|
||||
//! 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_in_ms = splash_settings.fade_in_ms(),
|
||||
fade_in_source = splash_settings.fade_in_source(),
|
||||
fade_out_ms = splash_settings.fade_out_ms(),
|
||||
fade_out_source = splash_settings.fade_out_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;
|
||||
214
crates/ksp-app-config-desk/src/splash.rs
Normal file
214
crates/ksp-app-config-desk/src/splash.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
// file: crates/ksp-app-config-desk/src/splash.rs
|
||||
// version: 6
|
||||
|
||||
//! Common splash settings and frontend event contracts for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
const DEFAULT_SPLASH_FADE_IN_MS: u32 = 300;
|
||||
const DEFAULT_SPLASH_FADE_OUT_MS: u32 = 300;
|
||||
const DEFAULT_SPLASH_MINIMUM_MS: u64 = 1200;
|
||||
const ENV_SPLASH_FADE_IN_MS: &str = "KSP_DESK_SPLASH_FADE_IN_MS";
|
||||
const ENV_SPLASH_FADE_OUT_MS: &str = "KSP_DESK_SPLASH_FADE_OUT_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 {
|
||||
fade_in_ms: u32,
|
||||
minimum_ms: u64,
|
||||
fade_out_ms: u32,
|
||||
fade_in_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource,
|
||||
fade_out_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 fade_in = environment.resolve_variable(ENV_SPLASH_FADE_IN_MS, std::option::Option::Some("300"));
|
||||
let fade_in = match fade_in {
|
||||
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_out = environment.resolve_variable(ENV_SPLASH_FADE_OUT_MS, std::option::Option::Some("300"));
|
||||
let fade_out = match fade_out {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fade_in_ms = parse_u32_setting(ENV_SPLASH_FADE_IN_MS, fade_in.value(), MAX_SPLASH_FADE_MS);
|
||||
let fade_in_ms = match fade_in_ms {
|
||||
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_out_ms = parse_u32_setting(ENV_SPLASH_FADE_OUT_MS, fade_out.value(), MAX_SPLASH_FADE_MS);
|
||||
let fade_out_ms = match fade_out_ms {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(Self {
|
||||
fade_in_ms,
|
||||
minimum_ms,
|
||||
fade_out_ms,
|
||||
fade_in_source: fade_in.source(),
|
||||
minimum_source: minimum.source(),
|
||||
fade_out_source: fade_out.source(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns safe in-memory timings used when the managed environment cannot be resolved.
|
||||
#[must_use]
|
||||
pub(crate) const fn fallback() -> Self {
|
||||
return Self {
|
||||
fade_in_ms: DEFAULT_SPLASH_FADE_IN_MS,
|
||||
minimum_ms: DEFAULT_SPLASH_MINIMUM_MS,
|
||||
fade_out_ms: DEFAULT_SPLASH_FADE_OUT_MS,
|
||||
fade_in_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
minimum_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
fade_out_source: ksp_config_lib::ConfigEnvironmentSource::Fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the configured fade-in duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_in_ms(self) -> u32 {
|
||||
return self.fade_in_ms;
|
||||
}
|
||||
|
||||
/// Returns the minimum fully-visible splash duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn minimum_ms(self) -> u64 {
|
||||
return self.minimum_ms;
|
||||
}
|
||||
|
||||
/// Returns the configured fade-out duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_out_ms(self) -> u32 {
|
||||
return self.fade_out_ms;
|
||||
}
|
||||
|
||||
/// Returns the safe provenance code for the fade-in duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_in_source(self) -> &'static str {
|
||||
return environment_source_code(self.fade_in_source);
|
||||
}
|
||||
|
||||
/// 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-out duration.
|
||||
#[must_use]
|
||||
pub(crate) const fn fade_out_source(self) -> &'static str {
|
||||
return environment_source_code(self.fade_out_source);
|
||||
}
|
||||
|
||||
/// Returns the expected additive backend lifecycle from readiness through fade-out.
|
||||
#[must_use]
|
||||
pub(crate) fn expected_backend_lifecycle_ms(self) -> u64 {
|
||||
return u64::from(self.fade_in_ms) + self.minimum_ms + u64::from(self.fade_out_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 message severity used by the bottom message feed.
|
||||
pub(crate) status: 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>, status: 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),
|
||||
status: status.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;
|
||||
287
crates/ksp-app-config-desk/src/tauri.rs
Normal file
287
crates/ksp-app-config-desk/src/tauri.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
// file: crates/ksp-app-config-desk/src/tauri.rs
|
||||
// version: 17
|
||||
|
||||
//! 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 context = tauri::generate_context!();
|
||||
let runtime_layout = configure_packaged_runtime(&context);
|
||||
if let std::result::Result::Err(error) = runtime_layout {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
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(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_packaged_runtime(context: &tauri::Context<tauri::Wry>) -> ksp_core_lib::Result<()> {
|
||||
if cfg!(debug_assertions) {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let resource_root = tauri::utils::platform::resource_dir(context.package_info(), &tauri::Env::default());
|
||||
let resource_root = match resource_root {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TAURI_RUNTIME_FAILED, "Cannot resolve packaged Tauri resource directory")
|
||||
.with_context("tauri_error", error.to_string()),
|
||||
);
|
||||
},
|
||||
};
|
||||
let layout = ksp_config_lib::prepare_packaged_runtime(resource_root.as_path());
|
||||
let layout = match layout {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let working_directory = std::env::set_current_dir(layout.runtime_root());
|
||||
return match working_directory {
|
||||
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 activate packaged KSP runtime directory")
|
||||
.with_context("runtime_root", layout.runtime_root().to_string_lossy().into_owned())
|
||||
.with_source(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
173
crates/ksp-app-config-desk/src/tw_splash.rs
Normal file
173
crates/ksp-app-config-desk/src/tw_splash.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
// file: crates/ksp-app-config-desk/src/tw_splash.rs
|
||||
// version: 6
|
||||
|
||||
//! 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 splash window label.
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Starts the one-shot splash lifecycle after the splash frontend reports readiness.
|
||||
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,
|
||||
fade_in_ms = settings.fade_in_ms(),
|
||||
fade_in_source = settings.fade_in_source(),
|
||||
minimum_ms = settings.minimum_ms(),
|
||||
minimum_source = settings.minimum_source(),
|
||||
fade_out_ms = settings.fade_out_ms(),
|
||||
fade_out_source = settings.fade_out_source(),
|
||||
expected_backend_lifecycle_ms = settings.expected_backend_lifecycle_ms(),
|
||||
"splash frontend readiness accepted; starting splash to main lifecycle"
|
||||
);
|
||||
let emitted = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_in", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_in_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Initialisation de Config Desk...", "info");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_debug(&invoking_window, "Listener frontend prêt; fade-in piloté par Rust.");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "fade-in", u64::from(settings.fade_in_ms()), lifecycle_started).await;
|
||||
let emitted = emit_message(&invoking_window, "Configuration KSP chargée.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_debug(&invoking_window, "Configuration et provenance des timings résolues via ksp-config-lib.");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Runtime Logging initialisé.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_message(&invoking_window, "Gestion Config prête.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "minimum", settings.minimum_ms(), lifecycle_started).await;
|
||||
let emitted = emit_message(&invoking_window, "Initialisation terminée.", "success");
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let emitted = emit_order(
|
||||
&invoking_window,
|
||||
crate::SplashOrderDto::new("fade_out", std::option::Option::None, std::option::Option::None, std::option::Option::Some(settings.fade_out_ms())),
|
||||
);
|
||||
if let std::result::Result::Err(error) = emitted {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
sleep_and_log(&invoking_window, "fade-out", u64::from(settings.fade_out_ms()), lifecycle_started).await;
|
||||
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_message(window: &tauri::WebviewWindow, message: &str, status: &str) -> ksp_core_lib::Result<()> {
|
||||
return emit_order(
|
||||
window,
|
||||
crate::SplashOrderDto::new("add_message", std::option::Option::Some(message), std::option::Option::Some(status), std::option::Option::None),
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_debug(window: &tauri::WebviewWindow, message: &str) -> ksp_core_lib::Result<()> {
|
||||
if !cfg!(debug_assertions) {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
return emit_order(
|
||||
window,
|
||||
crate::SplashOrderDto::new("add_debug", std::option::Option::Some(message), std::option::Option::None, std::option::Option::None),
|
||||
);
|
||||
}
|
||||
|
||||
async fn sleep_and_log(window: &tauri::WebviewWindow, phase: &str, duration_ms: u64, lifecycle_started: std::time::Instant) {
|
||||
let wait_started = std::time::Instant::now();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_WINDOWS,
|
||||
window_label = window.label(),
|
||||
phase,
|
||||
configured_wait_ms = duration_ms,
|
||||
actual_wait_ms = wait_started.elapsed().as_secs_f64() * 1000.0,
|
||||
lifecycle_elapsed_ms = lifecycle_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"splash lifecycle wait completed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
66
crates/ksp-app-config-desk/tauri.conf.json
Normal file
66
crates/ksp-app-config-desk/tauri.conf.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP Config Desk",
|
||||
"version": "0.2.6",
|
||||
"identifier": "com.sasedev.ksp-app-config-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
"script": "npm run dev",
|
||||
"cwd": "."
|
||||
},
|
||||
"devUrl": "http://localhost:1430",
|
||||
"beforeBuildCommand": {
|
||||
"script": "npm run build",
|
||||
"cwd": "."
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"resources": {
|
||||
"../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json",
|
||||
"../../config/std.logging.json": "config/std.logging.json",
|
||||
"../../config/std.transport.json": "config/std.transport.json",
|
||||
"../../config/std.wallet.json": "config/std.wallet.json",
|
||||
"../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json",
|
||||
"../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json",
|
||||
"../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json",
|
||||
"../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
164
crates/ksp-app-config-desk/tests/desktop_contract.rs
Normal file
164
crates/ksp-app-config-desk/tests/desktop_contract.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
// file: crates/ksp-app-config-desk/tests/desktop_contract.rs
|
||||
// version: 7
|
||||
|
||||
//! 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,
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_semver_core(value: &str) -> std::option::Option<(u64, u64, u64)> {
|
||||
let core = match value.split_once('-') {
|
||||
std::option::Option::Some((core, _)) => core,
|
||||
std::option::Option::None => value,
|
||||
};
|
||||
let mut parts = core.split('.');
|
||||
let major = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let minor = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let patch = match parts.next().and_then(|part| return part.parse::<u64>().ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some((major, minor, patch));
|
||||
}
|
||||
|
||||
fn assert_packaged_version_floor(value: std::option::Option<&str>, field: &str) {
|
||||
assert!(value.is_some(), "{field} packaged version must exist");
|
||||
let value = match value {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let parsed = parse_semver_core(value);
|
||||
assert!(parsed.is_some(), "{field} packaged version must be SemVer-like");
|
||||
if let std::option::Option::Some(parsed) = parsed {
|
||||
assert!(parsed >= (0, 2, 6), "{field} packaged version must be >= 0.2.6");
|
||||
}
|
||||
}
|
||||
|
||||
#[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/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run dev"));
|
||||
assert_eq!(tauri.pointer("/build/beforeDevCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/script").and_then(serde_json::Value::as_str), std::option::Option::Some("npm run build"));
|
||||
assert_eq!(tauri.pointer("/build/beforeBuildCommand/cwd").and_then(serde_json::Value::as_str), std::option::Option::Some("."));
|
||||
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 manifest = read_text(root.join("Cargo.toml").as_path());
|
||||
assert!(manifest.contains("name = \"ksp-app-config-desk\""));
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
let build = package.pointer("/scripts/build").and_then(serde_json::Value::as_str);
|
||||
assert!(build.is_some_and(|value| return value.contains("tsc") && value.contains("vite build")));
|
||||
assert!(package.pointer("/scripts/check").is_none());
|
||||
assert!(package.pointer("/scripts/preview").is_none());
|
||||
}
|
||||
|
||||
#[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\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_014_template_uses_sidebar_navigation_and_kbot_style_splash_contract() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/main.html").as_path());
|
||||
let splash = read_text(root.join("frontend/splash.html").as_path());
|
||||
let app_style = read_text(root.join("frontend/sass/_app.scss").as_path());
|
||||
let splash_style = read_text(root.join("frontend/sass/splash.scss").as_path());
|
||||
let splash_ts = read_text(root.join("frontend/ts/splash.ts").as_path());
|
||||
assert!(main.starts_with("<!-- file: crates/ksp-app-config-desk/frontend/main.html -->"));
|
||||
assert!(main.contains("app-sidebar"));
|
||||
assert!(main.contains("row g-0 h-100 flex-nowrap"));
|
||||
assert!(app_style.contains("flex-wrap: nowrap"));
|
||||
assert!(app_style.contains("min-width: 0"));
|
||||
assert!(main.contains("nav nav-pills flex-column"));
|
||||
assert!(splash.starts_with("<!-- file: crates/ksp-app-config-desk/frontend/splash.html -->"));
|
||||
assert!(splash.contains("id=\"debug-info\""));
|
||||
assert!(splash.contains("id=\"messages-container\""));
|
||||
assert!(splash.contains("sass/splash.scss"));
|
||||
assert!(splash_style.contains("overflow: hidden"));
|
||||
assert!(splash_style.contains("width: 100%"));
|
||||
assert!(splash_style.contains("user-select: none"));
|
||||
assert!(splash_ts.contains("add_debug"));
|
||||
assert!(splash_ts.contains("add_message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writable_root() {
|
||||
let root = app_root();
|
||||
let tauri = read_json(root.join("tauri.conf.json").as_path());
|
||||
let tauri_version = tauri.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(tauri_version, "tauri.conf.json");
|
||||
let package = read_json(root.join("package.json").as_path());
|
||||
let package_version = package.pointer("/version").and_then(serde_json::Value::as_str);
|
||||
assert_packaged_version_floor(package_version, "package.json");
|
||||
assert_eq!(tauri_version, package_version, "desktop package metadata versions must remain synchronized");
|
||||
let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object);
|
||||
assert!(resources.is_some(), "packaged Config resources map must exist");
|
||||
if let std::option::Option::Some(resources) = resources {
|
||||
assert_eq!(resources.len(), 8);
|
||||
assert_eq!(resources.get("../../config/std.logging.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.logging.json"));
|
||||
assert_eq!(
|
||||
resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("config/schemas/std.wallet.schema.json"),
|
||||
);
|
||||
}
|
||||
let tauri_source = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime"));
|
||||
assert!(tauri_source.contains("tauri::utils::platform::resource_dir"));
|
||||
assert!(tauri_source.contains("std::env::set_current_dir(layout.runtime_root())"));
|
||||
}
|
||||
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());
|
||||
}
|
||||
93
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
93
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
// version: 6
|
||||
|
||||
#[test]
|
||||
fn profile_inventory_exposes_registered_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;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_STD_WALLET;
|
||||
}));
|
||||
assert!(inventory.iter().any(|document| -> bool {
|
||||
return document.file_id == ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK;
|
||||
}));
|
||||
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"));
|
||||
}
|
||||
36
crates/ksp-app-config-desk/unit_tests/splash.rs
Normal file
36
crates/ksp-app-config-desk/unit_tests/splash.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/splash.rs
|
||||
// version: 4
|
||||
|
||||
#[test]
|
||||
fn fallback_splash_settings_are_short_and_ordered() {
|
||||
let settings = crate::SplashSettings::fallback();
|
||||
assert_eq!(settings.fade_in_ms(), 300);
|
||||
assert_eq!(settings.minimum_ms(), 1200);
|
||||
assert_eq!(settings.fade_out_ms(), 300);
|
||||
assert_eq!(settings.expected_backend_lifecycle_ms(), 1800);
|
||||
assert_eq!(settings.fade_in_source(), "fallback");
|
||||
assert_eq!(settings.minimum_source(), "fallback");
|
||||
assert_eq!(settings.fade_out_source(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splash_order_contract_keeps_action_message_status_and_duration() {
|
||||
let order =
|
||||
crate::SplashOrderDto::new("add_message", std::option::Option::Some("ready"), std::option::Option::Some("success"), std::option::Option::Some(300));
|
||||
assert_eq!(order.action, "add_message");
|
||||
assert_eq!(order.message.as_deref(), std::option::Option::Some("ready"));
|
||||
assert_eq!(order.status.as_deref(), std::option::Option::Some("success"));
|
||||
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_in = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_IN_MS", "10001", 10_000);
|
||||
let oversized_fade_out = super::parse_u32_setting("KSP_DESK_SPLASH_FADE_OUT_MS", "10001", 10_000);
|
||||
assert!(malformed.is_err());
|
||||
assert!(oversized_minimum.is_err());
|
||||
assert!(oversized_fade_in.is_err());
|
||||
assert!(oversized_fade_out.is_err());
|
||||
}
|
||||
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/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
44
crates/ksp-app-wallet-desk/Cargo.toml
Normal file
44
crates/ksp-app-wallet-desk/Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
# file: crates/ksp-app-wallet-desk/Cargo.toml
|
||||
# version: 5
|
||||
|
||||
[package]
|
||||
name = "ksp-app-wallet-desk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "ksp_app_wallet_desk_lib"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "ksp-app-wallet-desk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true, features = ["std", "now"] }
|
||||
fs2.workspace = true
|
||||
ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-wallet-lib = { path = "../ksp-wallet-lib" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tauri.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt", "time"] }
|
||||
ts-rs.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
136
crates/ksp-app-wallet-desk/README.md
Normal file
136
crates/ksp-app-wallet-desk/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/README.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# `ksp-app-wallet-desk`
|
||||
|
||||
`ksp-app-wallet-desk` est l'application desktop spécialisée stable d'administration et de validation des wallets KSP.
|
||||
|
||||
La crate est un package Tauri mixte :
|
||||
|
||||
```text
|
||||
package : ksp-app-wallet-desk
|
||||
lib : ksp_app_wallet_desk_lib
|
||||
bin : ksp-app-wallet-desk
|
||||
```
|
||||
|
||||
## Responsabilités
|
||||
|
||||
Wallet Desk reste une couche d'interface et de composition. Il ne possède ni le wire `.kspwallet`, ni la cryptographie Wallet, ni la résolution Config, ni le transport Solana :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> configuration/composite/.env
|
||||
ksp-wallet-lib -> format, crypto, VIEW/OWNER, import/export, migration
|
||||
ksp-onchain-transport-lib -> RPC Solana HTTP
|
||||
ksp-logging-lib -> logging/tracing applicatif
|
||||
ksp-app-wallet-desk -> orchestration Tauri + projections sûres
|
||||
```
|
||||
|
||||
Le frontend ne reçoit jamais les keypairs, ciphertexts, passwords Config, paths complets import/export ou handles VIEW/OWNER. Les opérations privilégiées sont exécutées côté Rust.
|
||||
|
||||
## `.kspwallet` V1/V2
|
||||
|
||||
La surface courante conserve V1 et utilise V2 comme format natif par défaut :
|
||||
|
||||
```text
|
||||
V1 : JSON UTF-8 historique, lecture explicite toujours supportée
|
||||
V2 : wire binaire KSP, format de création/import par défaut
|
||||
```
|
||||
|
||||
Wallet Desk consomme uniquement les APIs non versionnées de `ksp-wallet-lib` :
|
||||
|
||||
- création/import -> format par défaut `V2` ;
|
||||
- inventory/inspection/open -> détection V1/V2 ;
|
||||
- mutations OWNER/VIEW -> restent dans le format natif ouvert ;
|
||||
- ouverture d'un V1 -> aucune migration implicite.
|
||||
|
||||
`ksp-wallet-lib` conserve parallèlement les APIs explicites `_v1` / `_v2` pour les consumers qui doivent imposer un format. `DEFAULT_WALLET_FORMAT` et `LATEST_SUPPORTED_WALLET_FORMAT` sont des politiques distinctes ; l'apparition future d'un V3 n'impose donc pas de modifier automatiquement le format créé par les APIs génériques.
|
||||
|
||||
La migration V1 -> V2 est une opération explicite OWNER-authentifiée possédée par `ksp-wallet-lib`. Wallet Desk ne migre jamais silencieusement un wallet lors de sa sélection, inspection ou ouverture.
|
||||
|
||||
## Capacités fonctionnelles
|
||||
|
||||
La surface validée comprend :
|
||||
|
||||
- inventory root-scoped des `.kspwallet` ;
|
||||
- create/import Solana CLI JSON et Base58 ;
|
||||
- sélection et inspection locked ;
|
||||
- ouverture VIEW et OWNER, manuelle ou via candidats secrets Config ;
|
||||
- affichage Pubkey/alias/notes uniquement après autorisation ;
|
||||
- `getBalance` via le Transport HTTP KSP ;
|
||||
- mutation alias/notes OWNER ;
|
||||
- rotations OWNER et VIEW ;
|
||||
- self-rotation VIEW ;
|
||||
- disable/recreate VIEW fort par OWNER ;
|
||||
- export OWNER Solana CLI JSON/Base58 no-clobber ;
|
||||
- wire V2 par défaut tout en conservant la compatibilité V1.
|
||||
|
||||
## Frontières desktop
|
||||
|
||||
Le frontend utilise Bootstrap, Font Awesome, DataTables/Select, SimpleBar et le bridge Logging KSP. Les capabilities Tauri du guest restent minimales :
|
||||
|
||||
```text
|
||||
core:default
|
||||
tracing:default
|
||||
```
|
||||
|
||||
Les pickers import/export sont invoqués côté Rust. Aucun accès direct filesystem ou réseau Solana n'est accordé au frontend.
|
||||
|
||||
## Développement
|
||||
|
||||
Le workspace contient plusieurs applications Tauri. `-c/--config` n'est pas un sélecteur de crate ; le cycle doit être lancé depuis le répertoire applicatif :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
En développement, le launcher Rust normalise ensuite son current working directory vers la racine du workspace avant le bootstrap Config. Les chemins relatifs `config/`, `config/schemas/`, `.env`, `logs/` et `wallets/` restent ainsi cohérents avec le checkout de développement sans déplacer leur ownership hors de `ksp-config-lib`.
|
||||
|
||||
Les ports réservés sont :
|
||||
|
||||
```text
|
||||
Vite HTTP : 1432
|
||||
Vite WS : 1433
|
||||
```
|
||||
|
||||
## Runtime packagé
|
||||
|
||||
Une application distribuée ne dépend pas du checkout source ni du répertoire depuis lequel l'utilisateur lance le binaire.
|
||||
|
||||
Les documents Config et schemas enregistrés sont embarqués comme resources Tauri. Au démarrage release :
|
||||
|
||||
1. Tauri résout son répertoire de resources ;
|
||||
2. `ksp-config-lib` résout un répertoire de données utilisateur KSP writable via `ProjectDirs` ;
|
||||
3. les documents Config packagés sont copiés uniquement lorsqu'ils sont absents ;
|
||||
4. les schemas, possédés par le package courant, sont resynchronisés à chaque lancement ;
|
||||
5. le process adopte ce répertoire KSP writable comme current working directory avant `AppState::initialize`.
|
||||
|
||||
Le `.env` n'est jamais embarqué ni prérempli. Les Config déjà modifiées par l'utilisateur ne sont jamais écrasées silencieusement. Les chemins relatifs de logs et wallets restent ancrés dans ce runtime writable, sauf override explicite par Config.
|
||||
|
||||
## Validation
|
||||
|
||||
Les validations Rust courantes sont :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-app-wallet-desk
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Le parcours fonctionnel utilise exclusivement Tauri :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
Le build production de la release est exécuté seulement après tous les autres gates :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri build)
|
||||
```
|
||||
|
||||
Cette commande reste l'absolue dernière opération de validation lorsqu'une publication Wallet Desk est candidate au tag stable.
|
||||
|
||||
Voir également [`USAGE.md`](USAGE.md), [`../../docs/formats/KSPWALLET_V1.md`](../../docs/formats/KSPWALLET_V1.md), [`../../docs/formats/KSPWALLET_V2.md`](../../docs/formats/KSPWALLET_V2.md) et [`../../docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md`](../../docs/validation/009-V0_2_6_WALLET_DESK_COMPLIANCE.md).
|
||||
140
crates/ksp-app-wallet-desk/USAGE.md
Normal file
140
crates/ksp-app-wallet-desk/USAGE.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Utilisation de `ksp-app-wallet-desk`
|
||||
|
||||
## 1. Lancement de développement
|
||||
|
||||
Depuis la racine du workspace :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
Pour isoler les wallets d'un parcours manuel :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && \
|
||||
KSP_WALLETS_DIRECTORY=var/wallet-desk-manual cargo tauri dev)
|
||||
```
|
||||
|
||||
Le processus Rust debug recale son current working directory sur la racine du workspace avant le bootstrap Config. `KSP_WALLETS_DIRECTORY`, les autres variables KSP et le `.env` restent résolus exclusivement par `ksp-config-lib`.
|
||||
|
||||
## 2. Format natif
|
||||
|
||||
Wallet Desk ne sélectionne pas directement une version de wire : il utilise la façade générique de `ksp-wallet-lib`.
|
||||
|
||||
```text
|
||||
nouveau wallet -> V2
|
||||
import Solana CLI/Base58 -> V2
|
||||
inspection/ouverture -> auto-détection V1/V2
|
||||
mutation d'un V1 ouvert -> V1
|
||||
mutation d'un V2 ouvert -> V2
|
||||
```
|
||||
|
||||
La présence de V2 ne réécrit pas automatiquement les fichiers V1 existants.
|
||||
|
||||
## 3. Création / import
|
||||
|
||||
Dans **Create / Import** :
|
||||
|
||||
- la création génère une nouvelle identité Solana et un `.kspwallet` V2 ;
|
||||
- l'import accepte les formats de transfert Solana retenus, puis crée un `.kspwallet` V2 ;
|
||||
- OWNER est toujours requis ; VIEW est optionnel ;
|
||||
- les passwords sont request-only et ne sont jamais renvoyés par IPC.
|
||||
|
||||
L'import natif utilise un picker Rust. Le path source complet et les bytes de keypair ne traversent pas le frontend.
|
||||
|
||||
## 4. Inventory et ouverture
|
||||
|
||||
L'inventory n'affiche que des projections locked sûres. La sélection ne déverrouille jamais automatiquement un secret Config.
|
||||
|
||||
L'ouverture peut ensuite utiliser :
|
||||
|
||||
- un password saisi manuellement ;
|
||||
- un candidat secret Config explicitement sélectionné côté backend.
|
||||
|
||||
VIEW et OWNER produisent des handles Rust-only. La Pubkey, l'alias et les notes ne sont projetés qu'après autorisation.
|
||||
|
||||
## 5. Administration
|
||||
|
||||
OWNER permet notamment :
|
||||
|
||||
- modifier l'alias ;
|
||||
- ajouter/modifier/supprimer des notes ;
|
||||
- changer le password OWNER ;
|
||||
- changer, désactiver ou recréer VIEW ;
|
||||
- exporter la keypair en Solana CLI JSON ou Base58.
|
||||
|
||||
VIEW peut effectuer sa self-rotation lorsqu'il est actif. Les opérations destructives/privilégiées utilisent les modals Bootstrap du shell et non `window.confirm`/`alert`.
|
||||
|
||||
## 6. Migration V1 -> V2
|
||||
|
||||
La migration existe dans `ksp-wallet-lib`, mais n'est jamais un effet secondaire d'une ouverture Wallet Desk.
|
||||
|
||||
Les APIs bibliothèque dédiées sont :
|
||||
|
||||
```text
|
||||
migrate_wallet_v1_to_v2
|
||||
migrate_wallet_file_v1_to_v2
|
||||
migrate_wallet_file_v1_to_v2_in_place
|
||||
```
|
||||
|
||||
Elles authentifient OWNER, reconstruisent un document cryptographique V2 neuf et conservent l'identité Solana ainsi que les metadata/note IDs. Lorsque VIEW est activé, un password VIEW cible doit être fourni. La copie est no-clobber ; le remplacement in-place est stale-protected.
|
||||
|
||||
## 7. Balance Solana
|
||||
|
||||
Une session VIEW ou OWNER autorisée peut demander `getBalance` via `ksp-onchain-transport-lib`. Le frontend ne choisit ni Pubkey arbitraire ni URL RPC : ces valeurs sont dérivées de l'état Rust autorisé et de Config.
|
||||
|
||||
## 8. Runtime packagé
|
||||
|
||||
En build release, les Config/schemas enregistrés sont des resources du bundle Tauri. Avant le bootstrap applicatif, `ksp-config-lib` prépare un répertoire de données utilisateur commun à KSP :
|
||||
|
||||
```text
|
||||
<runtime KSP writable>/
|
||||
├── config/
|
||||
│ ├── composite.ksp-app-wallet-desk.json
|
||||
│ ├── std.logging.json
|
||||
│ ├── std.transport.json
|
||||
│ ├── std.wallet.json
|
||||
│ └── schemas/
|
||||
├── .env # créé/modifié uniquement par Config si nécessaire
|
||||
├── logs/ # par défaut si path relatif
|
||||
└── wallets/ # par défaut si path relatif
|
||||
```
|
||||
|
||||
Le chemin physique exact dépend de la plateforme et est résolu par `ProjectDirs`; l'application ne suppose aucune home directory particulière.
|
||||
|
||||
Les Config existantes sont conservées lors d'une mise à jour. Les schemas sont synchronisés depuis le package courant. Le `.env` et les secrets ne sont jamais embarqués dans le bundle.
|
||||
|
||||
## 9. Validation de publication
|
||||
|
||||
Avant le build :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-config-lib
|
||||
cargo test -p ksp-wallet-lib
|
||||
cargo test -p ksp-app-config-desk
|
||||
cargo test -p ksp-app-wallet-desk
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Puis parcours fonctionnel :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri dev)
|
||||
```
|
||||
|
||||
Le smoke Devnet opt-in, s'il est rejoué, doit l'être avant le build final.
|
||||
|
||||
L'absolue dernière opération est :
|
||||
|
||||
```bash
|
||||
(cd crates/ksp-app-wallet-desk && cargo tauri build)
|
||||
```
|
||||
|
||||
Aucune commande de validation ne doit être exécutée après ce build avant la décision de publication/tag, afin de conserver la preuve d'ordre du gate.
|
||||
12
crates/ksp-app-wallet-desk/build.rs
Normal file
12
crates/ksp-app-wallet-desk/build.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// file: crates/ksp-app-wallet-desk/build.rs
|
||||
// version: 1
|
||||
|
||||
//! Build script for the KSP wallet desktop application.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
crates/ksp-app-wallet-desk/capabilities/default.json
Normal file
13
crates/ksp-app-wallet-desk/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for KSP Wallet Desk",
|
||||
"windows": [
|
||||
"splash",
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
BIN
crates/ksp-app-wallet-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
BIN
crates/ksp-app-wallet-desk/frontend/fonts/DOS_Amazigh.ttf
Normal file
Binary file not shown.
25
crates/ksp-app-wallet-desk/frontend/fonts/README.md
Normal file
25
crates/ksp-app-wallet-desk/frontend/fonts/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/frontend/fonts/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Fonts du splash Wallet Desk
|
||||
|
||||
Wallet Desk réutilise le gabarit de splash KSP déjà stabilisé dans Config Desk et conserve la police locale :
|
||||
|
||||
```text
|
||||
DOS_Amazigh.ttf
|
||||
font-family: Dos Amazigh
|
||||
```
|
||||
|
||||
Destination :
|
||||
|
||||
```text
|
||||
crates/ksp-app-wallet-desk/frontend/fonts/DOS_Amazigh.ttf
|
||||
```
|
||||
|
||||
Empreinte SHA-256 de l'asset repris du gabarit :
|
||||
|
||||
```text
|
||||
a01ff4b5a0d699db7cefcf5336bf0e379ff6fbf0d096087571a13c8782f9aa02
|
||||
```
|
||||
|
||||
`splash.scss` déclare `Dos Amazigh` avec `@font-face` et l'applique au titre `#app-name`.
|
||||
BIN
crates/ksp-app-wallet-desk/frontend/imgs/logo.png
Normal file
BIN
crates/ksp-app-wallet-desk/frontend/imgs/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/ksp-app-wallet-desk/frontend/imgs/splash.png
Normal file
BIN
crates/ksp-app-wallet-desk/frontend/imgs/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
502
crates/ksp-app-wallet-desk/frontend/main.html
Normal file
502
crates/ksp-app-wallet-desk/frontend/main.html
Normal file
@@ -0,0 +1,502 @@
|
||||
<!-- file: crates/ksp-app-wallet-desk/frontend/main.html -->
|
||||
<!-- version: 5 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="imgs/logo.png">
|
||||
<link rel="stylesheet" href="sass/main.scss">
|
||||
<title>Wallet Desk — Dashboard</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">Wallet 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">Dashboard</span>
|
||||
</div>
|
||||
<div class="ms-auto d-flex align-items-center gap-2 flex-nowrap">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-shell-action="deselect" disabled>
|
||||
<i class="fa-solid fa-xmark me-2" aria-hidden="true"></i>Deselect
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-shell-action="lock" disabled>
|
||||
<i class="fa-solid fa-lock me-2" aria-hidden="true"></i>Lock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main container-fluid p-0">
|
||||
<div class="row g-0 h-100 flex-nowrap">
|
||||
<aside class="col-auto app-sidebar border-end bg-body-tertiary">
|
||||
<div class="app-sidebar-scroll h-100" data-simplebar>
|
||||
<nav class="nav nav-pills flex-column gap-1 p-3" aria-label="Navigation Wallet Desk">
|
||||
<button class="nav-link text-start active" type="button" data-view="dashboard"><i class="fa-solid fa-gauge-high me-2" aria-hidden="true"></i>Dashboard</button>
|
||||
<button class="nav-link text-start" type="button" data-view="wallets"><i class="fa-solid fa-wallet me-2" aria-hidden="true"></i>Wallets</button>
|
||||
<button class="nav-link text-start" type="button" data-view="create-import"><i class="fa-solid fa-circle-plus me-2" aria-hidden="true"></i>Create / Import</button>
|
||||
<button class="nav-link text-start" type="button" data-view="details"><i class="fa-solid fa-id-card me-2" aria-hidden="true"></i>Details</button>
|
||||
<button class="nav-link text-start" type="button" data-view="security"><i class="fa-solid fa-shield-halved me-2" aria-hidden="true"></i>Security</button>
|
||||
<button class="nav-link text-start" type="button" data-view="diagnostics"><i class="fa-solid fa-stethoscope me-2" aria-hidden="true"></i>Diagnostics</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="col app-content app-scrollable h-100" 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">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 id="viewTitle" class="h3 mb-1">Dashboard</h1>
|
||||
<p class="text-body-secondary mb-0">Identité autorisée et balance SOL via le Transport HTTP composite sont actives.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary">0.2.6</span>
|
||||
</div>
|
||||
<section data-view-panel="dashboard">
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Runtime</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 app-runtime-list">
|
||||
<dt class="col-sm-5">Version</dt><dd id="runtimeVersion" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Phase</dt><dd id="runtimeShellPhase" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Documents Config enregistrés</dt><dd id="runtimeConfigDocuments" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil composite</dt><dd id="runtimeCompositeProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil Logging</dt><dd id="runtimeLoggingProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil Transport</dt><dd id="runtimeTransportProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Cluster Transport</dt><dd id="runtimeTransportClusters" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Provider Transport</dt><dd id="runtimeTransportProviders" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Endpoints Transport</dt><dd id="runtimeTransportEndpoints" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Rôle Transport</dt><dd id="runtimeTransportRole" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Profil Wallet</dt><dd id="runtimeWalletProfile" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Fallback Logging</dt><dd id="runtimeLoggingFallback" class="col-sm-7">—</dd>
|
||||
<dt class="col-sm-5">Racine Wallet</dt><dd id="runtimeWalletsDirectory" class="col-sm-7 text-break">—</dd>
|
||||
<dt class="col-sm-5">Sous-répertoire Wallet</dt><dd id="runtimeWalletsSubdirectory" class="col-sm-7 text-break">—</dd>
|
||||
<dt class="col-sm-5">Répertoire effectif</dt><dd id="runtimeEffectiveWalletsDirectory" class="col-sm-7 text-break">—</dd>
|
||||
<dt class="col-sm-5">Créé au démarrage</dt><dd id="runtimeWalletDirectoryCreated" class="col-sm-7">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold">Wallet courant</div>
|
||||
<div class="card-body text-center">
|
||||
<i id="currentWalletIcon" class="fa-solid fa-lock fa-2x mb-3 text-body-secondary" aria-hidden="true"></i>
|
||||
<p id="currentWalletFilename" class="fw-semibold mb-2">Aucun wallet sélectionné.</p>
|
||||
<dl class="row small text-start mb-0">
|
||||
<dt class="col-5">État</dt><dd id="currentWalletState" class="col-7">—</dd>
|
||||
<dt class="col-5">Format</dt><dd id="currentWalletFormat" class="col-7">—</dd>
|
||||
<dt class="col-5">VIEW</dt><dd id="currentWalletView" class="col-7">—</dd>
|
||||
<dt class="col-5">Secrets Config</dt><dd id="currentWalletSecretCandidates" class="col-7">0</dd>
|
||||
<dt class="col-5">Pubkey</dt><dd id="currentWalletPubkey" class="col-7 text-break">—</dd>
|
||||
<dt class="col-5">Alias</dt><dd id="currentWalletAlias" class="col-7 text-break">—</dd>
|
||||
<dt class="col-5">Notes</dt><dd id="currentWalletNotes" class="col-7 text-break">—</dd>
|
||||
<dt class="col-5">Balance SOL</dt><dd id="currentWalletBalanceSol" class="col-7">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="wallets" hidden>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="fw-semibold">Wallets disponibles</span>
|
||||
<small class="text-body-secondary"><i class="fa-solid fa-lock me-1" aria-hidden="true"></i>verrouillé · <i class="fa-solid fa-lock-open me-1" aria-hidden="true"></i>ouvert</small>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-shell-action="refresh-wallets">
|
||||
<i class="fa-solid fa-rotate me-2" aria-hidden="true"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="walletInventoryTable" class="table table-striped table-hover align-middle w-100">
|
||||
<thead><tr><th>État</th><th>Filename</th><th>Format</th><th>VIEW</th><th>Inspection</th></tr></thead>
|
||||
<tbody id="walletInventoryBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="create-import" hidden>
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Créer un wallet natif KSP</div>
|
||||
<div class="card-body">
|
||||
<form id="createWalletForm" autocomplete="off">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="createWalletFilename">Filename</label>
|
||||
<input id="createWalletFilename" class="form-control" type="text" value="wallet-01.kspwallet" required spellcheck="false">
|
||||
<div class="form-text">Un seul filename sous le répertoire Wallet effectif, suffixe exact .kspwallet.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="createWalletAlias">Alias interne optionnel</label>
|
||||
<input id="createWalletAlias" class="form-control" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="createWalletOwnerPassword">OWNER password</label>
|
||||
<input id="createWalletOwnerPassword" class="form-control" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input id="createWalletEnableView" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" for="createWalletEnableView">Créer aussi une capability VIEW</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="createWalletViewPassword">VIEW password</label>
|
||||
<input id="createWalletViewPassword" class="form-control" type="password" autocomplete="new-password" disabled>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="createWalletInitialNote">Note initiale optionnelle</label>
|
||||
<textarea id="createWalletInitialNote" class="form-control" rows="3" autocomplete="off"></textarea>
|
||||
</div>
|
||||
<div class="col-12 d-flex align-items-center gap-3">
|
||||
<button id="createWalletSubmit" class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-circle-plus me-2" aria-hidden="true"></i>Create wallet
|
||||
</button>
|
||||
<span id="createWalletStatus" class="text-body-secondary" role="status">Prêt.</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="fa-solid fa-shield-halved me-2" aria-hidden="true"></i>
|
||||
Les passwords sont envoyés uniquement du frontend vers Rust pour l'opération courante. Ils ne sont jamais renvoyés par Rust ni journalisés.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Importer un keypair Solana vers un wallet KSP</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label" for="importTransferFormat">Format source</label>
|
||||
<select id="importTransferFormat" class="form-select">
|
||||
<option value="solana_cli_json">Solana CLI JSON</option>
|
||||
<option value="solana_keypair_base58">Keypair Base58 complet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7 d-flex align-items-end">
|
||||
<button id="inspectImportSource" class="btn btn-outline-primary" type="button">
|
||||
<i class="fa-solid fa-folder-open me-2" aria-hidden="true"></i>Choisir et inspecter la source
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-secondary mb-0">
|
||||
<div><strong>Source :</strong> <span id="importSourceName">—</span></div>
|
||||
<div><strong>Format :</strong> <span id="importSourceFormat">—</span></div>
|
||||
<div><strong>Pubkey inspectée :</strong> <span id="importSourcePubkey" class="text-break">—</span></div>
|
||||
<div class="small text-body-secondary mt-2">Le chemin et le contenu secret restent exclusivement côté Rust. Le frontend ne reçoit que cette projection publique.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="importWalletForm" autocomplete="off">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="importWalletFilename">Destination .kspwallet</label>
|
||||
<input id="importWalletFilename" class="form-control" type="text" value="imported-01.kspwallet" required spellcheck="false">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="importWalletAlias">Alias interne optionnel</label>
|
||||
<input id="importWalletAlias" class="form-control" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="importWalletOwnerPassword">Nouveau OWNER password</label>
|
||||
<input id="importWalletOwnerPassword" class="form-control" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input id="importWalletEnableView" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" for="importWalletEnableView">Créer aussi une capability VIEW</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="importWalletViewPassword">Nouveau VIEW password</label>
|
||||
<input id="importWalletViewPassword" class="form-control" type="password" autocomplete="new-password" disabled>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="importWalletInitialNote">Note initiale optionnelle</label>
|
||||
<textarea id="importWalletInitialNote" class="form-control" rows="3" autocomplete="off"></textarea>
|
||||
</div>
|
||||
<div class="col-12 d-flex align-items-center gap-3">
|
||||
<button id="importWalletSubmit" class="btn btn-primary" type="submit" disabled>
|
||||
<i class="fa-solid fa-file-import me-2" aria-hidden="true"></i>Import wallet
|
||||
</button>
|
||||
<span id="importWalletStatus" class="text-body-secondary" role="status">Choisir une source.</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="details" hidden>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<span class="fw-semibold">Détails autorisés et balance réseau</span>
|
||||
<button id="refreshWalletBalance" class="btn btn-outline-primary btn-sm" type="button" disabled>
|
||||
<i class="fa-solid fa-coins me-2" aria-hidden="true"></i>Refresh balance
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-3">
|
||||
<dt class="col-md-4">Pubkey</dt><dd id="detailsWalletPubkey" class="col-md-8 text-break">—</dd>
|
||||
<dt class="col-md-4">Alias</dt><dd id="detailsWalletAlias" class="col-md-8 text-break">—</dd>
|
||||
<dt class="col-md-4">Notes</dt><dd id="detailsWalletNotes" class="col-md-8 text-break">—</dd>
|
||||
<dt class="col-md-4">Lamports</dt><dd id="detailsBalanceLamports" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">SOL</dt><dd id="detailsBalanceSol" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">RPC slot</dt><dd id="detailsBalanceSlot" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">RPC api_version</dt><dd id="detailsBalanceApiVersion" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Transport</dt><dd id="detailsBalanceTransport" class="col-md-8">—</dd>
|
||||
</dl>
|
||||
<div id="balanceStatus" class="alert alert-secondary mb-0" role="status">Unlock VIEW ou OWNER pour interroger getBalance.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header fw-semibold">Administration metadata OWNER</div>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary">Alias et notes sont protégés dans le wallet. Les contrôles restent désactivés tant qu’une session OWNER n’est pas ouverte.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="ownerWalletAlias">Alias interne</label>
|
||||
<div class="input-group">
|
||||
<input id="ownerWalletAlias" class="form-control" type="text" autocomplete="off" data-owner-metadata-control disabled>
|
||||
<button id="saveOwnerAlias" class="btn btn-outline-primary" type="button" data-owner-metadata-control disabled>Save</button>
|
||||
<button id="clearOwnerAlias" class="btn btn-outline-secondary" type="button" data-owner-metadata-control disabled>Clear</button>
|
||||
</div>
|
||||
<div class="form-text">Limite V1 : 256 octets UTF-8. La validation autoritative reste dans ksp-wallet-lib.</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="ownerNewNote">Nouvelle note</label>
|
||||
<div class="input-group">
|
||||
<textarea id="ownerNewNote" class="form-control" rows="2" autocomplete="off" data-owner-metadata-control disabled></textarea>
|
||||
<button id="addOwnerNote" class="btn btn-outline-primary" type="button" data-owner-metadata-control disabled>Add note</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-semibold">Notes autorisées</span>
|
||||
<small class="text-body-secondary">64 notes maximum en V1</small>
|
||||
</div>
|
||||
<div id="ownerNotesList" class="vstack gap-2"><span class="text-body-secondary">Ouvrir OWNER pour administrer les notes.</span></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div id="ownerMetadataStatus" class="alert alert-secondary mb-0" role="status">Session OWNER requise.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="security" hidden>
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Unlock manuel du wallet sélectionné</div>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary">Chaque tentative Argon2 est explicite et peut prendre plusieurs secondes. Le password est envoyé uniquement frontend → Rust puis purgé.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="unlockWalletViewPassword">VIEW password</label>
|
||||
<div class="input-group">
|
||||
<input id="unlockWalletViewPassword" class="form-control" type="password" autocomplete="current-password">
|
||||
<button id="unlockViewManual" class="btn btn-outline-primary" type="button" disabled>Unlock VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="unlockWalletOwnerPassword">OWNER password</label>
|
||||
<div class="input-group">
|
||||
<input id="unlockWalletOwnerPassword" class="form-control" type="password" autocomplete="current-password">
|
||||
<button id="unlockOwnerManual" class="btn btn-outline-danger" type="button" disabled>Unlock OWNER</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Secrets configurés</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">Candidats effectifs : <strong id="unlockSecretCandidateCount">0</strong></p>
|
||||
<p class="small text-body-secondary">Les noms, suffixes et valeurs des secrets Wallet configurés restent exclusivement côté Rust/Config. Cette action tente les candidats uniquement sur demande.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button id="unlockViewConfigured" class="btn btn-outline-primary" type="button" disabled>Unlock VIEW avec secret configuré</button>
|
||||
<button id="unlockOwnerConfigured" class="btn btn-outline-danger" type="button" disabled>Unlock OWNER avec secret configuré</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold">Rotation credentials OWNER / VIEW</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary">OWNER peut administrer OWNER et VIEW sans ancien password VIEW. Une session VIEW peut self-rotate son propre password VIEW. Les rotations modifient le fichier Wallet sans changer automatiquement les secrets Config.</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="rotateOwnerPassword">Nouveau OWNER password</label>
|
||||
<input id="rotateOwnerPassword" class="form-control mb-2" type="password" autocomplete="new-password" data-owner-rotation-control disabled>
|
||||
<label class="form-label" for="rotateOwnerPasswordConfirm">Confirmer OWNER password</label>
|
||||
<div class="input-group">
|
||||
<input id="rotateOwnerPasswordConfirm" class="form-control" type="password" autocomplete="new-password" data-owner-rotation-control disabled>
|
||||
<button id="rotateOwnerPasswordSubmit" class="btn btn-outline-danger" type="button" data-owner-rotation-control disabled>Rotate OWNER</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="rotateViewPassword">Nouveau VIEW password</label>
|
||||
<input id="rotateViewPassword" class="form-control mb-2" type="password" autocomplete="new-password" data-view-rotation-control disabled>
|
||||
<label class="form-label" for="rotateViewPasswordConfirm">Confirmer VIEW password</label>
|
||||
<div class="input-group">
|
||||
<input id="rotateViewPasswordConfirm" class="form-control" type="password" autocomplete="new-password" data-view-rotation-control disabled>
|
||||
<button id="rotateViewPasswordSubmit" class="btn btn-outline-primary" type="button" data-view-rotation-control disabled>Rotate VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rotationStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session VIEW ou OWNER requise pour une rotation VIEW ; OWNER requis pour une rotation OWNER.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-warning">
|
||||
<div class="card-header fw-semibold">Administration forte VIEW — OWNER uniquement</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary">Ces opérations changent l’autorité VIEW courante, pas seulement son password. Disable retire VIEW et rekey les metadata ; Recreate génère une nouvelle metadata key, un nouveau slot VIEW et un nouveau password. La keypair Solana, l’OWNER, l’alias et les notes restent inchangés.</p>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-4">
|
||||
<button id="disableWalletView" class="btn btn-outline-danger w-100" type="button" disabled>Strong disable VIEW</button>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="recreateViewPassword">Nouveau VIEW password</label>
|
||||
<input id="recreateViewPassword" class="form-control" type="password" autocomplete="new-password" data-view-security-recreate-control disabled>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="recreateViewPasswordConfirm">Confirmer VIEW password</label>
|
||||
<div class="input-group">
|
||||
<input id="recreateViewPasswordConfirm" class="form-control" type="password" autocomplete="new-password" data-view-security-recreate-control disabled>
|
||||
<button id="recreateWalletView" class="btn btn-outline-warning" type="button" data-view-security-recreate-control disabled>Strong recreate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="viewSecurityStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session OWNER requise pour administrer fortement VIEW.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-danger-subtle">
|
||||
<div class="card-header fw-semibold">Export keypair Solana — OWNER uniquement</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary">L’export produit un fichier secret non chiffré contenant la keypair Solana complète. Le contenu et le chemin complet restent exclusivement côté Rust ; le frontend choisit seulement le format puis confirme l’opération.</p>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="walletExportFormat">Format d’export</label>
|
||||
<select id="walletExportFormat" class="form-select" data-owner-export-control disabled>
|
||||
<option value="solana_cli_json">Solana CLI JSON</option>
|
||||
<option value="solana_keypair_base58">Keypair Base58 complet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-6 d-grid">
|
||||
<button id="exportWalletKeypair" class="btn btn-outline-danger" type="button" data-owner-export-control disabled>Export secret keypair file</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="walletExportStatus" class="alert alert-secondary mt-3 mb-0" role="status">Session OWNER requise. Aucun export texte ou clipboard n’est disponible.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div id="unlockStatus" class="alert alert-secondary mb-0" role="status">Sélectionner un wallet verrouillé.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-view-panel="diagnostics" hidden>
|
||||
<div class="card shadow-sm"><div class="card-header fw-semibold">Diagnostics Transport sûrs</div><div class="card-body"><dl class="row mb-0">
|
||||
<dt class="col-md-4">Profil</dt><dd id="diagnosticsTransportProfile" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Clusters</dt><dd id="diagnosticsTransportClusters" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Providers</dt><dd id="diagnosticsTransportProviders" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Endpoints disponibles</dt><dd id="diagnosticsTransportEndpoints" class="col-md-8">—</dd>
|
||||
<dt class="col-md-4">Rôle balance</dt><dd id="diagnosticsTransportRole" class="col-md-8">—</dd>
|
||||
</dl></div></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<div class="modal fade" id="deleteWalletNoteModal" tabindex="-1" aria-labelledby="deleteWalletNoteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="deleteWalletNoteModalLabel">Supprimer la note protégée ?</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Cette mutation OWNER est définitive pour la note sélectionnée. Le contenu protégé n’est pas recopié dans la confirmation.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="confirmDeleteWalletNote" type="button" class="btn btn-danger">Delete note</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="disableWalletViewModal" tabindex="-1" aria-labelledby="disableWalletViewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="disableWalletViewModalLabel">Strong disable VIEW ?</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Le slot VIEW courant sera supprimé et les metadata seront rekeyées. Le password VIEW actuel ne pourra plus ouvrir l’état courant. Les copies historiques du fichier restent hors de la garantie de révocation.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="confirmDisableWalletView" type="button" class="btn btn-danger">Disable VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="recreateWalletViewModal" tabindex="-1" aria-labelledby="recreateWalletViewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="recreateWalletViewModalLabel">Strong recreate VIEW ?</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Une nouvelle metadata key et un nouveau slot VIEW seront créés. Tout credential VIEW courant sera remplacé. Le nouveau password n’est jamais recopié dans cette confirmation.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="confirmRecreateWalletView" type="button" class="btn btn-warning">Recreate VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="exportWalletKeypairModal" tabindex="-1" aria-labelledby="exportWalletKeypairModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="exportWalletKeypairModalLabel">Exporter la keypair Solana ?</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Cette opération OWNER crée un fichier secret non chiffré contenant les 64 octets de la keypair Solana. Le fichier doit être protégé comme une clé privée. Un fichier existant ne sera jamais écrasé par Wallet.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="confirmExportWalletKeypair" type="button" class="btn btn-danger">Choose destination and export</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>
|
||||
111
crates/ksp-app-wallet-desk/frontend/sass/_app.scss
Normal file
111
crates/ksp-app-wallet-desk/frontend/sass/_app.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/sass/_app.scss
|
||||
// version: 2
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
$app-sidebar-width: 250px;
|
||||
|
||||
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-main > .row {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.app-scrollable {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-shell-card {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-runtime-list dd,
|
||||
#walletInventoryTable td:nth-child(2),
|
||||
#walletInventoryTable td:nth-child(3) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: $app-sidebar-width;
|
||||
min-width: $app-sidebar-width;
|
||||
max-width: $app-sidebar-width;
|
||||
}
|
||||
|
||||
.app-sidebar-scroll,
|
||||
.app-content {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sidebar .nav-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-placeholder {
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-body-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-placeholder i {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
#walletInventoryTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
160
crates/ksp-app-wallet-desk/frontend/sass/_bootswatch.scss
Normal file
160
crates/ksp-app-wallet-desk/frontend/sass/_bootswatch.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/sass/_bootswatch.scss
|
||||
// version: 1
|
||||
|
||||
// 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-wallet-desk/frontend/sass/_fontawesome.scss
Normal file
19
crates/ksp-app-wallet-desk/frontend/sass/_fontawesome.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/sass/_fontawesome.scss
|
||||
// version: 1
|
||||
|
||||
//@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-wallet-desk/frontend/sass/_simplebar.scss
Normal file
248
crates/ksp-app-wallet-desk/frontend/sass/_simplebar.scss
Normal file
@@ -0,0 +1,248 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/sass/_simplebar.scss
|
||||
// version: 1
|
||||
|
||||
/* 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-wallet-desk/frontend/sass/_variables.scss
Normal file
95
crates/ksp-app-wallet-desk/frontend/sass/_variables.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
// file: crates/ksp-app-wallet-desk/frontend/sass/_variables.scss
|
||||
// version: 1
|
||||
|
||||
// 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-wallet-desk/frontend/sass/main.scss
Normal file
10
crates/ksp-app-wallet-desk/frontend/sass/main.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: crates/ksp-app-wallet-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";
|
||||
137
crates/ksp-app-wallet-desk/frontend/sass/splash.scss
Normal file
137
crates/ksp-app-wallet-desk/frontend/sass/splash.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// file: crates/ksp-app-wallet-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;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#splash-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
max-width: 100%;
|
||||
height: 637px;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
#splash-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#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);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#debug-info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
padding: 10px 10px 36px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#debug-info::-webkit-scrollbar,
|
||||
#messages-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.splash-message {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
animation: splashMessageFadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.splash-message.info { background: rgba(33, 150, 243, 0.22); }
|
||||
.splash-message.warning { background: rgba(255, 152, 0, 0.22); }
|
||||
.splash-message.error { background: rgba(244, 67, 54, 0.22); }
|
||||
.splash-message.success { background: rgba(76, 175, 80, 0.22); }
|
||||
|
||||
@keyframes splashMessageFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user