Add real-time file synchronization with watchdog, SSE notifications, and dynamic vault management API

This commit is contained in:
2026-03-23 14:16:45 -04:00
parent d8e5d0ef57
commit 757b72c549
9 changed files with 1365 additions and 8 deletions
+285
View File
@@ -1988,6 +1988,11 @@
_setField("cfg-title-boost", data.title_boost);
_setField("cfg-tag-boost", data.tag_boost);
_setField("cfg-prefix-exp", data.prefix_max_expansions);
// Watcher config
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
_setField("cfg-watcher-interval", data.watcher_polling_interval || 5);
_setField("cfg-watcher-debounce", data.watcher_debounce || 2);
} catch (err) {
console.error("Failed to load backend config:", err);
}
@@ -1998,6 +2003,16 @@
if (el && value !== undefined) el.value = value;
}
function _setCheckbox(id, checked) {
const el = document.getElementById(id);
if (el) el.checked = !!checked;
}
function _getCheckbox(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function _getFieldNum(id, fallback) {
const el = document.getElementById(id);
if (!el) return fallback;
@@ -2023,6 +2038,10 @@
title_boost: _getFieldNum("cfg-title-boost", 3.0),
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0),
};
try {
await fetch("/api/config", {
@@ -3156,6 +3175,271 @@
}, { passive: false });
}
// ---------------------------------------------------------------------------
// SSE Client — IndexUpdateManager
// ---------------------------------------------------------------------------
const IndexUpdateManager = (() => {
let eventSource = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let recentEvents = [];
const MAX_RECENT_EVENTS = 20;
let connectionState = "disconnected"; // disconnected | connecting | connected
function connect() {
if (eventSource) {
eventSource.close();
}
connectionState = "connecting";
_updateBadge();
eventSource = new EventSource("/api/events");
eventSource.addEventListener("connected", (e) => {
connectionState = "connected";
reconnectDelay = 1000;
_updateBadge();
});
eventSource.addEventListener("index_updated", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_updated", data);
_onIndexUpdated(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_reloaded", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_reloaded", data);
_onIndexReloaded(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_added", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_added", data);
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_removed", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_removed", data);
showToast(`Vault "${data.vault}" supprimé`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
eventSource.close();
eventSource = null;
_scheduleReconnect();
};
}
function _scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function _addEvent(type, data) {
recentEvents.unshift({
type,
data,
timestamp: new Date().toISOString(),
});
if (recentEvents.length > MAX_RECENT_EVENTS) {
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
}
}
async function _onIndexUpdated(data) {
// Brief syncing state
connectionState = "syncing";
_updateBadge();
const n = data.total_changes || 0;
const vaults = (data.vaults || []).join(", ");
showToast(`${n} fichier(s) mis à jour (${vaults})`, "info");
// Refresh sidebar and tags if affected vault matches current context
const affectsCurrentVault = selectedContextVault === "all" ||
(data.vaults || []).includes(selectedContextVault);
if (affectsCurrentVault) {
try {
await Promise.all([loadVaults(), loadTags()]);
// Refresh current file if it was updated
if (currentVault && currentPath) {
const changed = (data.changes || []).some(
(c) => c.vault === currentVault && c.path === currentPath
);
if (changed) {
openFile(currentVault, currentPath);
}
}
} catch (err) {
console.error("Error refreshing after index update:", err);
}
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
async function _onIndexReloaded(data) {
connectionState = "syncing";
_updateBadge();
showToast("Index complet rechargé", "info");
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Error refreshing after full reload:", err);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
function _updateBadge() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.className = "sync-badge sync-badge--" + connectionState;
const labels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Synchronisé",
syncing: "Mise à jour...",
};
badge.title = labels[connectionState] || connectionState;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
connectionState = "disconnected";
_updateBadge();
}
function getState() {
return connectionState;
}
function getRecentEvents() {
return recentEvents;
}
return { connect, disconnect, getState, getRecentEvents };
})();
function initSyncStatus() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.addEventListener("click", (e) => {
e.stopPropagation();
toggleSyncPanel();
});
IndexUpdateManager.connect();
}
function toggleSyncPanel() {
let panel = document.getElementById("sync-panel");
if (panel) {
panel.remove();
return;
}
panel = document.createElement("div");
panel.id = "sync-panel";
panel.className = "sync-panel";
_renderSyncPanel(panel);
document.body.appendChild(panel);
// Close on outside click
setTimeout(() => {
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
}, 0);
}
function _closeSyncPanelOutside(e) {
const panel = document.getElementById("sync-panel");
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
panel.remove();
}
}
function _renderSyncPanel(panel) {
const state = IndexUpdateManager.getState();
const events = IndexUpdateManager.getRecentEvents();
const stateLabels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Connecté",
syncing: "Synchronisation...",
};
let html = `<div class="sync-panel__header">
<span class="sync-panel__title">Synchronisation</span>
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
</div>`;
if (events.length === 0) {
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
} else {
html += `<div class="sync-panel__events">`;
events.slice(0, 10).forEach((ev) => {
const time = new Date(ev.timestamp).toLocaleTimeString();
const typeLabels = {
index_updated: "Mise à jour",
index_reloaded: "Rechargement",
vault_added: "Vault ajouté",
vault_removed: "Vault supprimé",
};
const label = typeLabels[ev.type] || ev.type;
const detail = ev.data.vaults ? ev.data.vaults.join(", ") : (ev.data.vault || "");
html += `<div class="sync-panel__event">
<span class="sync-panel__event-type">${label}</span>
<span class="sync-panel__event-detail">${detail}</span>
<span class="sync-panel__event-time">${time}</span>
</div>`;
});
html += `</div>`;
}
panel.innerHTML = html;
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -3175,6 +3459,7 @@
initSidebarFilter();
initSidebarResize();
initEditor();
initSyncStatus();
try {
await Promise.all([loadVaults(), loadTags()]);
+34
View File
@@ -88,6 +88,7 @@
<i data-lucide="book-open" style="width:20px;height:20px"></i>
ObsiGate
</div>
<span class="sync-badge sync-badge--disconnected" id="sync-badge" title="Synchronisation"></span>
</div>
<div class="header-center">
@@ -387,6 +388,39 @@
</div>
</section>
<!-- Watcher / Synchronisation -->
<section class="config-section">
<h2>Synchronisation automatique</h2>
<p class="config-description">Surveillance des fichiers en temps réel via watchdog. Les modifications sont détectées automatiquement et l'index est mis à jour sans redémarrage.</p>
<div class="config-row">
<label class="config-label" for="cfg-watcher-enabled">Activer la surveillance</label>
<label class="config-toggle">
<input type="checkbox" id="cfg-watcher-enabled" checked>
<span class="config-toggle-slider"></span>
</label>
<span class="config-hint">Activer/désactiver la surveillance automatique des fichiers</span>
</div>
<div class="config-row">
<label class="config-label" for="cfg-watcher-polling">Mode polling (fallback)</label>
<label class="config-toggle">
<input type="checkbox" id="cfg-watcher-polling">
<span class="config-toggle-slider"></span>
</label>
<span class="config-hint">Forcer le mode polling au lieu de inotify natif (utile si le mode natif ne fonctionne pas)</span>
</div>
<div class="config-row">
<label class="config-label" for="cfg-watcher-interval">Intervalle polling (s)</label>
<input type="number" id="cfg-watcher-interval" class="config-input config-input--num" min="1" max="30" step="1" value="5">
<span class="config-hint">Intervalle de scrutation en mode polling (1–30 secondes)</span>
</div>
<div class="config-row">
<label class="config-label" for="cfg-watcher-debounce">Debounce (s)</label>
<input type="number" id="cfg-watcher-debounce" class="config-input config-input--num" min="0.5" max="10" step="0.5" value="2">
<span class="config-hint">Délai avant traitement des changements groupés (0.5–10 secondes)</span>
</div>
</section>
<!-- Diagnostics -->
<section class="config-section">
<h2>Diagnostics</h2>
+183
View File
@@ -2775,3 +2775,186 @@ body.resizing-v {
max-width: 150px;
}
}
/* ---------------------------------------------------------------------------
Sync Badge — real-time connection status indicator
--------------------------------------------------------------------------- */
.sync-badge {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 8px;
cursor: pointer;
transition: background 300ms, box-shadow 300ms;
flex-shrink: 0;
vertical-align: middle;
}
.sync-badge--disconnected {
background: #ef4444;
box-shadow: 0 0 0 2px rgba(239,68,68,0.25);
}
.sync-badge--connecting {
background: #f59e0b;
box-shadow: 0 0 0 2px rgba(245,158,11,0.25);
animation: sync-pulse 1.5s ease-in-out infinite;
}
.sync-badge--connected {
background: #22c55e;
box-shadow: 0 0 0 2px rgba(34,197,94,0.2);
}
.sync-badge--syncing {
background: #3b82f6;
box-shadow: 0 0 0 2px rgba(59,130,246,0.3);
animation: sync-pulse 0.8s ease-in-out infinite;
}
@keyframes sync-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ---------------------------------------------------------------------------
Sync Panel — event log overlay
--------------------------------------------------------------------------- */
.sync-panel {
position: fixed;
top: 52px;
left: 16px;
width: 340px;
max-height: 400px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
z-index: 9999;
overflow: hidden;
font-size: 0.82rem;
}
.sync-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.sync-panel__title {
font-weight: 600;
color: var(--text-primary);
}
.sync-panel__state {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 8px;
font-weight: 500;
}
.sync-panel__state--connected {
background: rgba(34,197,94,0.15);
color: #22c55e;
}
.sync-panel__state--disconnected {
background: rgba(239,68,68,0.15);
color: #ef4444;
}
.sync-panel__state--connecting {
background: rgba(245,158,11,0.15);
color: #f59e0b;
}
.sync-panel__state--syncing {
background: rgba(59,130,246,0.15);
color: #3b82f6;
}
.sync-panel__empty {
padding: 20px 14px;
text-align: center;
color: var(--text-muted);
}
.sync-panel__events {
max-height: 320px;
overflow-y: auto;
padding: 4px 0;
}
.sync-panel__event {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.sync-panel__event:last-child {
border-bottom: none;
}
.sync-panel__event-type {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.sync-panel__event-detail {
color: var(--text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sync-panel__event-time {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ---------------------------------------------------------------------------
Toast — info variant (blue)
--------------------------------------------------------------------------- */
.toast.toast-info {
background: #1e3a5f;
border-left: 4px solid #3b82f6;
color: #bfdbfe;
}
[data-theme="light"] .toast.toast-info {
background: #eff6ff;
border-left: 4px solid #3b82f6;
color: #1e40af;
}
/* ---------------------------------------------------------------------------
Config Toggle Switch
--------------------------------------------------------------------------- */
.config-toggle {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.config-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.config-toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 22px;
transition: background 200ms, border-color 200ms;
}
.config-toggle-slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: var(--text-muted);
border-radius: 50%;
transition: transform 200ms, background 200ms;
}
.config-toggle input:checked + .config-toggle-slider {
background: rgba(34,197,94,0.2);
border-color: #22c55e;
}
.config-toggle input:checked + .config-toggle-slider::before {
transform: translateX(18px);
background: #22c55e;
}