v0.1.4-pre.010
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-app-config-desk/Cargo.toml
|
||||
# version: 4
|
||||
# version: 5
|
||||
|
||||
[package]
|
||||
name = "ksp-app-config-desk"
|
||||
@@ -27,6 +27,7 @@ ksp-config-lib = { path = "../ksp-config-lib" }
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# `ksp-app-config-desk`
|
||||
|
||||
@@ -27,6 +27,7 @@ Le gabarit desktop actif fournit maintenant :
|
||||
- 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 ;
|
||||
- `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;
|
||||
@@ -51,6 +52,7 @@ frontend/
|
||||
│ ├── frontend_log.ts
|
||||
│ ├── invoke.ts
|
||||
│ ├── main.ts
|
||||
│ ├── profiles.ts
|
||||
│ └── splash.ts
|
||||
├── main.html
|
||||
└── splash.html
|
||||
@@ -127,6 +129,13 @@ Après la readiness frontend, Rust émet `fade_in`, attend la durée minimale,
|
||||
|
||||
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. Les routes non encore fonctionnelles affichent un placeholder sans réimplémenter les services prévus par les prereleases suivantes.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- file: crates/ksp-app-config-desk/TODO.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# 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` et le panneau Documents 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`, le panneau Documents et le panneau Profils/provenance sont maintenant présents.
|
||||
|
||||
Les prochaines tranches doivent encore :
|
||||
|
||||
- développer les panneaux Profils, Environnement/`.env`, Secrets et Logging ;
|
||||
- développer les panneaux Environnement/`.env`, Secrets et Logging ;
|
||||
- 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 ;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# 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`. Le panneau Documents est fonctionnel ; les panneaux Profils, Environnement/`.env`, Secrets et Logging 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 et Profils sont fonctionnels ; les panneaux Environnement/`.env`, Secrets et Logging restent à 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
|
||||
```
|
||||
|
||||
Seule la vue d'ensemble consomme déjà `get_app_snapshot`; les autres routes restent des placeholders jusqu'à leurs tranches fonctionnelles. 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 vues d'ensemble, Documents et Profils sont fonctionnelles ; Environnement / `.env` et Logging restent des placeholders jusqu'à leurs tranches fonctionnelles. 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
|
||||
|
||||
@@ -153,3 +153,17 @@ Pour chaque document, le backend expose :
|
||||
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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
|
||||
<!-- version: 4 -->
|
||||
<!-- version: 5 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -116,6 +116,94 @@
|
||||
<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="placeholderPanel" hidden aria-live="polite">
|
||||
<div class="alert alert-secondary mb-4" role="status">
|
||||
Cette route du shell est prête ; son contenu fonctionnel sera ajouté dans la prerelease prévue par le plan 0.1.4.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -91,3 +91,23 @@ body {
|
||||
#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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/main.ts
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
import "bootstrap";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
@@ -8,6 +8,7 @@ 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 { initializeProfilesPanel } from "./profiles";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
@@ -26,7 +27,7 @@ const viewCopy: Record<ViewId, { title: string; description: string }> = {
|
||||
},
|
||||
profiles: {
|
||||
title: "Profils",
|
||||
description: "Sélection et provenance des profils Config — surface fonctionnelle prévue en pre.010.",
|
||||
description: "Sélection default/explicite, vues global/profil/effective sûre et provenance Config.",
|
||||
},
|
||||
environment: {
|
||||
title: "Environnement / .env",
|
||||
@@ -74,9 +75,13 @@ function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||||
if (documents) {
|
||||
documents.hidden = viewId !== "documents";
|
||||
}
|
||||
const profiles = document.querySelector<HTMLElement>("#profilesPanel");
|
||||
if (profiles) {
|
||||
profiles.hidden = viewId !== "profiles";
|
||||
}
|
||||
const placeholder = document.querySelector<HTMLElement>("#placeholderPanel");
|
||||
if (placeholder) {
|
||||
placeholder.hidden = viewId === "overview" || viewId === "documents";
|
||||
placeholder.hidden = viewId === "overview" || viewId === "documents" || viewId === "profiles";
|
||||
}
|
||||
frontendTrace("main", "Main view DOM updated", { viewId, source });
|
||||
}
|
||||
@@ -136,6 +141,7 @@ async function initializeMain(): Promise<void> {
|
||||
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
|
||||
bindNavigation();
|
||||
initializeDocumentsPanel();
|
||||
initializeProfilesPanel();
|
||||
activateView("overview", "startup");
|
||||
try {
|
||||
await loadSnapshot();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/constants.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Application-owned tracing targets and domains.
|
||||
|
||||
@@ -19,3 +19,5 @@ pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||
/// Structured domain for Config document inventory, diagnostics and repair.
|
||||
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
|
||||
/// Structured domain for Config profile selection and provenance inspection.
|
||||
pub(crate) const TRACING_DOMAIN_PROFILES: &str = "config.profiles";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/errors.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Application-local error codes for the configuration desktop shell.
|
||||
|
||||
@@ -15,6 +15,11 @@ pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp
|
||||
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");
|
||||
|
||||
/// 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");
|
||||
/// 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");
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/lib.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Tauri desktop application for managing and validating KSP configuration.
|
||||
|
||||
@@ -14,6 +14,7 @@ mod documents;
|
||||
mod dto_common;
|
||||
mod errors;
|
||||
mod frontend_logging;
|
||||
mod profiles;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
mod tw_main;
|
||||
@@ -28,6 +29,7 @@ pub(crate) use self::bootstrap::initialize_logging;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_DOCUMENTS;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_PROFILES;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||
@@ -45,6 +47,8 @@ pub(crate) use self::errors::ERROR_CODE_DOCUMENT_KIND_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||
pub(crate) use self::errors::ERROR_CODE_PROFILE_CONTRACT_MISSING;
|
||||
pub(crate) use self::errors::ERROR_CODE_PROFILE_PROJECTION_FAILED;
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_SETTING_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
|
||||
@@ -52,6 +56,8 @@ 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::profiles::ConfigProfileDetailDto;
|
||||
pub(crate) use self::profiles::ConfigProfileDocumentDto;
|
||||
pub(crate) use self::splash::SplashOrderDto;
|
||||
pub(crate) use self::splash::SplashSettings;
|
||||
pub(crate) use self::tw_main::show_and_focus as show_main_window;
|
||||
|
||||
351
crates/ksp-app-config-desk/src/profiles.rs
Normal file
351
crates/ksp-app-config-desk/src/profiles.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
// file: crates/ksp-app-config-desk/src/profiles.rs
|
||||
// version: 1
|
||||
|
||||
//! Safe Config profile inspection and provenance projections for Config Desk.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-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>,
|
||||
}
|
||||
|
||||
/// Lists validated Config documents that expose standard profiles.
|
||||
pub(crate) fn 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 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,
|
||||
});
|
||||
}
|
||||
|
||||
struct ProfileContract {
|
||||
default_profile: String,
|
||||
profile_ids: std::vec::Vec<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/tauri.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! Tauri runtime assembly for the KSP configuration desktop application.
|
||||
|
||||
@@ -42,6 +42,8 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
|
||||
get_config_documents,
|
||||
get_config_document_detail,
|
||||
save_config_document_source,
|
||||
get_config_profile_documents,
|
||||
get_config_profile_detail,
|
||||
emit_frontend_log,
|
||||
splash_frontend_ready
|
||||
]);
|
||||
@@ -92,6 +94,30 @@ fn save_config_document_source(
|
||||
return crate::documents::save_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::profiles::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::profiles::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 emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
|
||||
73
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
73
crates/ksp-app-config-desk/unit_tests/profiles.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/profiles.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn profile_inventory_exposes_logging_default_and_available_profiles() {
|
||||
let management = committed_management();
|
||||
assert!(management.is_ok(), "committed 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_eq!(inventory.len(), 1);
|
||||
assert_eq!(inventory[0].file_id, ksp_config_lib::FILE_ID_STD_LOGGING);
|
||||
assert_eq!(inventory[0].default_profile, "local_dev");
|
||||
assert!(inventory[0].profile_ids.iter().any(|profile_id| {
|
||||
return profile_id == "local_dev";
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_profile_detail_uses_safe_effective_value_and_dotenv_provenance() {
|
||||
let management = committed_management();
|
||||
assert!(management.is_ok(), "committed management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
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(detail) = detail {
|
||||
assert_eq!(detail.selected_profile, "local_dev");
|
||||
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 = committed_management();
|
||||
assert!(management.is_ok(), "committed management should construct: {management:?}");
|
||||
if let std::result::Result::Ok(management) = management {
|
||||
let detail = super::detail_from_management(&management, ksp_config_lib::FILE_ID_STD_LOGGING, std::option::Option::Some("local_dev"));
|
||||
assert!(detail.is_ok(), "explicit profile should inspect: {detail:?}");
|
||||
if let std::result::Result::Ok(detail) = detail {
|
||||
assert_eq!(detail.selected_profile, "local_dev");
|
||||
assert_eq!(detail.selection_source, "explicit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user