diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js
index 7695363..10afc84 100644
--- a/Client/udpstreamer/static/app.js
+++ b/Client/udpstreamer/static/app.js
@@ -2706,7 +2706,7 @@ function buildSidebar() {
list.appendChild(empty);
}
- list.appendChild(makeAddSourceSection());
+ list.appendChild(makeSourcesConfigSection());
}
function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div');
@@ -3473,16 +3473,35 @@ function applyCalibrationChanged() {
if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); }
}
-// Replaced in Task 10 with the Sources & Config status renderer.
-function onConfigAck(msg) { /* no-op until Task 10 */ }
+// Last config acknowledgement, kept outside the DOM because buildSidebar()
+// discards and recreates this whole section on every `sources` broadcast.
+let _cfgStatus = null; // {ok: bool, text: string} or null
-function makeAddSourceSection() {
+function renderCfgStatus(el) {
+ el.className = 'cfg-status';
+ if (!_cfgStatus) { el.textContent = ''; return; }
+ el.classList.add(_cfgStatus.ok ? 'ok' : 'err');
+ el.textContent = _cfgStatus.text;
+}
+
+function onConfigAck(msg) {
+ const what = msg.type === 'configSaved' ? 'Saved' : 'Reloaded';
+ if (msg.ok) {
+ _cfgStatus = { ok: true, text: what + ': ' + (msg.path || 'config file') };
+ } else {
+ _cfgStatus = { ok: false, text: what + ' failed: ' + (msg.error || 'unknown error') };
+ }
+ const el = document.getElementById('cfg-status');
+ if (el) renderCfgStatus(el);
+}
+
+function makeSourcesConfigSection() {
const section = document.createElement('div');
section.className = 'add-source-section';
const title = document.createElement('div');
title.className = 'add-source-title';
- title.innerHTML = '▶ Add Source';
+ title.innerHTML = '▶ Sources & Config';
const body = document.createElement('div');
body.className = 'add-source-body';
@@ -3515,12 +3534,37 @@ function makeAddSourceSection() {
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
+ const btnRow = document.createElement('div');
+ btnRow.className = 'cfg-btn-row';
+
const saveBtn = document.createElement('button');
saveBtn.className = 'add-src-btn save-src-btn';
- saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
- saveBtn.addEventListener('click', saveConfigWS);
+ saveBtn.textContent = 'Save';
+ saveBtn.title = 'Write the source list and all signal calibration to the hub\u2019s config file';
+ saveBtn.addEventListener('click', () => {
+ _cfgStatus = null;
+ const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
+ saveConfigWS();
+ });
- body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, saveBtn);
+ const reloadBtn = document.createElement('button');
+ reloadBtn.className = 'add-src-btn reload-src-btn';
+ reloadBtn.textContent = 'Reload';
+ reloadBtn.title = 'Re-read the config file: calibration is replaced wholesale, '
+ + 'missing sources are added, and no running source is stopped';
+ reloadBtn.addEventListener('click', () => {
+ _cfgStatus = null;
+ const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
+ reloadConfigWS();
+ });
+
+ btnRow.append(saveBtn, reloadBtn);
+
+ const status = document.createElement('div');
+ status.id = 'cfg-status';
+ renderCfgStatus(status);
+
+ body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, btnRow, status);
section.append(title, body);
title.addEventListener('click', () => {
diff --git a/Client/udpstreamer/static/style.css b/Client/udpstreamer/static/style.css
index 64d83c1..2b6803d 100644
--- a/Client/udpstreamer/static/style.css
+++ b/Client/udpstreamer/static/style.css
@@ -478,6 +478,16 @@ input[type=range].trig-range::-webkit-slider-thumb {
.add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); }
.save-src-btn { color:var(--green); }
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
+.cfg-btn-row { display:flex; gap:6px; }
+.cfg-btn-row .add-src-btn { flex:1; }
+.reload-src-btn { color:var(--peach); }
+.reload-src-btn:hover { background:rgba(250,179,135,0.1); border-color:var(--peach); }
+.cfg-status {
+ font-size:10px; line-height:1.3; padding:2px 0; min-height:13px;
+ overflow-wrap:anywhere;
+}
+.cfg-status.ok { color:var(--green); }
+.cfg-status.err { color:var(--red); }
/* ── Stats panel ─────────────────────────────────────────────── */
#stats-panel {