v0.1.4-pre.010

This commit is contained in:
2026-08-16 15:04:58 +02:00
parent cb10fd354b
commit f0f2954236
17 changed files with 1007 additions and 22 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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();

View 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();
}