chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
452
src/static/groups.js
Normal file
452
src/static/groups.js
Normal file
@@ -0,0 +1,452 @@
|
||||
// Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups.
|
||||
|
||||
async function fetchGroupsMap() {
|
||||
try {
|
||||
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json();
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
} catch (e) {
|
||||
console.error('fetchGroupsMap:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevicesMapForGroups() {
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json();
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
} catch (e) {
|
||||
console.error('fetchDevicesMapForGroups:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||||
if (!containerEl) return;
|
||||
containerEl.innerHTML = '';
|
||||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
macRows.forEach((row, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'zone-device-row profiles-row';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'zone-device-row-label';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = row.label || row.mac || '—';
|
||||
label.appendChild(strong);
|
||||
label.appendChild(document.createTextNode(' '));
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'muted-text';
|
||||
sub.textContent = row.mac || '';
|
||||
label.appendChild(sub);
|
||||
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'btn btn-danger btn-small';
|
||||
rm.textContent = 'Remove';
|
||||
rm.addEventListener('click', () => {
|
||||
macRows.splice(idx, 1);
|
||||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||||
});
|
||||
div.appendChild(label);
|
||||
div.appendChild(rm);
|
||||
containerEl.appendChild(div);
|
||||
});
|
||||
|
||||
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
|
||||
const addWrap = document.createElement('div');
|
||||
addWrap.className = 'zone-devices-add profiles-actions';
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'zone-device-add-select';
|
||||
sel.appendChild(new Option('Add device…', ''));
|
||||
entries.forEach(([mac, d]) => {
|
||||
if (macsInRows.has(mac)) return;
|
||||
const labelName = d && d.name ? String(d.name).trim() : '';
|
||||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
||||
sel.appendChild(new Option(optLabel, mac));
|
||||
});
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-primary btn-small';
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.addEventListener('click', () => {
|
||||
const mac = sel.value;
|
||||
if (!mac || !devicesMap[mac]) return;
|
||||
const n = String((devicesMap[mac].name || '').trim() || mac);
|
||||
macRows.push({ mac, label: n });
|
||||
sel.value = '';
|
||||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
containerEl.appendChild(addWrap);
|
||||
refreshEditGroupDebug();
|
||||
}
|
||||
|
||||
function collectGroupEditPayload() {
|
||||
const idInput = document.getElementById('edit-group-id');
|
||||
const nameInput = document.getElementById('edit-group-name');
|
||||
const gid = idInput && idInput.value;
|
||||
const rows = window.__editGroupDeviceRows || [];
|
||||
const devices = rows.map((r) => r.mac).filter(Boolean);
|
||||
const payload = {
|
||||
name: nameInput ? nameInput.value.trim() : '',
|
||||
devices,
|
||||
};
|
||||
const dn = document.getElementById('edit-group-wifi-driver-name');
|
||||
const nl = document.getElementById('edit-group-wifi-num-leds');
|
||||
const co = document.getElementById('edit-group-wifi-color-order');
|
||||
const ws = document.getElementById('edit-group-wifi-startup-mode');
|
||||
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
|
||||
else payload.wifi_driver_display_name = null;
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
|
||||
else payload.wifi_driver_num_leds = null;
|
||||
} else payload.wifi_driver_num_leds = null;
|
||||
if (co && co.value) payload.wifi_color_order = co.value;
|
||||
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||||
const gob = document.getElementById('edit-group-output-brightness');
|
||||
if (gob && gob.value !== '') {
|
||||
const nb = parseInt(gob.value, 10);
|
||||
if (!Number.isNaN(nb)) payload.output_brightness = Math.max(0, Math.min(255, nb));
|
||||
}
|
||||
return { gid, payload };
|
||||
}
|
||||
|
||||
function refreshEditGroupDebug() {
|
||||
const ta = document.getElementById('edit-group-debug');
|
||||
if (!ta) return;
|
||||
try {
|
||||
const { gid, payload } = collectGroupEditPayload();
|
||||
const loaded = window.__editGroupLoadedSnapshot;
|
||||
ta.value = JSON.stringify(
|
||||
{
|
||||
group_id: gid || null,
|
||||
loaded_from_server: loaded != null ? loaded : null,
|
||||
save_payload_preview: payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (e) {
|
||||
ta.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadWifiFieldsFromGroup(g) {
|
||||
const wName = document.getElementById('edit-group-wifi-driver-name');
|
||||
const wLeds = document.getElementById('edit-group-wifi-num-leds');
|
||||
const wCo = document.getElementById('edit-group-wifi-color-order');
|
||||
const wStart = document.getElementById('edit-group-wifi-startup-mode');
|
||||
if (wName) {
|
||||
const v = g && Object.prototype.hasOwnProperty.call(g, 'wifi_driver_display_name')
|
||||
? g.wifi_driver_display_name
|
||||
: null;
|
||||
wName.value = v != null && String(v).trim() !== '' ? String(v).trim() : '';
|
||||
}
|
||||
if (wLeds) {
|
||||
const v = g && g.wifi_driver_num_leds;
|
||||
wLeds.value =
|
||||
v != null && v !== '' && String(v).trim() !== ''
|
||||
? String(v)
|
||||
: '';
|
||||
}
|
||||
if (wCo) {
|
||||
const co = (g && g.wifi_color_order) || 'rgb';
|
||||
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
|
||||
? String(co).toLowerCase()
|
||||
: 'rgb';
|
||||
}
|
||||
if (wStart) {
|
||||
const sm = (g && g.wifi_startup_mode) || 'default';
|
||||
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
|
||||
? String(sm).toLowerCase()
|
||||
: 'default';
|
||||
}
|
||||
const gob = document.getElementById('edit-group-output-brightness');
|
||||
const gobv = document.getElementById('edit-group-output-brightness-value');
|
||||
if (gob) {
|
||||
let bv = 255;
|
||||
if (g && g.output_brightness != null && g.output_brightness !== '') {
|
||||
const n = parseInt(String(g.output_brightness), 10);
|
||||
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
|
||||
}
|
||||
gob.value = String(bv);
|
||||
if (gobv) gobv.textContent = String(bv);
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditGroupModal(groupId, groupDoc) {
|
||||
const modal = document.getElementById('edit-group-modal');
|
||||
const idInput = document.getElementById('edit-group-id');
|
||||
const nameInput = document.getElementById('edit-group-name');
|
||||
const editor = document.getElementById('edit-group-devices-editor');
|
||||
|
||||
let g = groupDoc;
|
||||
if (!g || typeof g !== 'object') {
|
||||
try {
|
||||
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
|
||||
if (response.ok) g = await response.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
g = g || {};
|
||||
try {
|
||||
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
|
||||
} catch (e) {
|
||||
window.__editGroupLoadedSnapshot = g;
|
||||
}
|
||||
|
||||
if (idInput) idInput.value = groupId;
|
||||
if (nameInput) nameInput.value = g.name || '';
|
||||
|
||||
const dm = await fetchDevicesMapForGroups();
|
||||
const macs = Array.isArray(g.devices) ? g.devices : [];
|
||||
window.__editGroupDeviceRows = macs.map((m) => {
|
||||
const mac = String(m).trim().toLowerCase().replace(/:/g, '').replace(/-/g, '');
|
||||
const d = dm[mac];
|
||||
return {
|
||||
mac,
|
||||
label: d && d.name ? String(d.name).trim() : mac,
|
||||
};
|
||||
});
|
||||
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
||||
loadWifiFieldsFromGroup(g);
|
||||
refreshEditGroupDebug();
|
||||
if (modal) modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function loadGroupsModal() {
|
||||
const container = document.getElementById('groups-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const data = await fetchGroupsMap();
|
||||
renderGroupsList(data || {});
|
||||
} catch (e) {
|
||||
console.error('loadGroupsModal:', e);
|
||||
container.innerHTML = '<span class="muted-text">Failed to load groups.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupsList(groups) {
|
||||
const container = document.getElementById('groups-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
const ids = Object.keys(groups).filter((k) => groups[k] && typeof groups[k] === 'object');
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No groups yet. Create one to assign devices and Wi‑Fi defaults.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
ids.forEach((gid) => {
|
||||
const g = groups[gid];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
|
||||
const label = document.createElement('span');
|
||||
const devs = Array.isArray(g.devices) ? g.devices : [];
|
||||
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
|
||||
label.style.flex = '1';
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditGroupModal(gid, g));
|
||||
|
||||
const brightBtn = document.createElement('button');
|
||||
brightBtn.className = 'btn btn-secondary btn-small';
|
||||
brightBtn.type = 'button';
|
||||
brightBtn.textContent = 'Apply brightness';
|
||||
brightBtn.title = 'Push group output brightness to Wi‑Fi drivers in this group';
|
||||
brightBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Apply brightness failed');
|
||||
return;
|
||||
}
|
||||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||||
alert(
|
||||
n
|
||||
? `Sent brightness to ${n} driver(s).`
|
||||
: 'No Wi‑Fi drivers received brightness (check connections).',
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Apply brightness failed');
|
||||
}
|
||||
});
|
||||
|
||||
const applyBtn = document.createElement('button');
|
||||
applyBtn.className = 'btn btn-primary btn-small';
|
||||
applyBtn.type = 'button';
|
||||
applyBtn.textContent = 'Apply defaults to drivers';
|
||||
applyBtn.title = 'Push Wi‑Fi defaults to each connected driver in this group';
|
||||
applyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Apply failed');
|
||||
return;
|
||||
}
|
||||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||||
alert(
|
||||
n
|
||||
? `Sent defaults to ${n} driver(s).`
|
||||
: 'No Wi‑Fi drivers received the config (check defaults and connections).',
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Apply failed');
|
||||
}
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'btn btn-danger btn-small';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadGroupsModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Delete failed');
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(brightBtn);
|
||||
row.appendChild(applyBtn);
|
||||
row.appendChild(delBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const groupsBtn = document.getElementById('groups-btn');
|
||||
const groupsModal = document.getElementById('groups-modal');
|
||||
const groupsCloseBtn = document.getElementById('groups-close-btn');
|
||||
const newNameInput = document.getElementById('new-group-name');
|
||||
const createBtn = document.getElementById('create-group-btn');
|
||||
const editForm = document.getElementById('edit-group-form');
|
||||
const editCloseBtn = document.getElementById('edit-group-close-btn');
|
||||
const editModal = document.getElementById('edit-group-modal');
|
||||
|
||||
if (groupsBtn && groupsModal) {
|
||||
groupsBtn.addEventListener('click', () => {
|
||||
groupsModal.classList.add('active');
|
||||
loadGroupsModal();
|
||||
});
|
||||
}
|
||||
if (groupsCloseBtn && groupsModal) {
|
||||
groupsCloseBtn.addEventListener('click', () => groupsModal.classList.remove('active'));
|
||||
}
|
||||
|
||||
const grpOutBr = document.getElementById('edit-group-output-brightness');
|
||||
const grpOutBrVal = document.getElementById('edit-group-output-brightness-value');
|
||||
if (grpOutBr && grpOutBrVal) {
|
||||
grpOutBr.addEventListener('input', () => {
|
||||
grpOutBrVal.textContent = grpOutBr.value;
|
||||
});
|
||||
}
|
||||
|
||||
const createHandler = async () => {
|
||||
const name = newNameInput && newNameInput.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch('/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Create failed');
|
||||
return;
|
||||
}
|
||||
if (newNameInput) newNameInput.value = '';
|
||||
await loadGroupsModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Create failed');
|
||||
}
|
||||
};
|
||||
if (createBtn) createBtn.addEventListener('click', createHandler);
|
||||
if (newNameInput) {
|
||||
newNameInput.addEventListener('keypress', (ev) => {
|
||||
if (ev.key === 'Enter') createHandler();
|
||||
});
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('input', () => refreshEditGroupDebug());
|
||||
editForm.addEventListener('change', () => refreshEditGroupDebug());
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const { gid, payload } = collectGroupEditPayload();
|
||||
if (!gid) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Save failed');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore push errors after save */
|
||||
}
|
||||
if (editModal) editModal.classList.remove('active');
|
||||
await loadGroupsModal();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Save failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (editCloseBtn && editModal) {
|
||||
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user