Add settings nav tree, Logs viewer, and About page (Voicebox-style hierarchy)

- Sidebar: Settings → nav-tree-head with sub-items (General, Connections,
  Playback, Payloads, Storage, API Keys, Backup, Logs, About)
- nav.js: navSettingsCat() scrolls to section, expands tree on activate
- General: theme select synced with applyTheme, surfaces dark/light toggle
- Logs: /api/logs endpoint (300-entry circular buffer), refresh/clear/
  auto-refresh every 3 s, level filters (All/Error/Warning/Info)
- About: backend availability chips from _ttsBackends, tech stack tags
- server.py: _BufferHandler attaches to root logger, /api/logs GET+DELETE
- Fix duplicate toast on save, guard removed settings-btn reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 03:05:15 +02:00
parent f871486635
commit cd770801dc
6 changed files with 289 additions and 34 deletions

View File

@ -105,6 +105,29 @@ _registry: dict[str, Path] = {}
app = FastAPI(title="TTS Voice Creator - Clone and Design")
# ── In-memory log buffer ───────────────────────────────────────────────────────
_LOG_BUFFER_MAX = 400
_log_buffer: list[dict] = []
class _BufferHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
_log_buffer.insert(0, {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"name": record.name,
"msg": record.getMessage(),
})
del _log_buffer[_LOG_BUFFER_MAX:]
except Exception:
pass
_buf_handler = _BufferHandler()
_buf_handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(_buf_handler)
_ROUTING_LOG_MAX = int(os.environ.get("TTS_ROUTING_LOG_MAX", "120"))
_routing_log: list[dict] = []
@ -721,6 +744,18 @@ async def clear_tts_routing_log():
return {"ok": True, "items": []}
@app.get("/api/logs")
async def get_server_logs(limit: int = 200):
limit = max(1, min(int(limit or 200), _LOG_BUFFER_MAX))
return {"items": _log_buffer[:limit], "max": _LOG_BUFFER_MAX}
@app.delete("/api/logs")
async def clear_server_logs():
_log_buffer.clear()
return {"ok": True}
# ── VoiceDesign virtual voice presets ─────────────────────────────────────────
_DEFAULT_DESIGN_PRESETS = {

View File

@ -517,11 +517,13 @@ async function clientAutoTrimBounds(fid) {
function applyTheme(t) {
document.documentElement.dataset.theme = t;
$('theme-btn').innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>';
$('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
const btn = $('theme-btn');
if (btn) { btn.innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>'; btn.title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; }
const sel = $('s-theme-select');
if (sel) sel.value = t;
localStorage.setItem('vcf-theme', t);
}
$('theme-btn').addEventListener('click', () =>
$('theme-btn')?.addEventListener('click', () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
);
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
@ -1944,14 +1946,16 @@ async function loadSettings() {
$('s-vd-key').value = s.voice_design_api_key || '';
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
$('s-output-dir').value = s.output_dir || '';
const themeEl = $('s-theme-select');
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
await refreshTtsBackendAvailability();
renderSettingsAbout();
}
function markSettingsSeen() {
localStorage.setItem(SETTINGS_SEEN_KEY, '1');
}
function openSettings(firstRun = false) {
$('settings-first-run-note').style.display = firstRun ? '' : 'none';
if (firstRun) markSettingsSeen();
switchTab('settings');
}
@ -1965,7 +1969,7 @@ document.querySelectorAll('.s-eye-btn').forEach(btn => {
inp.type = inp.type === 'password' ? 'text' : 'password';
});
});
$('settings-btn').addEventListener('click', async () => { await loadSettings(); openSettings(false); });
$('settings-btn')?.addEventListener('click', async () => { await loadSettings(); openSettings(false); });
$('s-close-btn').addEventListener('click', async () => { await loadSettings(); toast('Settings reloaded', 'success'); });
$('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; });
$('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; });
@ -2032,7 +2036,11 @@ $('s-save-btn').addEventListener('click', async () => {
markSettingsSeen();
renderIntegrationSnippets();
toast('Settings saved', 'success');
toast('Settings saved', 'success');
});
// Theme select in General settings
document.addEventListener('change', e => {
if (e.target.id === 's-theme-select') applyTheme(e.target.value);
});
// ── Voice ID field (tab 3) ────────────────────────────────────────────────
@ -7137,6 +7145,84 @@ $('refine-restore-btn')?.addEventListener('click', () => {
toast('Original transcription restored', 'success');
});
// ── Settings: Logs ────────────────────────────────────────────────────────
let _logsLiveTimer = null;
let _logsActiveLevel = '';
function escLog(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
window.loadSettingsLogs = async function() {
const viewer = $('s-log-viewer');
if (!viewer) return;
try {
const data = await fetch('/api/logs?limit=300').then(r => r.json());
const items = (data.items || []).filter(item =>
!_logsActiveLevel || item.level === _logsActiveLevel
);
const count = $('s-log-count');
if (count) count.textContent = items.length + ' entries';
if (!items.length) {
viewer.innerHTML = '<div class="s-log-empty">No log entries</div>';
return;
}
viewer.innerHTML = items.map(item => {
const ts = item.ts ? item.ts.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : '';
return `<div class="s-log-row">
<span class="s-log-ts">${escLog(ts)}</span>
<span class="s-log-level ${escLog(item.level)}">${escLog(item.level)}</span>
<span class="s-log-msg">${escLog(item.msg)}</span>
</div>`;
}).join('');
} catch(e) {
viewer.innerHTML = `<div class="s-log-empty">Failed to load logs: ${escLog(e.message)}</div>`;
}
};
$('s-logs-refresh-btn')?.addEventListener('click', () => loadSettingsLogs());
$('s-logs-clear-btn')?.addEventListener('click', async () => {
await fetch('/api/logs', { method: 'DELETE' });
const viewer = $('s-log-viewer');
if (viewer) viewer.innerHTML = '<div class="s-log-empty">Logs cleared</div>';
const count = $('s-log-count');
if (count) count.textContent = '';
});
$('s-logs-live-toggle')?.addEventListener('change', function() {
clearInterval(_logsLiveTimer);
if (this.checked) {
loadSettingsLogs();
_logsLiveTimer = setInterval(loadSettingsLogs, 3000);
}
});
document.querySelectorAll('.s-log-filter').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.s-log-filter').forEach(b => b.classList.remove('is-active'));
this.classList.add('is-active');
_logsActiveLevel = this.dataset.logLevel;
loadSettingsLogs();
});
});
// ── Settings: About ───────────────────────────────────────────────────────
function renderSettingsAbout() {
const el = $('s-about-backends');
if (!el) return;
const available = new Set((_ttsBackends || []).filter(b => b.available).map(b => b.id));
const all = (_ttsBackends || []);
if (!all.length) { el.innerHTML = ''; return; }
el.innerHTML = all.map(b => {
const online = available.has(b.id);
return `<span class="s-about-backend ${online ? 'online' : 'offline'}">
<span class="mdi mdi-speaker-outline"></span>${escHtml(b.label)}
<span class="mdi ${online ? 'mdi-check-circle-outline' : 'mdi-close-circle-outline'}"></span>
</span>`;
}).join('');
}
// ── Voices import ──────────────────────────────────────────────────────────
$('s-import-voices-file')?.addEventListener('change', async function () {

View File

@ -76,14 +76,26 @@
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')">
<span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps
</div>
<div class="nav-item" data-nav-section="s-settings" onclick="navTo('s-settings')">
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span> Settings
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
<span class="nav-label">Settings</span>
<span class="nav-chevron" id="nav-settings-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree" id="nav-settings-tree">
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')">General</div>
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')">Connections</div>
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')">Playback</div>
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')">Payloads</div>
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')">Storage</div>
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')">API Keys</div>
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')">Backup</div>
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')">Logs</div>
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')">About</div>
</div>
</nav>
<div class="sidebar-footer">
<button id="theme-btn" title="Switch theme" style="display:none"><span class="mdi mdi-weather-sunny"></span></button>
<button id="settings-btn" onclick="navTo('s-settings')" title="Open settings"><span class="mdi mdi-cog-outline"></span> Settings</button>
</div>
</aside>

View File

@ -37,12 +37,20 @@
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
item.classList.toggle('active', item.dataset.navSection === sectionId);
});
// Expand tree only when voices section is active
var tree = document.getElementById('nav-voices-tree');
var chevron = document.getElementById('nav-voices-chevron');
// Voices nav tree
var voicesTree = document.getElementById('nav-voices-tree');
var voicesChevron = document.getElementById('nav-voices-chevron');
var isVoices = sectionId === 's-voices';
if (tree) tree.classList.toggle('open', isVoices);
if (chevron) chevron.classList.toggle('open', isVoices);
if (voicesTree) voicesTree.classList.toggle('open', isVoices);
if (voicesChevron) voicesChevron.classList.toggle('open', isVoices);
// Settings nav tree
var settingsTree = document.getElementById('nav-settings-tree');
var settingsChevron = document.getElementById('nav-settings-chevron');
var isSettings = sectionId === 's-settings';
if (settingsTree) settingsTree.classList.toggle('open', isSettings);
if (settingsChevron) settingsChevron.classList.toggle('open', isSettings);
var main = document.getElementById('main-content');
if (main) main.scrollTop = 0;
@ -87,6 +95,30 @@
set('nav-voices-count', voices.length);
};
// ── Settings tree sub-navigation ──────────────────────────────────────────
window._settingsSidebarCat = null;
window.navSettingsCat = function (cat) {
navTo('s-settings');
window._settingsSidebarCat = cat;
document.querySelectorAll('[data-settings-cat]').forEach(function (el) {
el.classList.toggle('is-active', el.dataset.settingsCat === cat);
});
if (cat === 'logs' && typeof loadSettingsLogs === 'function') loadSettingsLogs();
// Scroll inside main-content to the target section
var target = document.getElementById('s-settings-' + cat);
if (target) {
setTimeout(function () {
var main = document.getElementById('main-content');
if (main) {
var mainRect = main.getBoundingClientRect();
var targetRect = target.getBoundingClientRect();
main.scrollTo({ top: main.scrollTop + targetRect.top - mainRect.top - 16, behavior: 'smooth' });
}
}, 50);
}
};
// Show default section on load
showSection('s-voices');
runSideEffects('library');

View File

@ -13,15 +13,30 @@
<div class="s-header">
<div>
<h2>Settings</h2>
<p class="s-subtitle">Configure the service URLs you use. Advanced payloads, folders, and keys are tucked away below.</p>
<p class="s-subtitle">Configure the service URLs you use. Advanced payloads, folders, and keys are below.</p>
</div>
<span class="s-stack-badge"><span class="mdi mdi-check-circle-outline"></span> Local stack</span>
</div>
<!-- General -->
<div class="s-section" id="s-settings-general">
<div class="s-section-label">General</div>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>Theme</label>
<select id="s-theme-select">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<span class="s-hint">Switch between light and dark interface.</span>
</div>
</div>
</div>
<!-- Core connections -->
<div class="s-section">
<div class="s-section-label">Core connections</div>
<p class="s-section-desc">These are the endpoints you change most often. NVIDIA TTS and STT are grouped separately.</p>
<div class="s-section" id="s-settings-connections">
<div class="s-section-label">Connections</div>
<p class="s-section-desc">Service URLs for each backend. Change these first when setting up.</p>
<div class="s-group">
<div class="s-group-head">
@ -131,8 +146,9 @@
</div>
<!-- Playback behavior -->
<div class="s-section">
<div class="s-section-label">Playback behavior</div>
<div class="s-section" id="s-settings-playback">
<div class="s-section-label">Playback</div>
<p class="s-section-desc">Controls how previews play and how OpenAI-compatible requests are shaped.</p>
<div class="settings-grid settings-behavior-grid">
<div class="s-field">
<label>TTS preview playback</label>
@ -156,11 +172,17 @@
</div>
</div>
<!-- Save / Reload -->
<div class="btn-row settings-actions">
<button class="btn-primary" id="s-save-btn">Save settings</button>
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
</div>
<!-- Advanced payloads -->
<details class="s-details">
<details class="s-details" id="s-settings-payloads">
<summary>
<span class="mdi mdi-code-braces"></span>
<span class="s-details-title">Advanced request payloads</span>
<span class="s-details-title">Payloads</span>
<small>JSON extras sent to each backend</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary>
@ -209,10 +231,10 @@
</details>
<!-- Voice folders -->
<details class="s-details">
<details class="s-details" id="s-settings-storage">
<summary>
<span class="mdi mdi-folder-outline"></span>
<span class="s-details-title">Voice folders</span>
<span class="s-details-title">Storage</span>
<small>container paths and Portainer volume mounts</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary>
@ -231,10 +253,10 @@
</details>
<!-- API keys -->
<details class="s-details">
<details class="s-details" id="s-settings-apikeys">
<summary>
<span class="mdi mdi-key-outline"></span>
<span class="s-details-title">API keys</span>
<span class="s-details-title">API Keys</span>
<small>usually empty for local containers</small>
<span class="mdi mdi-chevron-down s-details-chevron"></span>
</summary>
@ -263,14 +285,8 @@
</div>
</details>
<!-- Actions -->
<div class="btn-row settings-actions">
<button class="btn-primary" id="s-save-btn">Save settings</button>
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
</div>
<!-- Backup & restore -->
<div class="s-section s-section-last">
<div class="s-section" id="s-settings-backup">
<div class="s-section-label">Backup &amp; restore</div>
<p class="s-hint">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p>
<div class="btn-row" style="margin-top:8px;gap:10px;flex-wrap:wrap">
@ -283,5 +299,45 @@
</div>
</div>
<!-- Logs -->
<div class="s-section" id="s-settings-logs">
<div class="s-section-label">Logs</div>
<p class="s-section-desc">Recent server activity. Useful for debugging backend connections and API errors.</p>
<div class="s-log-toolbar">
<button class="btn-secondary" id="s-logs-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
<button class="btn-secondary" id="s-logs-clear-btn" type="button"><span class="mdi mdi-trash-can-outline"></span> Clear</button>
<label class="s-log-live-label">
<input type="checkbox" id="s-logs-live-toggle">
<span>Auto-refresh</span>
</label>
<span class="s-log-count" id="s-log-count"></span>
</div>
<div class="s-log-filters">
<button class="s-log-filter is-active" data-log-level="">All</button>
<button class="s-log-filter" data-log-level="ERROR">Error</button>
<button class="s-log-filter" data-log-level="WARNING">Warning</button>
<button class="s-log-filter" data-log-level="INFO">Info</button>
</div>
<div class="s-log-viewer" id="s-log-viewer">
<div class="s-log-empty">Click Refresh to load logs</div>
</div>
</div>
<!-- About -->
<div class="s-section s-section-last" id="s-settings-about">
<div class="s-section-label">About</div>
<div class="s-about-block">
<div class="s-about-name"><span class="mdi mdi-microphone-variant"></span> TTS Voice Creator</div>
<p class="s-about-desc">Clone, design, and deploy custom voices using local AI backends. Compatible with Qwen3-TTS, Kokoro FastAPI, NVIDIA Magpie, and any OpenAI-compatible TTS/STT endpoint.</p>
<div class="s-about-stack">
<span class="s-about-chip"><span class="mdi mdi-language-python"></span> FastAPI</span>
<span class="s-about-chip"><span class="mdi mdi-language-javascript"></span> Vanilla JS</span>
<span class="s-about-chip"><span class="mdi mdi-music-note"></span> WaveSurfer.js v7</span>
<span class="s-about-chip"><span class="mdi mdi-vector-square"></span> MDI v7.4.47</span>
</div>
<div class="s-about-backends" id="s-about-backends"></div>
</div>
</div>
</div>
</div><!-- /tab-settings -->

View File

@ -376,9 +376,43 @@ audio { width: 100%; }
.settings-param-grid textarea { min-height:58px; }
/* Actions row */
.settings-actions { display:flex; gap:10px; align-items:center; padding:20px 32px; }
.settings-actions { display:flex; gap:10px; align-items:center; padding:20px 32px; border-top:1px solid var(--border); border-bottom:1px solid var(--border); }
.settings-actions button { min-width:128px; }
/* Log viewer */
.s-log-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.s-log-live-label { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--subtext); cursor:pointer; padding:0 4px; }
.s-log-live-label input { accent-color:var(--accent); cursor:pointer; }
.s-log-count { font-size:12px; color:var(--subtext); margin-left:auto; }
.s-log-filters { display:flex; gap:6px; flex-wrap:wrap; }
.s-log-filter { border:1px solid var(--border); background:transparent; color:var(--subtext); border-radius:999px; padding:3px 10px; font-size:12px; cursor:pointer; transition:all .15s; }
.s-log-filter:hover { border-color:var(--accent); color:var(--accent); }
.s-log-filter.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:380px; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.s-log-empty { color:var(--subtext); font-family:inherit; text-align:center; padding:24px 0; }
.s-log-row { display:grid; grid-template-columns:130px 56px 1fr; gap:8px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
.s-log-row:last-child { border-bottom:none; }
.s-log-ts { color:var(--subtext); font-size:11px; white-space:nowrap; }
.s-log-level { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
.s-log-level.ERROR, .s-log-level.CRITICAL { color:var(--red); }
.s-log-level.WARNING { color:#f59e0b; }
.s-log-level.INFO { color:var(--accent); }
.s-log-level.DEBUG { color:var(--subtext); }
.s-log-msg { color:var(--text); word-break:break-all; }
/* About */
.s-about-block { display:flex; flex-direction:column; gap:14px; max-width:640px; }
.s-about-name { font-size:20px; font-weight:700; color:var(--text); display:flex; align-items:center; gap:8px; }
.s-about-name .mdi { font-size:22px; color:var(--accent); }
.s-about-desc { font-size:14px; color:var(--subtext); line-height:1.65; margin:0; }
.s-about-stack { display:flex; flex-wrap:wrap; gap:8px; }
.s-about-chip { display:inline-flex; align-items:center; gap:5px; background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:4px 10px; font-size:12px; color:var(--subtext); }
.s-about-backends { display:flex; flex-wrap:wrap; gap:8px; }
.s-about-backend { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--border); border-radius:var(--radius); padding:5px 10px; font-size:12px; color:var(--subtext); }
.s-about-backend.online { border-color:color-mix(in srgb,var(--green) 50%,transparent); color:var(--green); }
.s-about-backend.offline { opacity:.55; }
.s-about-backend .mdi { font-size:11px; }
/* Responsive */
@media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
@media (max-width:900px) {