Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics

This commit is contained in:
2026-03-22 19:03:34 -04:00
parent d9add8dcba
commit d311a09527
11 changed files with 1097 additions and 182 deletions
+111 -11
View File
@@ -9,6 +9,7 @@
let currentVault = null;
let currentPath = null;
let searchTimeout = null;
let searchAbortController = null;
let showingSource = false;
let cachedRawSource = null;
let allVaults = [];
@@ -20,6 +21,7 @@
let fallbackEditorEl = null;
let sidebarFilterCaseSensitive = false;
let searchCaseSensitive = false;
let _iconDebounceTimer = null;
const panelState = {
vault: true,
tag: true,
@@ -78,7 +80,26 @@
// ---------------------------------------------------------------------------
// Safe CDN helpers
// ---------------------------------------------------------------------------
/**
* Debounced icon creation — batches multiple rapid calls into one
* DOM scan to avoid excessive reflows when building large trees.
*/
function safeCreateIcons() {
if (typeof lucide === "undefined" || !lucide.createIcons) return;
if (_iconDebounceTimer) return; // already scheduled
_iconDebounceTimer = requestAnimationFrame(() => {
_iconDebounceTimer = null;
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
});
}
/** Force-flush icon creation immediately (use sparingly). */
function flushIcons() {
if (_iconDebounceTimer) {
cancelAnimationFrame(_iconDebounceTimer);
_iconDebounceTimer = null;
}
if (typeof lucide !== "undefined" && lucide.createIcons) {
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
}
@@ -292,12 +313,60 @@
});
}
// ---------------------------------------------------------------------------
// Toast notifications
// ---------------------------------------------------------------------------
/** Display a brief toast message at the bottom of the viewport. */
function showToast(message, type) {
type = type || "error";
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container";
container.setAttribute("aria-live", "polite");
document.body.appendChild(container);
}
var toast = document.createElement("div");
toast.className = "toast toast-" + type;
toast.textContent = message;
container.appendChild(toast);
// Trigger entrance animation
requestAnimationFrame(function () { toast.classList.add("show"); });
setTimeout(function () {
toast.classList.remove("show");
toast.addEventListener("transitionend", function () { toast.remove(); });
}, 3500);
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`API error: ${res.status}`);
/**
* Fetch JSON from an API endpoint with optional AbortSignal support.
* Surfaces errors to the user via toast instead of silently failing.
*
* @param {string} path - API URL path.
* @param {object} [opts] - Fetch options (may include signal).
* @returns {Promise<any>} Parsed JSON response.
*/
async function api(path, opts) {
var res;
try {
res = await fetch(path, opts || {});
} catch (err) {
if (err.name === "AbortError") throw err; // let callers handle abort
showToast("Erreur réseau — vérifiez votre connexion");
throw err;
}
if (!res.ok) {
var detail = "";
try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ }
showToast(detail || "Erreur API : " + res.status);
throw new Error(detail || "API error: " + res.status);
}
return res.json();
}
@@ -564,8 +633,17 @@
}
async function loadDirectory(vaultName, dirPath, container) {
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
const data = await api(url);
// Show inline loading indicator while fetching directory contents
container.innerHTML = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
var data;
try {
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
data = await api(url);
} catch (err) {
container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
return;
}
container.innerHTML = "";
const fragment = document.createDocumentFragment();
@@ -905,9 +983,17 @@
if (active) active.classList.add("active");
} catch (e) { /* selector might fail on special chars */ }
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
renderFile(data);
// Show loading state while fetching
const area = document.getElementById("content-area");
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
try {
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
renderFile(data);
} catch (err) {
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
}
}
function renderFile(data) {
@@ -1324,13 +1410,26 @@
}
async function performSearch(query, vaultFilter, tagFilter) {
// Cancel any in-flight search request
if (searchAbortController) {
searchAbortController.abort();
}
searchAbortController = new AbortController();
showLoading();
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
const data = await api(url);
renderSearchResults(data, query, tagFilter);
try {
const data = await api(url, { signal: searchAbortController.signal });
renderSearchResults(data, query, tagFilter);
} catch (err) {
if (err.name === "AbortError") return; // superseded by newer request
showWelcome();
} finally {
searchAbortController = null;
}
}
function renderSearchResults(data, query, tagFilter) {
@@ -1764,6 +1863,7 @@
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
showToast("Erreur lors de l'initialisation");
}
safeCreateIcons();