Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"web": "node webserver.js",
"import-talkgroups": "node import_csv.js",
"check:config": "node scripts/check-config.js",
"check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js",
"check:syntax": "node --check bot.js && node --check webserver.js && node --check geocoding.js && node --check import_csv.js && node --check public/app.js && node --check public/setup.js && node --check public/settings.js",
"demo:data": "node scripts/generate-demo-data.js",
"test": "node --test test/*.test.js"
},
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@
<a href="#" id="add-user-btn">Add User</a>
<a href="#" id="view-users-btn">View Users</a>
<a href="#" id="manage-sessions-btn">Manage Sessions</a>
<a href="/settings" id="settings-console-link">Settings Console</a>
<a href="#" id="call-purge-btn">Call Purge</a>
</div>
</div>
Expand Down
82 changes: 82 additions & 0 deletions public/settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scanner Map Settings</title>
<link rel="stylesheet" href="/setup.css" />
</head>
<body>
<main class="setup-shell">
<header class="setup-header">
<div>
<h1>Scanner Map Settings</h1>
<p>Manage runtime settings, write-only secrets, and setup diagnostics.</p>
</div>
<a class="status-pill" href="/">Back to Map</a>
</header>

<div class="layout">
<nav class="steps" aria-label="Settings sections">
<button class="step-button active" data-step="general">General</button>
<button class="step-button" data-step="ingestion">Ingestion</button>
<button class="step-button" data-step="providers">Providers</button>
<button class="step-button" data-step="diagnostics">Diagnostics</button>
</nav>

<section class="panel">
<div id="general" class="section active">
<h2>General</h2>
<div class="grid">
<div class="field"><label for="publicDomain">Public domain</label><input id="publicDomain" /></div>
<div class="field"><label for="timezone">Timezone</label><input id="timezone" /></div>
<div class="field"><label for="summaryLookbackHours">Summary lookback hours</label><input id="summaryLookbackHours" type="number" min="0" /></div>
<div class="field"><label for="askAiLookbackHours">Ask AI lookback hours</label><input id="askAiLookbackHours" type="number" min="0" /></div>
</div>
</div>

<div id="ingestion" class="section">
<h2>Ingestion</h2>
<div class="grid">
<div class="field"><label for="mappedTalkGroups">Mapped talkgroups</label><input id="mappedTalkGroups" /></div>
<div class="field"><label for="enableMappedTalkGroups">Enable mapped talkgroups</label><select id="enableMappedTalkGroups"><option>true</option><option>false</option></select></div>
<div class="field"><label for="uploadApiKey">Upload API key</label><input id="uploadApiKey" type="password" placeholder="Write-only replacement" /></div>
</div>
</div>

<div id="providers" class="section">
<h2>Providers</h2>
<div class="grid">
<div class="field"><label for="storageMode">Storage mode</label><select id="storageMode"><option>local</option><option>s3</option></select></div>
<div class="field"><label for="transcriptionMode">Transcription mode</label><select id="transcriptionMode"><option>local</option><option>remote</option><option>openai</option><option>icad</option></select></div>
<div class="field"><label for="transcriptionDevice">Transcription device</label><select id="transcriptionDevice"><option>cpu</option><option>cuda</option></select></div>
<div class="field"><label for="aiProvider">AI provider</label><select id="aiProvider"><option>ollama</option><option>openai</option></select></div>
<div class="field"><label for="ollamaUrl">Ollama URL</label><input id="ollamaUrl" /></div>
<div class="field"><label for="ollamaModel">Ollama model</label><input id="ollamaModel" /></div>
<div class="field"><label for="openaiModel">OpenAI model</label><input id="openaiModel" /></div>
<div class="field"><label for="fasterWhisperServerUrl">Remote transcription URL</label><input id="fasterWhisperServerUrl" /></div>
<div class="field"><label for="googleMapsApiKey">Google Maps API key</label><input id="googleMapsApiKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="locationIqApiKey">LocationIQ API key</label><input id="locationIqApiKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="openaiApiKey">OpenAI API key</label><input id="openaiApiKey" type="password" placeholder="Write-only replacement" /></div>
</div>
</div>

<div id="diagnostics" class="section">
<h2>Diagnostics</h2>
<div class="actions">
<button class="secondary" id="run-diagnostics">Run Checks</button>
<button class="secondary" id="load-jobs">Load Job Summary</button>
</div>
<div id="diagnostic-output" class="result-list"></div>
</div>

<div class="actions">
<button class="primary" id="save-settings">Save Settings</button>
<span id="save-result"></span>
</div>
</section>
</div>
</main>
<script src="/settings.js"></script>
</body>
</html>
77 changes: 77 additions & 0 deletions public/settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const normalKeys = [
'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours',
'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode',
'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel',
'fasterWhisperServerUrl'
];
const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey'];

function showStep(id) {
document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id));
document.querySelectorAll('.step-button').forEach((button) => button.classList.toggle('active', button.dataset.step === id));
}

document.querySelectorAll('.step-button').forEach((button) => button.addEventListener('click', () => showStep(button.dataset.step)));

async function jsonFetch(url, options = {}) {
const response = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...options });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || response.statusText);
return data;
}

async function loadSettings() {
const data = await jsonFetch('/api/settings');
for (const key of normalKeys) {
const input = document.getElementById(key);
if (input && data.settings[key]) input.value = data.settings[key].value;
}
for (const key of secretKeys) {
const input = document.getElementById(key);
if (input && data.secrets[key]?.configured) input.placeholder = 'Configured - enter a new value to replace';
}
}

document.getElementById('save-settings').addEventListener('click', async () => {
const result = document.getElementById('save-result');
try {
const payload = {};
for (const key of normalKeys) {
const input = document.getElementById(key);
if (input) payload[key] = input.value;
}
const saved = await jsonFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });

for (const key of secretKeys) {
const input = document.getElementById(key);
if (input && input.value) {
await jsonFetch(`/api/settings/secrets/${key}`, { method: 'PUT', body: JSON.stringify({ value: input.value }) });
input.value = '';
input.placeholder = 'Configured - enter a new value to replace';
}
}

result.textContent = saved.requiresRestart ? 'Saved. Restart required for some changes.' : 'Saved.';
} catch (error) {
result.textContent = error.message;
}
});

document.getElementById('run-diagnostics').addEventListener('click', async () => {
const output = document.getElementById('diagnostic-output');
const checks = await jsonFetch('/api/settings/checks');
output.innerHTML = `<pre>${JSON.stringify(checks, null, 2)}</pre>`;
});

document.getElementById('load-jobs').addEventListener('click', async () => {
const output = document.getElementById('diagnostic-output');
const [summary, recent] = await Promise.all([
jsonFetch('/api/jobs/summary'),
jsonFetch('/api/jobs/recent?limit=10')
]);
output.innerHTML = `<pre>${JSON.stringify({ summary, recent }, null, 2)}</pre>`;
});

loadSettings().catch((error) => {
document.getElementById('save-result').textContent = error.message;
});
Loading
Loading