v0.1.4-pre.015

This commit is contained in:
2026-08-16 17:38:23 +02:00
parent 789ffb16ce
commit d7185905d8
17 changed files with 1124 additions and 305 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/README.md -->
<!-- version: 15 -->
<!-- version: 16 -->
# `ksp-app-config-desk`
@@ -29,7 +29,7 @@ Le gabarit desktop actif fournit maintenant :
- 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 en lecture typée, alimenté par `ConfigManagement::load_logging_document()` et projetant profils, console, fichiers, filtres, targets et domains sans parsing frontend ;
- 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 ;
- `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;
@@ -132,7 +132,7 @@ KSP_DESK_SPLASH_FADE_MS=300
Après la readiness frontend, Rust émet `fade_in`, attend la durée minimale, émet `fade_out`, attend la durée de fade puis `tw_main.rs` affiche/focalise `main` avant destruction du splash. Le backend journalise en `debug` les valeurs résolues, leur provenance (`process`, `dotenv` ou `fallback`), chaque attente réellement observée et la durée totale. Avec `12000/3000`, la durée backend attendue entre readiness et activation de `main` est donc d'environ `15000 ms`. Une valeur splash invalide ne bloque pas le manager : des timings de fallback sûrs restent en mémoire afin que la future surface `.env` puisse permettre la réparation.
Le shell principal expose les cinq routes de référence `Vue d'ensemble`, `Documents`, `Profils`, `Environnement / .env` et `Logging`. Le logo porte déjà l'identité KSP ; le texte du header suit donc la forme `Config Desk — <vue active>` au lieu de répéter `KSP`. Les quelques commandes principales restent des pills/tabs à droite ; un dropdown sera préféré lorsqu'une application possède trop de commandes pour conserver ce format lisible. La route Logging expose maintenant la lecture typée du document et de ses profils ; les mutations/persistence et le hot reload restent réservés aux prereleases suivantes.
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 et sa persistence atomique ; le hot reload runtime reste réservé à `pre.016`.
## Panneau Profils
@@ -141,11 +141,11 @@ La vue **Profils** inspecte les documents validés qui exposent le contrat stand
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 — lecture typée
## 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.
La tranche de lecture 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. La sélection d'un profil ne modifie rien : `pre.014` est strictement read-only. Les mutations typées et la persistence via `save_logging_document()` appartiennent à `pre.015`.
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. En `pre.015`, le frontend maintient un brouillon typé : create/clone/rename/delete de profils, 0/1/N file sinks et target filters restent locaux jusqu'à **Sauvegarder**. Le backend reconstruit les types publics Config et appelle `ConfigManagement::save_logging_document()`, qui valide la totalité du candidat avant remplacement atomique. Le runtime actif n'est pas reconfiguré avant `pre.016`.
## Traçabilité frontend

View File

@@ -1,13 +1,13 @@
<!-- file: crates/ksp-app-config-desk/TODO.md -->
<!-- version: 13 -->
<!-- version: 14 -->
# TODO `ksp-app-config-desk`
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP, le lifecycle `splash -> main`, les panneaux Documents, Profils/provenance et Environnement avec management `.env`/reveal Secret privilégié, ainsi que la lecture typée du document Logging sont maintenant présents.
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP, le lifecycle `splash -> main`, les panneaux Documents, Profils/provenance et Environnement avec management `.env`/reveal Secret privilégié, ainsi que l'édition typée/persistence atomique du document Logging sont maintenant présents.
Les prochaines tranches doivent encore :
- ajouter les mutations/persistence du Logging editor puis son hot reload et son panneau de test ;
- ajouter le hot reload du Logging editor puis son panneau de test ;
- faire évoluer le runtime Logging vers des fichiers applicatifs uniques par lancement (`app-name.<start-timestamp>.<ext>`) ;
- ramener le target applicatif de `debug` à `info` ou `warn` avant clôture de la release ;
- démontrer le hot reload Logging et le panneau de test de routage ;

View File

@@ -1,11 +1,11 @@
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
<!-- version: 15 -->
<!-- version: 16 -->
# 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 son document/profils/sinks/filtres en lecture typée ; les mutations et le hot reload restent à compléter.
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 et une persistence atomique ; seul le hot reload runtime reste à compléter.
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.
@@ -131,7 +131,7 @@ Environnement / .env
Logging
```
Les cinq vues sont maintenant fonctionnelles en lecture. Logging charge le contrat typé `std.logging.json`; ses mutations et son hot reload restent désactivés jusqu'aux tranches dédiées. 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.
Les cinq vues sont fonctionnelles. Logging charge le contrat typé `std.logging.json`, permet de modifier son brouillon puis de le persister via Config ; son hot reload runtime reste désactivé jusqu'à `pre.016`. 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
@@ -200,7 +200,7 @@ Le bouton **Supprimer** ouvre un modal Bootstrap indiquant la variable ciblée.
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 — lecture typée
## Panneau Logging — édition typée
La vue **Logging** appelle `get_logging_document`. Le backend charge le document avec `ConfigManagement::load_logging_document()` et expose :
@@ -210,5 +210,5 @@ La vue **Logging** appelle `get_logging_document`. Le backend charge le document
- tous les fichiers (`output_id`, path, rotation, format, ANSI et filtre) ;
- tous les `target_filters` globaux.
Le sélecteur **Profil inspec** ne modifie pas le document. **Rafraîchir** relit la source typée. Aucun bouton de sauvegarde n'est présent en `pre.014`; les opérations create/clone/rename/delete, `default_profile` et persistence sont volontairement reportées à `pre.015`.
Le sélecteur **Profil édi** 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** envoie un candidat typé à `save_logging_document`, qui reconstruit les contrats Config et persiste atomiquement après validation. **Recharger** resynchronise depuis la source et demande confirmation si le brouillon contient des changements non sauvegardés. Le runtime Logging actif ne change pas dans `pre.015`.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
<!-- version: 10 -->
<!-- version: 11 -->
<!DOCTYPE html>
<html lang="fr">
@@ -300,110 +300,144 @@
<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 — lecture typée</h2>
<p class="text-body-secondary small mb-0">Projection read-only de std.logging.json via les types publics de ksp-config-lib ; aucune mutation runtime dans cette tranche.</p>
<h2 class="h5 mb-1">Logging editor — mutations/persistence</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>. Le runtime Logging n'est pas rechargé dans cette tranche.</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
</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
</button>
</div>
<button id="refreshLoggingDocument" 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>
<dl class="row small mb-4 app-profile-metadata">
<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">format_version</dt>
<dd id="loggingFormatVersion" class="col-lg-9 font-monospace"></dd>
<dt class="col-lg-3">logs_directory</dt>
<dd id="loggingLogsDirectory" class="col-lg-9 font-monospace text-break"></dd>
<dt class="col-lg-3 mb-0">default_profile</dt>
<dd id="loggingDefaultProfile" class="col-lg-9 mb-0 font-monospace"></dd>
<dt class="col-lg-3 mb-0">format_version</dt>
<dd id="loggingFormatVersion" class="col-lg-9 mb-0 font-monospace"></dd>
</dl>
<div class="row g-3 align-items-end mb-4">
<div class="col-xl-5">
<label class="form-label" for="loggingProfileSelect">Profil inspecté</label>
<select id="loggingProfileSelect" class="form-select" disabled></select>
<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>
<div class="col-xl-3">
<span class="form-label d-block">default_filter</span>
<code id="loggingProfileDefaultFilter"></code>
</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>
<div class="col-xl-4">
<span class="form-label d-block">span_events</span>
<code id="loggingProfileSpanEvents"></code>
</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="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th scope="col">Enabled</th>
<th scope="col">Output</th>
<th scope="col">ANSI</th>
<th scope="col">Format</th>
<th scope="col">Level</th>
<th scope="col">Targets</th>
<th scope="col">Domains</th>
</tr>
</thead>
<tbody>
<tr>
<td id="loggingConsoleEnabled"></td>
<td id="loggingConsoleOutput"></td>
<td id="loggingConsoleAnsi"></td>
<td id="loggingConsoleFormat"></td>
<td id="loggingConsoleLevel"></td>
<td id="loggingConsoleTargets" class="font-monospace text-break"></td>
<td id="loggingConsoleDomains" class="font-monospace text-break"></td>
</tr>
</tbody>
</table>
<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">
<h3 class="h6 mb-3">Fichiers</h3>
<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.</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-striped table-hover table-sm align-middle w-100">
<thead>
<tr>
<th scope="col">output_id</th>
<th scope="col">Enabled</th>
<th scope="col">Path</th>
<th scope="col">Rotation</th>
<th scope="col">Format</th>
<th scope="col">ANSI</th>
<th scope="col">Level</th>
<th scope="col">Targets</th>
<th scope="col">Domains</th>
</tr>
</thead>
<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">
<h3 class="h6 mb-3">Target filters globaux</h3>
<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-striped table-hover table-sm align-middle w-100">
<thead>
<tr>
<th scope="col">target_prefix</th>
<th scope="col">Level</th>
</tr>
</thead>
<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 id="loggingSaveReport" class="small text-body-secondary mb-2">Aucune sauvegarde 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>
@@ -417,6 +451,43 @@
</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 la source les abandonnera.</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</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 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">

View File

@@ -1,17 +1,27 @@
// file: crates/ksp-app-config-desk/frontend/ts/logging.ts
// version: 1
// version: 2
//! Read-only typed Logging editor backed exclusively by ConfigManagement.
//! Typed Logging editor backed exclusively by ConfigManagement validation and persistence.
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 { LoggingOutputFilterDto } from "./bindings/ksp_app_config_desk/logging/LoggingOutputFilterDto";
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 { frontendDebug, frontendTrace } from "./frontend_log";
import { invokeKsp } from "./invoke";
let loggingDocument: LoggingDocumentDto | null = null;
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;
function setLoggingStatus(message: string, tone: "primary" | "success" | "danger" = "primary"): void {
let draft: LoggingDocumentCandidateDto | 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;
@@ -28,58 +38,177 @@ function setText(selector: string, value: string): void {
}
}
function boolLabel(value: boolean): string {
return value ? "oui" : "non";
function inputValue(selector: string): string {
return document.querySelector<HTMLInputElement>(selector)?.value ?? "";
}
function selectorsLabel(values: string[]): string {
return values.length > 0 ? values.join(", ") : "";
function selectedValue(selector: string): string {
return document.querySelector<HTMLSelectElement>(selector)?.value ?? "";
}
function filterSummary(filter: LoggingOutputFilterDto): { level: string; targets: string; domains: string } {
return { level: filter.level, targets: selectorsLabel(filter.targets), domains: selectorsLabel(filter.domains) };
function checkboxValue(selector: string): boolean {
return document.querySelector<HTMLInputElement>(selector)?.checked ?? false;
}
function appendCell(row: HTMLTableRowElement, value: string, className?: string): void {
const cell = document.createElement("td");
cell.textContent = value;
if (className) {
cell.className = className;
function setInputValue(selector: string, value: string): void {
const input = document.querySelector<HTMLInputElement>(selector);
if (input) {
input.value = value;
}
row.append(cell);
}
function renderProfileSelect(documentDto: LoggingDocumentDto): void {
const select = document.querySelector<HTMLSelectElement>("#loggingProfileSelect");
if (!select) {
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");
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;
}
const previous = select.value;
select.replaceChildren();
for (const profile of documentDto.profiles) {
const option = document.createElement("option");
option.value = profile.profileId;
option.textContent = profile.profileId === documentDto.defaultProfile ? `${profile.profileId} (default)` : profile.profileId;
select.append(option);
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);
}
const previousExists = documentDto.profiles.some(profile => profile.profileId === previous);
select.value = previousExists ? previous : documentDto.defaultProfile;
if (!documentDto.profiles.some(profile => profile.profileId === select.value) && documentDto.profiles.length > 0) {
select.value = documentDto.profiles[0].profileId;
if (!draft.profiles.some(profile => profile.profileId === selectedProfileId)) {
selectedProfileId = draft.profiles[0]?.profileId ?? "";
}
select.disabled = documentDto.profiles.length === 0;
frontendTrace("main", "Logging editor profile select replaced", { profileCount: documentDto.profiles.length });
profileSelect.value = selectedProfileId;
defaultSelect.value = draft.defaultProfile;
frontendTrace("main", "Logging profile selectors replaced", { profileCount: draft.profiles.length });
}
function renderConsole(profile: LoggingProfileDto): void {
const summary = filterSummary(profile.console.filter);
setText("#loggingConsoleEnabled", boolLabel(profile.console.enabled));
setText("#loggingConsoleOutput", profile.console.output);
setText("#loggingConsoleAnsi", boolLabel(profile.console.ansi));
setText("#loggingConsoleFormat", profile.console.format);
setText("#loggingConsoleLevel", summary.level);
setText("#loggingConsoleTargets", summary.targets);
setText("#loggingConsoleDomains", summary.domains);
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 {
@@ -88,21 +217,30 @@ function renderFiles(profile: LoggingProfileDto): void {
return;
}
body.replaceChildren();
for (const file of profile.files) {
const summary = filterSummary(file.filter);
profile.files.forEach((file, index) => {
const row = document.createElement("tr");
appendCell(row, file.outputId, "font-monospace");
appendCell(row, boolLabel(file.enabled));
appendCell(row, file.path, "font-monospace text-break");
appendCell(row, file.rotation);
appendCell(row, file.format);
appendCell(row, boolLabel(file.ansi));
appendCell(row, summary.level);
appendCell(row, summary.targets, "font-monospace text-break");
appendCell(row, summary.domains, "font-monospace text-break");
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 table DOM replaced", { fileCount: profile.files.length });
});
frontendTrace("main", "Logging file sink editor DOM replaced", { fileCount: profile.files.length });
}
function renderTargetFilters(profile: LoggingProfileDto): void {
@@ -111,32 +249,45 @@ function renderTargetFilters(profile: LoggingProfileDto): void {
return;
}
body.replaceChildren();
for (const filter of profile.targetFilters) {
profile.targetFilters.forEach((filter, index) => {
const row = document.createElement("tr");
appendCell(row, filter.targetPrefix, "font-monospace");
appendCell(row, filter.level);
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 override table DOM replaced", { targetFilterCount: profile.targetFilters.length });
});
frontendTrace("main", "Logging target filter editor DOM replaced", { targetFilterCount: profile.targetFilters.length });
}
function renderSelectedProfile(source: "load" | "selection"): void {
const documentDto = loggingDocument;
const select = document.querySelector<HTMLSelectElement>("#loggingProfileSelect");
if (!documentDto || !select) {
return;
}
const profile = documentDto.profiles.find(candidate => candidate.profileId === select.value);
function renderSelectedProfile(source: "load" | "selection" | "mutation"): void {
const profile = selectedProfile();
if (!profile) {
setLoggingStatus("Le profil Logging sélectionné n'existe plus dans le document chargé.", "danger");
setEditorEnabled(false);
return;
}
setText("#loggingProfileDefaultFilter", profile.defaultFilter);
setText("#loggingProfileSpanEvents", profile.spanEvents);
renderConsole(profile);
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);
frontendTrace("main", "Logging profile read-only view rendered", {
setEditorEnabled(true);
frontendTrace("main", "Logging profile editor rendered", {
profileId: profile.profileId,
source,
fileCount: profile.files.length,
@@ -145,46 +296,332 @@ function renderSelectedProfile(source: "load" | "selection"): void {
}
function renderDocument(documentDto: LoggingDocumentDto): void {
loggingDocument = 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());
setText("#loggingLogsDirectory", documentDto.logsDirectory);
setText("#loggingDefaultProfile", documentDto.defaultProfile);
renderProfileSelect(documentDto);
setInputValue("#loggingLogsDirectoryInput", draft.logsDirectory);
renderProfileSelects();
renderSelectedProfile("load");
frontendTrace("main", "Logging document read-only view rendered", { profileCount: documentDto.profiles.length });
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 et sauvegarde atomique du document Logging...");
frontendDebug("main", "Typed Logging document persistence 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);
setText("#loggingSaveReport", `source_changed=${result.sourceChanged} · reload_required=${result.reloadRequired} · runtime Logging inchangé`);
setLoggingStatus(result.sourceChanged ? "Document Logging validé et persisté. Le runtime sera traité en pre.016." : "Document Logging valide mais source inchangée.", "success");
frontendDebug("main", "Typed Logging document persistence completed", {
sourceChanged: result.sourceChanged,
reloadRequired: result.reloadRequired,
profileCount: result.document.profiles.length,
});
} catch {
setEditorEnabled(true);
setLoggingStatus("Candidat Logging refusé. std.logging.json reste inchangé ; corrige le brouillon puis réessaie.", "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 refresh requested");
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) en lecture seule.`, "success");
frontendDebug("main", "Logging document refresh completed", {
setLoggingStatus(`${documentDto.profiles.length} profil(s) Logging chargé(s). Le brouillon est synchronisé avec la source.`, "success");
frontendDebug("main", "Logging document reload completed", {
profileCount: documentDto.profiles.length,
defaultProfile: documentDto.defaultProfile,
});
} catch {
loggingDocument = null;
draft = null;
setEditorEnabled(false);
setLoggingStatus("Le document Logging typé n'a pas pu être chargé. Consulte les diagnostics backend.", "danger");
}
}
/// Initializes the read-only Logging editor surface.
export function initializeLoggingPanel(): void {
document.querySelector<HTMLButtonElement>("#refreshLoggingDocument")?.addEventListener("click", () => {
void refreshLoggingDocument("user");
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<HTMLSelectElement>("#loggingProfileSelect")?.addEventListener("change", event => {
const profileId = (event.currentTarget as HTMLSelectElement).value;
frontendDebug("main", "Logging read-only profile selection changed", { profileId });
syncStaticProfileFields();
selectedProfileId = (event.currentTarget as HTMLSelectElement).value;
frontendDebug("main", "Logging profile selection changed", { profileId: selectedProfileId });
renderSelectedProfile("selection");
});
frontendTrace("main", "Logging editor handlers installed");
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);
frontendTrace("main", "Logging editor mutation handlers installed");
void refreshLoggingDocument("startup");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/frontend/ts/main.ts
// version: 9
// version: 10
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
@@ -38,7 +38,7 @@ const viewCopy: Record<ViewId, { title: string; description: string }> = {
},
logging: {
title: "Logging",
description: "Lecture typée des profils, sinks et filtres Logging ; mutations et hot reload restent dans les tranches suivantes.",
description: "Édition typée et persistence atomique des profils, sinks et filtres Logging ; le hot reload runtime reste dans la tranche suivante.",
},
};

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/lib.rs
// version: 10
// version: 11
//! Tauri desktop application for managing and validating KSP configuration.
@@ -66,7 +66,9 @@ pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
pub(crate) use self::frontend_logging::emit_frontend_log_event;
pub(crate) use self::logging_editor::LoggingDocumentCandidateDto;
pub(crate) use self::logging_editor::LoggingDocumentDto;
pub(crate) use self::logging_editor::LoggingDocumentSaveResultDto;
pub(crate) use self::profiles::ConfigProfileDetailDto;
pub(crate) use self::profiles::ConfigProfileDocumentDto;
pub(crate) use self::secrets::SecretRevealRequestDto;

View File

@@ -1,12 +1,12 @@
// file: crates/ksp-app-config-desk/src/logging_editor.rs
// version: 1
// version: 2
//! Read-only typed Logging document projection for the Config Desk Logging editor.
//! Typed Logging document projection and validated persistence for the Config Desk Logging editor.
use ts_rs::TS; // rust-rules: derive-import
/// One Logging sink selector/filter exposed to the read-only editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
/// 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 {
@@ -18,8 +18,8 @@ pub(crate) struct LoggingOutputFilterDto {
pub(crate) domains: std::vec::Vec<String>,
}
/// Console sink configuration exposed to the read-only editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
/// 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 {
@@ -35,8 +35,8 @@ pub(crate) struct LoggingConsoleDto {
pub(crate) filter: LoggingOutputFilterDto,
}
/// One persistent file sink exposed to the read-only editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
/// 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 {
@@ -56,8 +56,8 @@ pub(crate) struct LoggingFileDto {
pub(crate) filter: LoggingOutputFilterDto,
}
/// One global Logging target override exposed to the read-only editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
/// 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 {
@@ -67,8 +67,8 @@ pub(crate) struct LoggingTargetFilterDto {
pub(crate) level: String,
}
/// One typed Logging profile exposed to the read-only editor.
#[derive(Clone, Debug, serde::Serialize, TS)]
/// 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 {
@@ -86,7 +86,7 @@ pub(crate) struct LoggingProfileDto {
pub(crate) target_filters: std::vec::Vec<LoggingTargetFilterDto>,
}
/// Complete typed Logging document exposed to the read-only editor.
/// 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")]
@@ -105,11 +105,66 @@ pub(crate) struct LoggingDocumentDto {
pub(crate) profiles: std::vec::Vec<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<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,
/// Fresh typed document loaded after successful persistence.
pub(crate) document: LoggingDocumentDto,
}
/// Loads the typed Logging source through ConfigManagement and projects it for the frontend.
pub(crate) fn document(state: &crate::AppState) -> ksp_core_lib::Result<LoggingDocumentDto> {
return document_from_management(state.config_management());
}
/// Validates and atomically persists one typed Logging candidate through ConfigManagement.
pub(crate) fn save(state: &crate::AppState, candidate: LoggingDocumentCandidateDto) -> ksp_core_lib::Result<LoggingDocumentSaveResultDto> {
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 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(),
profile_count = document.profiles.len(),
default_profile = document.default_profile.as_str(),
"typed Logging document persistence completed; runtime was not reinitialized"
);
return std::result::Result::Ok(LoggingDocumentSaveResultDto {
source_changed: report.source_changed(),
reload_required: report.reload_required(),
document,
});
}
fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<LoggingDocumentDto> {
let source = management.load_logging_document();
let source = match source {
@@ -149,11 +204,51 @@ fn document_from_management(management: &ksp_config_lib::ConfigManagement) -> ks
profile_count = result.profiles.len(),
file_count,
target_filter_count,
"typed Logging document projected for read-only editor"
"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: 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: 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) -> LoggingProfileDto {
let mut files = std::vec::Vec::<LoggingFileDto>::with_capacity(profile.files().len());
for file in profile.files() {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/src/tauri.rs
// version: 12
// version: 13
//! Tauri runtime assembly for the KSP configuration desktop application.
@@ -49,6 +49,7 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
remove_environment_value,
reveal_environment_value,
get_logging_document,
save_logging_document,
emit_frontend_log,
splash_frontend_ready
]);
@@ -180,6 +181,18 @@ fn get_logging_document(state: tauri::State<'_, crate::AppState>) -> std::result
};
}
#[tauri::command]
fn save_logging_document(
candidate: crate::LoggingDocumentCandidateDto,
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::LoggingDocumentSaveResultDto, crate::CommandErrorDto> {
let result = crate::logging_editor::save(&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_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::emit_frontend_log_event(payload);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-config-desk/unit_tests/logging_editor.rs
// version: 1
// version: 2
#[test]
fn committed_logging_document_maps_complete_read_only_editor_contract() {
@@ -62,3 +62,72 @@ fn committed_management() -> ksp_core_lib::Result<ksp_config_lib::ConfigManageme
};
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 = super::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());
assert_eq!(config.profiles()[0].files()[0].ansi(), document.profiles[0].files[0].ansi);
}
}
}
#[test]
fn profile_candidate_preserves_multi_file_and_target_filter_shape() {
let profile = super::LoggingProfileDto {
profile_id: "clone_test".to_owned(),
default_filter: "info".to_owned(),
span_events: "full".to_owned(),
console: super::LoggingConsoleDto {
enabled: true,
output: "stdout".to_owned(),
ansi: false,
format: "human".to_owned(),
filter: super::LoggingOutputFilterDto { level: "info".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
files: std::vec![
super::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: super::LoggingOutputFilterDto { level: "debug".to_owned(), targets: std::vec!["*".to_owned()], domains: std::vec!["*".to_owned()] },
},
super::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: super::LoggingOutputFilterDto {
level: "error".to_owned(),
targets: std::vec!["ksp-config-lib".to_owned()],
domains: std::vec!["config".to_owned()],
},
},
],
target_filters: std::vec![super::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");
}