feat: add Remote Instances management UI to Settings (task-15)
This commit is contained in:
@@ -1184,6 +1184,57 @@ function buildSources(settings) {
|
|||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single remote instance row element with URL input, name input, and remove button.
|
||||||
|
* @param {string} url - remote instance URL
|
||||||
|
* @param {string} name - remote instance display name
|
||||||
|
* @returns {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
function _buildRemoteInstanceRow(url, name) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'settings-remote-row';
|
||||||
|
var urlInput = document.createElement('input');
|
||||||
|
urlInput.type = 'text';
|
||||||
|
urlInput.className = 'settings-remote-url';
|
||||||
|
urlInput.placeholder = 'http://192.168.1.x:8000';
|
||||||
|
urlInput.value = url || '';
|
||||||
|
var nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.className = 'settings-remote-name';
|
||||||
|
nameInput.placeholder = 'Device name';
|
||||||
|
nameInput.value = name || '';
|
||||||
|
var removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'settings-remote-remove';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.setAttribute('aria-label', 'Remove remote instance');
|
||||||
|
row.appendChild(urlInput);
|
||||||
|
row.appendChild(nameInput);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read remote instance rows from the DOM and save to server settings.
|
||||||
|
* Rebuilds _sources after saving so polling updates immediately.
|
||||||
|
*/
|
||||||
|
function _saveRemoteInstances() {
|
||||||
|
var container = $('setting-remote-instances');
|
||||||
|
if (!container) return;
|
||||||
|
var instances = [];
|
||||||
|
container.querySelectorAll('.settings-remote-row').forEach(function(row) {
|
||||||
|
var urlEl = row.querySelector('.settings-remote-url');
|
||||||
|
var nameEl = row.querySelector('.settings-remote-name');
|
||||||
|
var url = (urlEl && urlEl.value) ? urlEl.value.trim() : '';
|
||||||
|
var name = (nameEl && nameEl.value) ? nameEl.value.trim() : '';
|
||||||
|
if (url) {
|
||||||
|
instances.push({ url: url, name: name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
patchServerSetting('remote_instances', instances).then(function() {
|
||||||
|
_sources = buildSources(_serverSettings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Settings dialog ──────────────────────────────────────────────────────────
|
// ─── Settings dialog ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1403,6 +1454,22 @@ function openSettings() {
|
|||||||
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
|
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device name
|
||||||
|
const deviceNameEl = $('setting-device-name');
|
||||||
|
if (deviceNameEl) {
|
||||||
|
deviceNameEl.value = (ss && ss.device_name) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote instances
|
||||||
|
const remoteInstancesEl = $('setting-remote-instances');
|
||||||
|
if (remoteInstancesEl) {
|
||||||
|
remoteInstancesEl.innerHTML = '';
|
||||||
|
var remotes = (ss && ss.remote_instances) || [];
|
||||||
|
remotes.forEach(function(r) {
|
||||||
|
remoteInstancesEl.appendChild(_buildRemoteInstanceRow(r.url || '', r.name || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// New Session tab - populate template textarea
|
// New Session tab - populate template textarea
|
||||||
const templateEl = $('setting-template');
|
const templateEl = $('setting-template');
|
||||||
if (templateEl) {
|
if (templateEl) {
|
||||||
@@ -1882,6 +1949,51 @@ function bindStaticEventListeners() {
|
|||||||
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
|
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sessions tab — device name with 500ms debounce
|
||||||
|
var _deviceNameDebounceTimer;
|
||||||
|
on($('setting-device-name'), 'input', function() {
|
||||||
|
clearTimeout(_deviceNameDebounceTimer);
|
||||||
|
var val = this.value;
|
||||||
|
_deviceNameDebounceTimer = setTimeout(function() {
|
||||||
|
patchServerSetting('device_name', val).then(function() {
|
||||||
|
_sources = buildSources(_serverSettings);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessions tab — add remote instance button
|
||||||
|
on($('add-remote-instance-btn'), 'click', function() {
|
||||||
|
var container = $('setting-remote-instances');
|
||||||
|
if (container) {
|
||||||
|
container.appendChild(_buildRemoteInstanceRow('', ''));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessions tab — delegated remove handler on remote instances container
|
||||||
|
var remoteInstancesContainer = $('setting-remote-instances');
|
||||||
|
if (remoteInstancesContainer) {
|
||||||
|
remoteInstancesContainer.addEventListener('click', function(e) {
|
||||||
|
var removeBtn = e.target.closest && e.target.closest('.settings-remote-remove');
|
||||||
|
if (!removeBtn) return;
|
||||||
|
var row = removeBtn.closest('.settings-remote-row');
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
_saveRemoteInstances();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegated input save with debounce for remote instance URL/name fields
|
||||||
|
var _remoteDebounceTimer;
|
||||||
|
remoteInstancesContainer.addEventListener('input', function(e) {
|
||||||
|
var input = e.target.closest && e.target.closest('.settings-remote-url, .settings-remote-name');
|
||||||
|
if (!input) return;
|
||||||
|
clearTimeout(_remoteDebounceTimer);
|
||||||
|
_remoteDebounceTimer = setTimeout(function() {
|
||||||
|
_saveRemoteInstances();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Filter bar — delegated click handler (pills are re-rendered each poll)
|
// Filter bar — delegated click handler (pills are re-rendered each poll)
|
||||||
var filterBarEl = $('filter-bar');
|
var filterBarEl = $('filter-bar');
|
||||||
if (filterBarEl) {
|
if (filterBarEl) {
|
||||||
|
|||||||
@@ -164,6 +164,15 @@
|
|||||||
<label class="settings-label" for="setting-auto-open">Auto-open created sessions</label>
|
<label class="settings-label" for="setting-auto-open">Auto-open created sessions</label>
|
||||||
<input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked />
|
<input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label class="settings-label" for="setting-device-name">Device Name</label>
|
||||||
|
<input type="text" id="setting-device-name" class="settings-input" placeholder="This device" />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field settings-field--column">
|
||||||
|
<label class="settings-label">Remote Instances</label>
|
||||||
|
<div id="setting-remote-instances" class="settings-remote-list"></div>
|
||||||
|
<button id="add-remote-instance-btn" class="settings-action-btn">+ Add Remote</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-panel hidden" data-tab="notifications">
|
<div class="settings-panel hidden" data-tab="notifications">
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
|
|||||||
@@ -1451,3 +1451,86 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Remote Instances UI (task-15)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.settings-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-url {
|
||||||
|
flex: 2;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-url:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-name {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-name:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-remote-remove:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1681,3 +1681,20 @@ def test_css_sidebar_footer() -> None:
|
|||||||
css = read_css()
|
css = read_css()
|
||||||
for cls in (".sidebar-footer", ".sidebar-new-btn"):
|
for cls in (".sidebar-footer", ".sidebar-new-btn"):
|
||||||
assert cls in css, f"Missing CSS selector '{cls}'"
|
assert cls in css, f"Missing CSS selector '{cls}'"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Remote Instances UI (task-15) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_remote_instances_classes() -> None:
|
||||||
|
"""CSS classes for remote instances management must exist in style.css."""
|
||||||
|
css = read_css()
|
||||||
|
for cls in (
|
||||||
|
".settings-remote-list",
|
||||||
|
".settings-remote-row",
|
||||||
|
".settings-remote-url",
|
||||||
|
".settings-remote-name",
|
||||||
|
".settings-remote-remove",
|
||||||
|
".settings-input",
|
||||||
|
):
|
||||||
|
assert cls in css, f"Missing CSS selector '{cls}' — required for Remote Instances UI"
|
||||||
|
|||||||
@@ -1062,3 +1062,50 @@ def test_html_settings_close_btn_exists() -> None:
|
|||||||
assert dialog.find(id="settings-close-btn") is not None, (
|
assert dialog.find(id="settings-close-btn") is not None, (
|
||||||
"#settings-close-btn must be a descendant of #settings-dialog"
|
"#settings-close-btn must be a descendant of #settings-dialog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Remote Instances UI (task-15-remote-instances)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_sessions_tab_device_name_input() -> None:
|
||||||
|
"""Sessions tab must contain a #setting-device-name text input for the device name."""
|
||||||
|
soup = _SOUP
|
||||||
|
el = soup.find(id="setting-device-name")
|
||||||
|
assert el is not None, "Missing element with id='setting-device-name'"
|
||||||
|
# Must be inside the sessions panel
|
||||||
|
sessions_panel = soup.find("div", attrs={"data-tab": "sessions"})
|
||||||
|
assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')"
|
||||||
|
assert sessions_panel.find(id="setting-device-name") is not None, (
|
||||||
|
"#setting-device-name must be inside the sessions settings panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_sessions_tab_remote_instances_container() -> None:
|
||||||
|
"""Sessions tab must contain a #setting-remote-instances container for remote instance rows."""
|
||||||
|
soup = _SOUP
|
||||||
|
el = soup.find(id="setting-remote-instances")
|
||||||
|
assert el is not None, "Missing element with id='setting-remote-instances'"
|
||||||
|
# Must be inside the sessions panel
|
||||||
|
sessions_panel = soup.find("div", attrs={"data-tab": "sessions"})
|
||||||
|
assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')"
|
||||||
|
assert sessions_panel.find(id="setting-remote-instances") is not None, (
|
||||||
|
"#setting-remote-instances must be inside the sessions settings panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_sessions_tab_add_remote_instance_btn() -> None:
|
||||||
|
"""Sessions tab must contain an #add-remote-instance-btn button to add remote instances."""
|
||||||
|
soup = _SOUP
|
||||||
|
el = soup.find(id="add-remote-instance-btn")
|
||||||
|
assert el is not None, "Missing element with id='add-remote-instance-btn'"
|
||||||
|
assert el.name == "button", (
|
||||||
|
f"#add-remote-instance-btn must be a <button>, got: {el.name}"
|
||||||
|
)
|
||||||
|
# Must be inside the sessions panel
|
||||||
|
sessions_panel = soup.find("div", attrs={"data-tab": "sessions"})
|
||||||
|
assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')"
|
||||||
|
assert sessions_panel.find(id="add-remote-instance-btn") is not None, (
|
||||||
|
"#add-remote-instance-btn must be inside the sessions settings panel"
|
||||||
|
)
|
||||||
|
|||||||
@@ -2296,3 +2296,123 @@ def test_dom_content_loaded_calls_load_server_settings() -> None:
|
|||||||
"DOMContentLoaded handler must call loadServerSettings() "
|
"DOMContentLoaded handler must call loadServerSettings() "
|
||||||
"after startPolling() in the restoreState().then() chain"
|
"after startPolling() in the restoreState().then() chain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Remote Instances UI (task-15) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_remote_instances_function_exists() -> None:
|
||||||
|
"""_saveRemoteInstances function must exist in app.js."""
|
||||||
|
assert "_saveRemoteInstances" in _JS, (
|
||||||
|
"_saveRemoteInstances function must be defined in app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_remote_instances_calls_patch_server_setting() -> None:
|
||||||
|
"""_saveRemoteInstances must call patchServerSetting('remote_instances', ...)."""
|
||||||
|
match = re.search(
|
||||||
|
r"function _saveRemoteInstances\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.)|\n})",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "_saveRemoteInstances function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "patchServerSetting" in body, (
|
||||||
|
"_saveRemoteInstances must call patchServerSetting"
|
||||||
|
)
|
||||||
|
assert "remote_instances" in body, (
|
||||||
|
"_saveRemoteInstances must pass 'remote_instances' to patchServerSetting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_remote_instances_rebuilds_sources() -> None:
|
||||||
|
"""_saveRemoteInstances must rebuild _sources after saving."""
|
||||||
|
match = re.search(
|
||||||
|
r"function _saveRemoteInstances\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//|window\.)|\n})",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "_saveRemoteInstances function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "_sources" in body or "buildSources" in body, (
|
||||||
|
"_saveRemoteInstances must rebuild _sources after saving remote_instances"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_settings_populates_device_name() -> None:
|
||||||
|
"""openSettings must populate setting-device-name from server settings."""
|
||||||
|
match = re.search(
|
||||||
|
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─))",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "openSettings function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "setting-device-name" in body, (
|
||||||
|
"openSettings must populate #setting-device-name input from server settings"
|
||||||
|
)
|
||||||
|
assert "device_name" in body, (
|
||||||
|
"openSettings must reference device_name from server settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_settings_renders_remote_instances() -> None:
|
||||||
|
"""openSettings must render remote instances rows from server settings."""
|
||||||
|
match = re.search(
|
||||||
|
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─))",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "openSettings function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "setting-remote-instances" in body, (
|
||||||
|
"openSettings must populate #setting-remote-instances from server settings"
|
||||||
|
)
|
||||||
|
assert "remote_instances" in body, (
|
||||||
|
"openSettings must reference remote_instances when rendering the instances list"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_static_event_listeners_device_name_debounce() -> None:
|
||||||
|
"""bindStaticEventListeners must bind debounced save for setting-device-name."""
|
||||||
|
match = re.search(
|
||||||
|
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "bindStaticEventListeners function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "setting-device-name" in body, (
|
||||||
|
"bindStaticEventListeners must bind an event listener for #setting-device-name"
|
||||||
|
)
|
||||||
|
assert "device_name" in body, (
|
||||||
|
"bindStaticEventListeners must save device_name via patchServerSetting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_static_event_listeners_add_remote_instance_btn() -> None:
|
||||||
|
"""bindStaticEventListeners must bind the add-remote-instance-btn click handler."""
|
||||||
|
match = re.search(
|
||||||
|
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "bindStaticEventListeners function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "add-remote-instance-btn" in body, (
|
||||||
|
"bindStaticEventListeners must bind click handler for #add-remote-instance-btn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_static_event_listeners_remote_instance_remove() -> None:
|
||||||
|
"""bindStaticEventListeners must bind delegated handler for remote instance remove buttons."""
|
||||||
|
match = re.search(
|
||||||
|
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)(?=\n(?:function|//\s*─|window\.))",
|
||||||
|
_JS,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert match, "bindStaticEventListeners function not found in app.js"
|
||||||
|
body = match.group(1)
|
||||||
|
assert "settings-remote-remove" in body, (
|
||||||
|
"bindStaticEventListeners must handle delegated clicks on .settings-remote-remove buttons"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user