779 lines
34 KiB
TypeScript
779 lines
34 KiB
TypeScript
// file: crates/ksp-app-backfill-desk/frontend/ts/main.ts
|
||
// version: 8
|
||
|
||
import "bootstrap";
|
||
import ResizeObserver from "resize-observer-polyfill";
|
||
import "simplebar";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||
import type { BackfillRunStatusDto } from "./bindings/ksp_app_backfill_desk/backfill_status/BackfillRunStatusDto.ts";
|
||
import type { BackfillCancelResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillCancelResponseDto.ts";
|
||
import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts";
|
||
import type { BackfillResumeResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillResumeResponseDto.ts";
|
||
import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts";
|
||
import type { BackfillStartResponseDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartResponseDto.ts";
|
||
import type { BackfillDeskOptionsDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts";
|
||
import type { BackfillHttpRouteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillHttpRouteOptionDto.ts";
|
||
import type { ProgramIdAutocompleteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/ProgramIdAutocompleteOptionDto.ts";
|
||
import type { ShellStatusDto } from "./bindings/ksp_app_backfill_desk/dto_common/ShellStatusDto.ts";
|
||
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||
import { invokeKsp } from "./invoke";
|
||
|
||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||
installFrontendConsoleBridge("main");
|
||
|
||
type ViewId = "backfill" | "diagnostics";
|
||
|
||
const viewTitles: Record<ViewId, string> = {
|
||
backfill: "Backfill",
|
||
diagnostics: "Diagnostics",
|
||
};
|
||
|
||
const commitmentLabels: Record<string, string> = {
|
||
confirmed: "confirmed",
|
||
finalized: "finalized",
|
||
};
|
||
|
||
const scopeLabels: Record<string, string> = {
|
||
latest_address: "Adresse — historique le plus récent",
|
||
before_address: "Adresse — avant une signature",
|
||
after_address: "Adresse — après une signature",
|
||
explicit_signatures: "Signatures explicites",
|
||
};
|
||
|
||
let compositionReadyForStart = false;
|
||
let currentRunStatus: BackfillRunStatusDto | null = null;
|
||
let runStartAccepted = false;
|
||
let resumeInFlight = false;
|
||
|
||
function isViewId(value: string | undefined): value is ViewId {
|
||
return value === "backfill" || value === "diagnostics";
|
||
}
|
||
|
||
function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||
document.querySelectorAll<HTMLElement>("[data-view-panel]").forEach(panel => {
|
||
panel.hidden = panel.dataset.viewPanel !== viewId;
|
||
});
|
||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||
const active = button.dataset.view === viewId;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-current", active ? "page" : "false");
|
||
});
|
||
const header = document.querySelector<HTMLElement>("#headerViewTitle");
|
||
if (header) {
|
||
header.textContent = viewTitles[viewId];
|
||
}
|
||
document.title = `Backfill Desk — ${viewTitles[viewId]}`;
|
||
frontendTrace("main", "Backfill Desk view DOM updated", { viewId, source });
|
||
}
|
||
|
||
function bindFrontendInteractions(): void {
|
||
document.addEventListener(
|
||
"click",
|
||
event => {
|
||
const source = event.target;
|
||
if (!(source instanceof Element)) {
|
||
return;
|
||
}
|
||
const control = source.closest<HTMLElement>('button, a, input, select, textarea, [role="button"], [role="tab"], [data-view]');
|
||
if (!control) {
|
||
return;
|
||
}
|
||
frontendTrace("main", "Backfill Desk frontend control clicked", {
|
||
controlId: control.id || null,
|
||
role: control.getAttribute("role"),
|
||
tagName: control.tagName.toLowerCase(),
|
||
viewId: control.dataset.view ?? null,
|
||
});
|
||
},
|
||
true,
|
||
);
|
||
frontendTrace("main", "Backfill Desk frontend control interaction logger installed");
|
||
}
|
||
|
||
function bindNavigation(): void {
|
||
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
const viewId = button.dataset.view;
|
||
frontendDebug("main", "Backfill Desk navigation tab clicked", { viewId: viewId ?? "unknown" });
|
||
if (isViewId(viewId)) {
|
||
activateView(viewId, "user");
|
||
}
|
||
});
|
||
});
|
||
frontendTrace("main", "Backfill Desk navigation handlers installed");
|
||
}
|
||
|
||
function populateCodeSelect(selectId: string, values: string[], labels: Record<string, string>): void {
|
||
const select = document.querySelector<HTMLSelectElement>(`#${selectId}`);
|
||
if (!select) {
|
||
return;
|
||
}
|
||
const previous = select.value;
|
||
select.replaceChildren();
|
||
for (const value of values) {
|
||
const option = document.createElement("option");
|
||
option.value = value;
|
||
option.textContent = labels[value] ?? value;
|
||
select.append(option);
|
||
}
|
||
select.disabled = values.length === 0;
|
||
if (values.includes(previous)) {
|
||
select.value = previous;
|
||
}
|
||
}
|
||
|
||
function applyNumberInputContract(inputId: string, maximum: number, defaultValue: number): void {
|
||
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||
if (!input) {
|
||
return;
|
||
}
|
||
input.min = "1";
|
||
input.max = maximum.toString();
|
||
if (input.value === "") {
|
||
input.value = defaultValue.toString();
|
||
}
|
||
}
|
||
|
||
function refreshStartButton(): void {
|
||
const startButton = document.querySelector<HTMLButtonElement>("#startBackfillRequest");
|
||
if (startButton) {
|
||
startButton.disabled = !compositionReadyForStart || runStartAccepted;
|
||
}
|
||
}
|
||
|
||
|
||
function renderProgramIdAutocomplete(options: ProgramIdAutocompleteOptionDto[]): void {
|
||
const datalist = document.querySelector<HTMLDataListElement>("#backfillProgramIds");
|
||
if (!datalist) {
|
||
return;
|
||
}
|
||
datalist.replaceChildren();
|
||
for (const program of options) {
|
||
const option = document.createElement("option");
|
||
option.value = program.programId;
|
||
option.label = `${program.name} — ${program.code}`;
|
||
option.dataset.code = program.code;
|
||
option.dataset.domain = program.domain;
|
||
option.dataset.family = program.family;
|
||
option.dataset.name = program.name;
|
||
option.dataset.protocol = program.protocol;
|
||
datalist.append(option);
|
||
}
|
||
const help = document.querySelector<HTMLElement>("#backfillProgramIdAutocompleteHelp");
|
||
if (help) {
|
||
help.textContent = `${options.length} Program ID KSP disponibles comme suggestions ; la saisie reste libre.`;
|
||
}
|
||
frontendTrace("main", "Backfill Desk Program ID autocomplete dataset rendered", { optionCount: options.length });
|
||
}
|
||
|
||
function selectedProgramAutocompleteEntry(address: string): HTMLOptionElement | null {
|
||
const datalist = document.querySelector<HTMLDataListElement>("#backfillProgramIds");
|
||
if (!datalist) {
|
||
return null;
|
||
}
|
||
for (const option of Array.from(datalist.options)) {
|
||
if (option.value === address) {
|
||
return option;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function safeCommandErrorText(caughtError: unknown, fallback: string): string {
|
||
if (typeof caughtError === "object" && caughtError !== null) {
|
||
const candidate = caughtError as { code?: unknown; domain?: unknown; message?: unknown };
|
||
if (typeof candidate.domain === "string" && typeof candidate.code === "string" && typeof candidate.message === "string") {
|
||
return `${candidate.domain}/${candidate.code}: ${candidate.message}`;
|
||
}
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function renderCampaignContract(options: BackfillDeskOptionsDto): void {
|
||
renderProgramIdAutocomplete(options.programIdOptions);
|
||
populateCodeSelect("backfillScopeKind", options.scopeKinds, scopeLabels);
|
||
populateCodeSelect("backfillCommitment", options.commitments, commitmentLabels);
|
||
applyNumberInputContract("backfillPageSize", options.limits.maxPageSize, options.limits.defaultPageSize);
|
||
applyNumberInputContract("backfillMaxPages", options.limits.maxPages, options.limits.defaultMaxPages);
|
||
applyNumberInputContract("backfillMaxCandidates", options.limits.maxCandidates, options.limits.defaultMaxCandidates);
|
||
applyNumberInputContract("backfillHydrationConcurrency", options.limits.maxHydrationConcurrency, options.limits.defaultHydrationConcurrency);
|
||
const signatureHelp = document.querySelector<HTMLElement>("#backfillExplicitSignaturesHelp");
|
||
if (signatureHelp) {
|
||
signatureHelp.textContent = `Une signature par ligne, ${options.limits.minSignatureTextBytes}–${options.limits.maxSignatureTextBytes} octets encodés ; maximum ${options.limits.maxCandidates}.`;
|
||
}
|
||
const validateButton = document.querySelector<HTMLButtonElement>("#validateBackfillRequest");
|
||
if (validateButton) {
|
||
validateButton.disabled = !options.compositionReady || options.httpRoutes.length === 0;
|
||
}
|
||
updateCampaignScopeFields();
|
||
frontendTrace("main", "Backfill Desk campaign contract rendered", {
|
||
commitmentCount: options.commitments.length,
|
||
compositionReady: options.compositionReady,
|
||
scopeCount: options.scopeKinds.length,
|
||
});
|
||
}
|
||
|
||
function currentScopeKind(): string {
|
||
const select = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
|
||
return select?.value ?? "";
|
||
}
|
||
|
||
function updateCampaignScopeFields(): void {
|
||
const scopeKind = currentScopeKind();
|
||
const addressGroup = document.querySelector<HTMLElement>("#backfillAddressGroup");
|
||
const anchorGroup = document.querySelector<HTMLElement>("#backfillAnchorGroup");
|
||
const signaturesGroup = document.querySelector<HTMLElement>("#backfillExplicitSignaturesGroup");
|
||
const minContextSlotGroup = document.querySelector<HTMLElement>("#backfillMinContextSlotGroup");
|
||
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
|
||
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
|
||
const explicitScope = scopeKind === "explicit_signatures";
|
||
if (addressGroup) {
|
||
addressGroup.hidden = !addressScope;
|
||
}
|
||
if (anchorGroup) {
|
||
anchorGroup.hidden = !anchoredScope;
|
||
}
|
||
if (signaturesGroup) {
|
||
signaturesGroup.hidden = !explicitScope;
|
||
}
|
||
if (minContextSlotGroup) {
|
||
minContextSlotGroup.hidden = explicitScope;
|
||
}
|
||
frontendTrace("main", "Backfill Desk campaign scope fields updated", { scopeKind: scopeKind || "none" });
|
||
}
|
||
|
||
function invalidateCampaignValidation(source: "input" | "scope" | "route"): void {
|
||
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||
if (status) {
|
||
status.hidden = true;
|
||
status.textContent = "";
|
||
status.classList.remove("alert-success", "alert-danger");
|
||
status.classList.add("alert-secondary");
|
||
}
|
||
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
|
||
if (summary) {
|
||
summary.hidden = true;
|
||
}
|
||
frontendTrace("main", "Backfill Desk campaign validation invalidated", { source });
|
||
}
|
||
|
||
function numericInputValue(inputId: string): number {
|
||
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||
if (!input || !Number.isSafeInteger(input.valueAsNumber) || input.valueAsNumber < 0) {
|
||
return 0;
|
||
}
|
||
return input.valueAsNumber;
|
||
}
|
||
|
||
function rawOptionalInput(inputId: string, enabled: boolean): string | null {
|
||
if (!enabled) {
|
||
return null;
|
||
}
|
||
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
|
||
if (!input || input.value === "") {
|
||
return null;
|
||
}
|
||
return input.value;
|
||
}
|
||
|
||
function explicitSignatureLines(): string[] {
|
||
const textarea = document.querySelector<HTMLTextAreaElement>("#backfillExplicitSignatures");
|
||
if (!textarea || currentScopeKind() !== "explicit_signatures") {
|
||
return [];
|
||
}
|
||
return textarea.value.split(/\r?\n/).filter(line => line.length > 0);
|
||
}
|
||
|
||
function buildBackfillStartRequest(): BackfillStartRequestDto {
|
||
const scopeKind = currentScopeKind();
|
||
const role = document.querySelector<HTMLSelectElement>("#backfillHttpRoute")?.value ?? "";
|
||
const commitment = document.querySelector<HTMLSelectElement>("#backfillCommitment")?.value ?? "";
|
||
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
|
||
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
|
||
const minContextSlotEnabled = scopeKind !== "explicit_signatures";
|
||
return {
|
||
address: rawOptionalInput("backfillAddress", addressScope),
|
||
anchorSignature: rawOptionalInput("backfillAnchorSignature", anchoredScope),
|
||
commitment,
|
||
explicitSignatures: explicitSignatureLines(),
|
||
hydrationConcurrency: numericInputValue("backfillHydrationConcurrency"),
|
||
httpRole: role,
|
||
maxCandidates: numericInputValue("backfillMaxCandidates"),
|
||
maxPages: numericInputValue("backfillMaxPages"),
|
||
minContextSlot: rawOptionalInput("backfillMinContextSlot", minContextSlotEnabled),
|
||
pageSize: numericInputValue("backfillPageSize"),
|
||
scopeKind,
|
||
};
|
||
}
|
||
|
||
function renderRequestPreview(preview: BackfillRequestPreviewDto): void {
|
||
const values: Record<string, string> = {
|
||
validatedNetwork: preview.network,
|
||
validatedHttpRole: preview.httpRole,
|
||
validatedCommitment: preview.commitment,
|
||
validatedScopeKind: preview.scopeKind,
|
||
validatedExplicitSignatureCount: preview.explicitSignatureCount.toString(),
|
||
validatedBounds: `${preview.pageSize} / ${preview.maxPages} / ${preview.maxCandidates} / ${preview.hydrationConcurrency}`,
|
||
validatedMinContextSlot: preview.minContextSlotPresent ? "présent" : "absent",
|
||
};
|
||
for (const [id, value] of Object.entries(values)) {
|
||
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||
if (element) {
|
||
element.textContent = value;
|
||
}
|
||
}
|
||
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
|
||
if (summary) {
|
||
summary.hidden = false;
|
||
}
|
||
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||
if (status) {
|
||
status.hidden = false;
|
||
status.classList.remove("alert-secondary", "alert-danger");
|
||
status.classList.add("alert-success");
|
||
status.textContent = "Requête valide. Aucun job n'a été démarré.";
|
||
}
|
||
frontendTrace("main", "Backfill Desk validated request preview rendered", {
|
||
commitment: preview.commitment,
|
||
httpRole: preview.httpRole,
|
||
scopeKind: preview.scopeKind,
|
||
});
|
||
}
|
||
|
||
function renderRequestValidationError(caughtError: unknown): void {
|
||
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||
if (!status) {
|
||
return;
|
||
}
|
||
const message = safeCommandErrorText(caughtError, "Requête refusée par le backend.");
|
||
status.hidden = false;
|
||
status.classList.remove("alert-secondary", "alert-success");
|
||
status.classList.add("alert-danger");
|
||
status.textContent = message;
|
||
}
|
||
|
||
async function startBackfillRequest(): Promise<void> {
|
||
const request = buildBackfillStartRequest();
|
||
frontendDebug("main", "Backfill Desk campaign Start requested", {
|
||
commitment: request.commitment,
|
||
httpRole: request.httpRole,
|
||
scopeKind: request.scopeKind,
|
||
});
|
||
try {
|
||
const started = await invokeKsp<BackfillStartResponseDto>("main", "backfill_start", { request });
|
||
runStartAccepted = true;
|
||
refreshStartButton();
|
||
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
|
||
if (status) {
|
||
status.hidden = false;
|
||
status.classList.remove("alert-secondary", "alert-danger");
|
||
status.classList.add("alert-success");
|
||
status.textContent = `Job ${started.jobId} démarré (${started.state}).`;
|
||
}
|
||
await syncBackfillStatus("start");
|
||
frontendInfo("main", "Backfill Desk campaign Start accepted", { jobId: started.jobId, state: started.state });
|
||
} catch (caughtError) {
|
||
renderRequestValidationError(caughtError);
|
||
frontendWarn("main", "Backfill Desk campaign Start failed");
|
||
}
|
||
}
|
||
|
||
async function validateBackfillRequest(): Promise<void> {
|
||
const request = buildBackfillStartRequest();
|
||
frontendDebug("main", "Backfill Desk campaign validation requested", {
|
||
commitment: request.commitment,
|
||
httpRole: request.httpRole,
|
||
scopeKind: request.scopeKind,
|
||
});
|
||
try {
|
||
const preview = await invokeKsp<BackfillRequestPreviewDto>("main", "backfill_validate_request", { request });
|
||
renderRequestPreview(preview);
|
||
frontendDebug("main", "Backfill Desk campaign validation completed", {
|
||
commitment: preview.commitment,
|
||
httpRole: preview.httpRole,
|
||
scopeKind: preview.scopeKind,
|
||
});
|
||
} catch (caughtError) {
|
||
renderRequestValidationError(caughtError);
|
||
frontendWarn("main", "Backfill Desk campaign validation failed");
|
||
}
|
||
}
|
||
|
||
function bindCampaignForm(): void {
|
||
const scope = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
|
||
if (scope) {
|
||
scope.addEventListener("change", () => {
|
||
frontendDebug("main", "Backfill Desk campaign scope changed", { scopeKind: scope.value || "none" });
|
||
updateCampaignScopeFields();
|
||
invalidateCampaignValidation("scope");
|
||
});
|
||
}
|
||
const form = document.querySelector<HTMLFormElement>("#backfillCampaignForm");
|
||
if (form) {
|
||
form.addEventListener("input", () => invalidateCampaignValidation("input"));
|
||
form.addEventListener("submit", event => {
|
||
event.preventDefault();
|
||
void validateBackfillRequest();
|
||
});
|
||
}
|
||
const address = document.querySelector<HTMLInputElement>("#backfillAddress");
|
||
if (address) {
|
||
address.addEventListener("change", () => {
|
||
const selected = selectedProgramAutocompleteEntry(address.value);
|
||
frontendTrace("main", "Backfill Desk address autocomplete selection evaluated", {
|
||
programCode: selected?.dataset.code ?? null,
|
||
registryMatch: selected !== null,
|
||
});
|
||
});
|
||
}
|
||
const startButton = document.querySelector<HTMLButtonElement>("#startBackfillRequest");
|
||
if (startButton) {
|
||
startButton.addEventListener("click", () => {
|
||
void startBackfillRequest();
|
||
});
|
||
}
|
||
frontendTrace("main", "Backfill Desk campaign form handlers installed");
|
||
}
|
||
|
||
function routeLabel(route: BackfillHttpRouteOptionDto): string {
|
||
if (route.pooled) {
|
||
return `Pool multi-provider — ${route.providers.join(" + ")}`;
|
||
}
|
||
return route.providers.length === 1 ? `Provider — ${route.providers[0]}` : route.role;
|
||
}
|
||
|
||
function renderBackfillOptions(options: BackfillDeskOptionsDto): void {
|
||
compositionReadyForStart = options.compositionReady;
|
||
renderCampaignContract(options);
|
||
refreshStartButton();
|
||
const select = document.querySelector<HTMLSelectElement>("#backfillHttpRoute");
|
||
if (select) {
|
||
const previous = select.value;
|
||
select.replaceChildren();
|
||
for (const route of options.httpRoutes) {
|
||
const option = document.createElement("option");
|
||
option.value = route.role;
|
||
option.textContent = routeLabel(route);
|
||
select.append(option);
|
||
}
|
||
select.disabled = !options.transportReady || options.httpRoutes.length === 0;
|
||
const reusable = options.httpRoutes.some(route => route.role === previous);
|
||
if (reusable) {
|
||
select.value = previous;
|
||
}
|
||
}
|
||
const values: Record<string, string> = {
|
||
backfillNetwork: options.configuredNetworks.length === 1 ? options.configuredNetworks[0] : options.configuredNetworks.join(", ") || "indisponible",
|
||
backfillTransportReady: options.transportReady ? "ready" : "non ready",
|
||
backfillStoreReady: options.storeReady ? "ready" : "non ready",
|
||
backfillCompositionReady: options.compositionReady ? "ready" : "non ready",
|
||
};
|
||
for (const [id, value] of Object.entries(values)) {
|
||
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||
if (element) {
|
||
element.textContent = value;
|
||
}
|
||
}
|
||
const diagnostic = document.querySelector<HTMLElement>("#backfillOptionsDiagnostic");
|
||
if (diagnostic) {
|
||
const error = options.transportDiagnostic ?? options.storeDiagnostic;
|
||
diagnostic.hidden = error === null;
|
||
diagnostic.textContent = error === null ? "" : `${error.domain}/${error.code}: ${error.message}`;
|
||
}
|
||
frontendTrace("main", "Backfill Desk HTTP route options rendered", {
|
||
compositionReady: options.compositionReady,
|
||
routeCount: options.httpRoutes.length,
|
||
transportReady: options.transportReady,
|
||
});
|
||
}
|
||
|
||
async function loadBackfillOptions(source: "startup" | "user"): Promise<void> {
|
||
frontendDebug("main", "Backfill Desk HTTP route options load started", { source });
|
||
const options = await invokeKsp<BackfillDeskOptionsDto>("main", "backfill_options");
|
||
renderBackfillOptions(options);
|
||
frontendDebug("main", "Backfill Desk HTTP route options load completed", { source, routeCount: options.httpRoutes.length });
|
||
}
|
||
|
||
function bindBackfillRouteSelection(): void {
|
||
const select = document.querySelector<HTMLSelectElement>("#backfillHttpRoute");
|
||
if (!select) {
|
||
return;
|
||
}
|
||
select.addEventListener("change", () => {
|
||
frontendDebug("main", "Backfill Desk HTTP route selection changed", { role: select.value || "none" });
|
||
invalidateCampaignValidation("route");
|
||
});
|
||
frontendTrace("main", "Backfill Desk HTTP route selection handler installed");
|
||
}
|
||
|
||
|
||
function renderBackfillRunStatus(status: BackfillRunStatusDto | null, source: "cancel" | "event" | "resume" | "startup" | "start" | "user"): void {
|
||
const card = document.querySelector<HTMLElement>("#backfillRunStatusCard");
|
||
const cancelButton = document.querySelector<HTMLButtonElement>("#cancelBackfillRun");
|
||
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||
currentRunStatus = status;
|
||
if (!status) {
|
||
runStartAccepted = false;
|
||
if (cancelButton) {
|
||
cancelButton.disabled = true;
|
||
}
|
||
if (resumeButton) {
|
||
resumeButton.disabled = true;
|
||
}
|
||
resumeInFlight = false;
|
||
refreshStartButton();
|
||
if (card) {
|
||
card.hidden = true;
|
||
}
|
||
const terminalSummary = document.querySelector<HTMLElement>("#backfillTerminalSummary");
|
||
if (terminalSummary) {
|
||
terminalSummary.hidden = true;
|
||
terminalSummary.textContent = "";
|
||
}
|
||
frontendTrace("main", "Backfill Desk latest-value status cleared", { source });
|
||
return;
|
||
}
|
||
runStartAccepted = status.active;
|
||
if (cancelButton) {
|
||
cancelButton.disabled = !status.active;
|
||
}
|
||
if (status.active) {
|
||
resumeInFlight = false;
|
||
}
|
||
if (resumeButton) {
|
||
resumeButton.disabled = !status.terminal || !status.checkpointPresent || resumeInFlight;
|
||
}
|
||
refreshStartButton();
|
||
if (card) {
|
||
card.hidden = false;
|
||
}
|
||
const terminalSummary = document.querySelector<HTMLElement>("#backfillTerminalSummary");
|
||
if (terminalSummary) {
|
||
terminalSummary.hidden = !status.terminal;
|
||
terminalSummary.classList.remove("alert-success", "alert-warning", "alert-danger", "alert-secondary");
|
||
if (status.terminal) {
|
||
const failure = status.failureDomain === null || status.failureCode === null ? null : `${status.failureDomain}/${status.failureCode}`;
|
||
if (status.state === "completed") {
|
||
terminalSummary.classList.add("alert-success");
|
||
terminalSummary.textContent = `Campagne terminée — ${status.contiguousCompleted} candidats contigus durablement traités.`;
|
||
} else if (status.state === "cancelled") {
|
||
terminalSummary.classList.add("alert-warning");
|
||
terminalSummary.textContent = `Campagne annulée coopérativement — checkpoint ${status.checkpointPresent ? "disponible" : "absent"}.`;
|
||
} else {
|
||
terminalSummary.classList.add("alert-danger");
|
||
terminalSummary.textContent = `Campagne terminée en échec${failure === null ? "" : ` — ${failure}`}.`;
|
||
}
|
||
} else {
|
||
terminalSummary.classList.add("alert-secondary");
|
||
terminalSummary.textContent = "";
|
||
}
|
||
}
|
||
const values: Record<string, string> = {
|
||
runStatusJobId: status.jobId,
|
||
runStatusLifecycle: status.completion === null ? status.state : `${status.state} / ${status.completion}`,
|
||
runStatusPhase: status.phase,
|
||
runStatusSequence: status.sequence,
|
||
runStatusScope: status.scopeKind,
|
||
runStatusBoundary: status.discoveryBoundary ?? "—",
|
||
runStatusCandidates: `${status.candidatesSelected} / ${status.candidatesAdmitted} / ${status.candidatesFinished}`,
|
||
runStatusFrontier: `${status.contiguousCompleted} (holes ${status.holes})`,
|
||
runStatusEntities: `${status.entitiesInserted} / ${status.entitiesExisting} / ${status.entitiesPurged}`,
|
||
runStatusObservations: `${status.observationsInserted} / ${status.observationsExisting}`,
|
||
runStatusAnomalies: `missing ${status.missing} / conflits ${status.conflicts} / annulés ${status.cancelledCandidates}`,
|
||
runStatusConcurrency: status.maximumInFlight.toString(),
|
||
runStatusCheckpoint: status.checkpointPresent ? "présent" : "absent",
|
||
runStatusFailure: status.failureDomain === null || status.failureCode === null ? "—" : `${status.failureDomain}/${status.failureCode}`,
|
||
};
|
||
for (const [id, value] of Object.entries(values)) {
|
||
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||
if (element) {
|
||
element.textContent = value;
|
||
}
|
||
}
|
||
frontendTrace("main", "Backfill Desk latest-value status rendered", {
|
||
active: status.active,
|
||
phase: status.phase,
|
||
sequence: status.sequence,
|
||
source,
|
||
state: status.state,
|
||
terminal: status.terminal,
|
||
});
|
||
}
|
||
|
||
async function syncBackfillStatus(source: "cancel" | "resume" | "startup" | "start" | "user"): Promise<void> {
|
||
frontendDebug("main", "Backfill Desk latest-value status resynchronization started", { source });
|
||
const status = await invokeKsp<BackfillRunStatusDto | null>("main", "backfill_status");
|
||
renderBackfillRunStatus(status, source);
|
||
frontendDebug("main", "Backfill Desk latest-value status resynchronization completed", { source, statusPresent: status !== null });
|
||
}
|
||
|
||
async function resumeBackfillRun(): Promise<void> {
|
||
const status = currentRunStatus;
|
||
if (!status || !status.terminal || !status.checkpointPresent || resumeInFlight) {
|
||
return;
|
||
}
|
||
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||
resumeInFlight = true;
|
||
if (resumeButton) {
|
||
resumeButton.disabled = true;
|
||
}
|
||
frontendDebug("main", "Backfill Desk in-session Resume requested", { jobId: status.jobId, state: status.state });
|
||
try {
|
||
const response = await invokeKsp<BackfillResumeResponseDto>("main", "backfill_resume");
|
||
const feedback = document.querySelector<HTMLElement>("#backfillResumeFeedback");
|
||
if (feedback) {
|
||
feedback.hidden = false;
|
||
feedback.textContent = `Checkpoint repris dans ${response.jobId} (${response.state}).`;
|
||
}
|
||
frontendInfo("main", "Backfill Desk in-session Resume accepted", {
|
||
accepted: response.accepted,
|
||
jobId: response.jobId,
|
||
state: response.state,
|
||
});
|
||
await syncBackfillStatus("resume");
|
||
} catch (caughtError) {
|
||
resumeInFlight = false;
|
||
if (resumeButton && currentRunStatus?.terminal && currentRunStatus.checkpointPresent) {
|
||
resumeButton.disabled = false;
|
||
}
|
||
const feedback = document.querySelector<HTMLElement>("#backfillResumeFeedback");
|
||
if (feedback) {
|
||
feedback.hidden = false;
|
||
feedback.classList.remove("alert-secondary", "alert-success");
|
||
feedback.classList.add("alert-danger");
|
||
feedback.textContent = safeCommandErrorText(caughtError, "La reprise du checkpoint a été refusée.");
|
||
}
|
||
frontendWarn("main", "Backfill Desk in-session Resume failed");
|
||
}
|
||
}
|
||
|
||
async function bindBackfillStatusMonitoring(): Promise<void> {
|
||
await listen<BackfillRunStatusDto>("ksp-backfill-status", event => {
|
||
renderBackfillRunStatus(event.payload, "event");
|
||
});
|
||
const button = document.querySelector<HTMLButtonElement>("#refreshBackfillStatus");
|
||
if (button) {
|
||
button.addEventListener("click", () => {
|
||
frontendDebug("main", "Backfill Desk latest-value status refresh button clicked");
|
||
void syncBackfillStatus("user").catch(() => frontendWarn("main", "Backfill Desk latest-value status refresh failed"));
|
||
});
|
||
}
|
||
const cancelButton = document.querySelector<HTMLButtonElement>("#cancelBackfillRun");
|
||
if (cancelButton) {
|
||
cancelButton.addEventListener("click", () => {
|
||
const status = currentRunStatus;
|
||
if (!status || !status.active) {
|
||
return;
|
||
}
|
||
cancelButton.disabled = true;
|
||
frontendDebug("main", "Backfill Desk cooperative cancellation requested", { jobId: status.jobId, state: status.state });
|
||
void invokeKsp<BackfillCancelResponseDto>("main", "backfill_cancel", { jobId: status.jobId })
|
||
.then(response => {
|
||
const feedback = document.querySelector<HTMLElement>("#backfillCancelFeedback");
|
||
if (feedback) {
|
||
feedback.hidden = false;
|
||
feedback.textContent = response.accepted
|
||
? `Annulation coopérative acceptée pour ${response.jobId}.`
|
||
: `Aucune nouvelle annulation acceptée pour ${response.jobId} (${response.state}).`;
|
||
}
|
||
frontendInfo("main", "Backfill Desk cooperative cancellation response received", {
|
||
accepted: response.accepted,
|
||
jobId: response.jobId,
|
||
state: response.state,
|
||
});
|
||
return syncBackfillStatus("cancel");
|
||
})
|
||
.catch(caughtError => {
|
||
const feedback = document.querySelector<HTMLElement>("#backfillCancelFeedback");
|
||
if (feedback) {
|
||
feedback.hidden = false;
|
||
feedback.classList.remove("alert-secondary", "alert-success");
|
||
feedback.classList.add("alert-danger");
|
||
feedback.textContent = safeCommandErrorText(caughtError, "La demande d'annulation a été refusée.");
|
||
}
|
||
frontendWarn("main", "Backfill Desk cooperative cancellation failed");
|
||
if (currentRunStatus?.active) {
|
||
cancelButton.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
const resumeButton = document.querySelector<HTMLButtonElement>("#resumeBackfillRun");
|
||
if (resumeButton) {
|
||
resumeButton.addEventListener("click", () => {
|
||
void resumeBackfillRun();
|
||
});
|
||
}
|
||
frontendTrace("main", "Backfill Desk latest-value monitoring, cancellation and Resume handlers installed");
|
||
}
|
||
|
||
function renderRuntimeStatus(status: ShellStatusDto): void {
|
||
const values: Record<string, string> = {
|
||
runtimeVersion: status.applicationVersion,
|
||
runtimeShellPhase: status.shellPhase,
|
||
runtimeConfigDocuments: status.configDocumentCount.toString(),
|
||
runtimeLoggingProfile: status.activeLoggingProfile ?? "fallback transitoire",
|
||
runtimeLoggingFallback: status.fallbackLoggingActive ? "oui" : "non",
|
||
};
|
||
for (const [id, value] of Object.entries(values)) {
|
||
const element = document.querySelector<HTMLElement>(`#${id}`);
|
||
if (element) {
|
||
element.textContent = value;
|
||
}
|
||
}
|
||
const badge = document.querySelector<HTMLElement>("#appVersionBadge");
|
||
if (badge) {
|
||
badge.textContent = status.applicationVersion;
|
||
}
|
||
const diagnostic = document.querySelector<HTMLElement>("#runtimeDiagnostic");
|
||
if (diagnostic) {
|
||
diagnostic.hidden = status.startupDiagnostic === null;
|
||
diagnostic.textContent = status.startupDiagnostic === null ? "" : `${status.startupDiagnostic.domain}/${status.startupDiagnostic.code}: ${status.startupDiagnostic.message}`;
|
||
}
|
||
frontendTrace("main", "Backfill Desk runtime status rendered", {
|
||
fallbackLoggingActive: status.fallbackLoggingActive,
|
||
configDocumentCount: status.configDocumentCount,
|
||
});
|
||
}
|
||
|
||
async function loadRuntimeStatus(source: "startup" | "user"): Promise<void> {
|
||
frontendDebug("main", "Backfill Desk runtime status load started", { source });
|
||
const status = await invokeKsp<ShellStatusDto>("main", "get_runtime_status");
|
||
renderRuntimeStatus(status);
|
||
frontendDebug("main", "Backfill Desk runtime status load completed", { source });
|
||
}
|
||
|
||
function bindRuntimeStatusRefresh(): void {
|
||
const button = document.querySelector<HTMLButtonElement>("#refreshRuntimeStatus");
|
||
if (!button) {
|
||
return;
|
||
}
|
||
button.addEventListener("click", () => {
|
||
frontendDebug("main", "Backfill Desk runtime status refresh button clicked");
|
||
void loadRuntimeStatus("user").catch(() => frontendWarn("main", "Backfill Desk runtime status refresh failed"));
|
||
});
|
||
frontendTrace("main", "Backfill Desk runtime status refresh handler installed");
|
||
}
|
||
|
||
async function initializeMain(): Promise<void> {
|
||
const windowLabel = getCurrentWindow().label;
|
||
frontendInfo("main", "Backfill Desk main frontend loaded", { windowLabel });
|
||
bindFrontendInteractions();
|
||
bindNavigation();
|
||
bindRuntimeStatusRefresh();
|
||
bindBackfillRouteSelection();
|
||
bindCampaignForm();
|
||
await bindBackfillStatusMonitoring();
|
||
activateView("backfill", "startup");
|
||
try {
|
||
await Promise.all([loadRuntimeStatus("startup"), loadBackfillOptions("startup"), syncBackfillStatus("startup")]);
|
||
} catch {
|
||
frontendWarn("main", "Backfill Desk startup runtime status load failed");
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
void initializeMain();
|
||
});
|