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();
+4 -4
View File
@@ -157,7 +157,7 @@
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation des vaults">
<div class="sidebar-tree" id="sidebar-tree">
<!-- Sidebar filter -->
<div class="sidebar-filter">
@@ -190,7 +190,7 @@
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
</button>
<div class="sidebar-panel-content" id="vault-panel-content">
<div id="vault-tree"></div>
<div id="vault-tree" role="tree" aria-label="Arborescence des fichiers"></div>
</div>
</div>
@@ -212,8 +212,8 @@
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
<!-- Content -->
<main class="content-area" id="content-area">
<div class="welcome" id="welcome">
<main class="content-area" id="content-area" aria-label="Contenu principal">
<div class="welcome" id="welcome" role="status">
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
<h2>ObsiGate</h2>
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
+62 -2
View File
@@ -26,6 +26,10 @@
--scrollbar: #30363d;
--resize-handle: #30363d;
--overlay-bg: rgba(0,0,0,0.5);
--danger: #ff7b72;
--danger-bg: #3d1a18;
--success: #3fb950;
--success-bg: #1a3d1f;
}
/* ===== THEME — LIGHT ===== */
@@ -47,6 +51,10 @@
--scrollbar: #d0d7de;
--resize-handle: #d0d7de;
--overlay-bg: rgba(0,0,0,0.3);
--danger: #cf222e;
--danger-bg: #ffebe9;
--success: #1a7f37;
--success-bg: #dafbe1;
}
/* ===== BASE ===== */
@@ -1382,8 +1390,8 @@ select {
opacity: 0.9;
}
.editor-btn.danger:hover {
color: #ff7b72;
border-color: #ff7b72;
color: var(--danger);
border-color: var(--danger);
}
.editor-body {
flex: 1;
@@ -2059,3 +2067,55 @@ body.resizing-v {
border: none;
padding: 0;
}
/* --- Toast notifications --- */
.toast-container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 10px 20px;
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
opacity: 0;
transform: translateY(12px);
transition: opacity 250ms ease, transform 250ms ease;
pointer-events: auto;
max-width: 420px;
text-align: center;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-error {
border-color: var(--danger);
background: var(--danger-bg);
color: var(--danger);
}
.toast-success {
border-color: var(--success);
background: var(--success-bg);
color: var(--success);
}
/* --- Tree loading indicator --- */
.tree-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
}