Add real-time file synchronization with watchdog, SSE notifications, and dynamic vault management API
This commit is contained in:
+285
@@ -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()]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user