v0.12.1 — Unit Swap wizard, editable timeline, roster/tz fixes #54

Merged
serversdown merged 6 commits from dev into main 2026-05-20 11:44:48 -04:00
2 changed files with 93 additions and 32 deletions
Showing only changes of commit 6d37bd759e - Show all commits
+52 -16
View File
@@ -318,6 +318,7 @@ async def get_locations_with_assignments(
"hardware_model": modem.hardware_model, "hardware_model": modem.hardware_model,
"ip_address": modem.ip_address, "ip_address": modem.ip_address,
"phone_number": modem.phone_number, "phone_number": modem.phone_number,
"deployed": bool(modem.deployed),
} }
results.append({ results.append({
@@ -1306,12 +1307,28 @@ async def swap_unit_on_location(
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first() modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem: if not modem:
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found") raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
# Symmetric cleanup: if this modem still claims a previous partner
# (a different seismograph whose deployed_with_modem_id never got
# cleared in a past swap), break that stale link before re-pairing.
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
prev_partner.deployed_with_modem_id = None
unit.deployed_with_modem_id = modem_id unit.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = unit_id modem.deployed_with_unit_id = unit_id
# If the modem was on the bench, swapping it into the field puts it
# back in rotation.
if not modem.deployed:
modem.deployed = True
else: else:
# Clear modem pairing if not provided # Clear modem pairing if not provided
unit.deployed_with_modem_id = None unit.deployed_with_modem_id = None
# If the incoming unit was benched, putting it in the field flips it
# back to deployed (so polling / dashboards see it as in rotation).
if not unit.deployed:
unit.deployed = True
db.commit() db.commit()
return JSONResponse({ return JSONResponse({
@@ -1329,23 +1346,32 @@ async def swap_unit_on_location(
async def get_available_modems( async def get_available_modems(
project_id: str, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
include_benched: bool = Query(False),
): ):
""" """
Get all deployed, non-retired modems for the modem assignment dropdown. Get all non-retired modems for the modem assignment dropdown.
By default only deployed (in-rotation) modems are returned, preserving
the existing behavior for callers like the location-detail swap modal.
Pass ``include_benched=true`` to also include benched modems
(``RosterUnit.deployed == False``) — useful when picking a modem to
pull off the bench for a field swap. Each row's ``deployed`` flag is
returned so the UI can badge benched candidates.
""" """
modems = db.query(RosterUnit).filter( filters = [
and_( RosterUnit.device_type == "modem",
RosterUnit.device_type == "modem", RosterUnit.retired == False,
RosterUnit.deployed == True, ]
RosterUnit.retired == False, if not include_benched:
) filters.append(RosterUnit.deployed == True)
).order_by(RosterUnit.id).all() modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
return [ return [
{ {
"id": m.id, "id": m.id,
"hardware_model": m.hardware_model, "hardware_model": m.hardware_model,
"ip_address": m.ip_address, "ip_address": m.ip_address,
"deployed": bool(m.deployed),
} }
for m in modems for m in modems
] ]
@@ -1356,22 +1382,31 @@ async def get_available_units(
project_id: str, project_id: str,
location_type: str = Query(...), location_type: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
include_benched: bool = Query(False),
): ):
""" """
Get list of available units for assignment to a location. Get list of available units for assignment to a location.
Filters by device type matching the location type. Filters by device type matching the location type.
By default only deployed (in-rotation) units are returned, preserving
the existing location-detail swap-modal behavior. Pass
``include_benched=true`` to also include benched units
(``RosterUnit.deployed == False``) — exactly the candidates you'd
pull off the bench for a field swap. Each row carries a ``deployed``
flag so the UI can badge benched picks.
""" """
# Determine required device type # Determine required device type
required_device_type = "slm" if location_type == "sound" else "seismograph" required_device_type = "slm" if location_type == "sound" else "seismograph"
# Get all units of the required type that are deployed and not retired # Get all units of the required type that aren't retired (and optionally
all_units = db.query(RosterUnit).filter( # exclude benched units).
and_( filters = [
RosterUnit.device_type == required_device_type, RosterUnit.device_type == required_device_type,
RosterUnit.deployed == True, RosterUnit.retired == False,
RosterUnit.retired == False, ]
) if not include_benched:
).all() filters.append(RosterUnit.deployed == True)
all_units = db.query(RosterUnit).filter(and_(*filters)).all()
# Filter out units that already have active assignments (active = assigned_until IS NULL) # Filter out units that already have active assignments (active = assigned_until IS NULL)
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter( assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
@@ -1385,6 +1420,7 @@ async def get_available_units(
"device_type": unit.device_type, "device_type": unit.device_type,
"location": unit.address or unit.location, "location": unit.address or unit.location,
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type, "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
"deployed": bool(unit.deployed),
} }
for unit in all_units for unit in all_units
if unit.id not in assigned_unit_ids if unit.id not in assigned_unit_ids
+41 -16
View File
@@ -232,6 +232,12 @@ function _esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function _badge(deployed) {
return deployed
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
}
function swapGoToStep(n) { function swapGoToStep(n) {
_swap.step = n; _swap.step = n;
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
@@ -339,7 +345,10 @@ function _swapRenderLocations(locations) {
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>` ? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`; : `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
const modemLine = modem const modemLine = modem
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono">+ modem ${_esc(modem.id)}</div>` ? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
<span>+ modem ${_esc(modem.id)}</span>
${_badge(modem.deployed)}
</div>`
: ''; : '';
// Pass index for cleaner attribute escaping // Pass index for cleaner attribute escaping
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)" return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
@@ -370,7 +379,7 @@ async function _swapLoadUnits() {
const list = document.getElementById('swap-unit-list'); const list = document.getElementById('swap-unit-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>'; list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try { try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration`); const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
_swap.all_units = await r.json(); _swap.all_units = await r.json();
_swapRenderUnits(); _swapRenderUnits();
@@ -396,11 +405,15 @@ function _swapRenderUnits() {
list.innerHTML = items.map(u => { list.innerHTML = items.map(u => {
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : ''; const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : ''; const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
const badge = _badge(u.deployed);
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})' return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"> class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span> <span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
${model} <div class="flex items-center gap-2">
${badge}
${model}
</div>
</div> </div>
${loc} ${loc}
</button>`; </button>`;
@@ -428,11 +441,14 @@ function _swapInitModemStep() {
const question = document.getElementById('swap-modem-question'); const question = document.getElementById('swap-modem-question');
if (current) { if (current) {
question.textContent = `Modem currently at this location: ${current.id}`; question.textContent = 'Modem at this location';
choices.innerHTML = ` choices.innerHTML = `
<button data-action="keep" onclick="swapPickModemAction('keep')" <button data-action="keep" onclick="swapPickModemAction('keep')"
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"> class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div> <div class="flex items-center justify-between gap-2">
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
${_badge(current.deployed)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div> <div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
</button> </button>
<button data-action="swap" onclick="swapPickModemAction('swap')" <button data-action="swap" onclick="swapPickModemAction('swap')"
@@ -490,7 +506,7 @@ async function _swapLoadModems() {
const list = document.getElementById('swap-modem-list'); const list = document.getElementById('swap-modem-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>'; list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
try { try {
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems`); const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap"). // Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
let modems = await r.json(); let modems = await r.json();
@@ -520,12 +536,16 @@ function _swapRenderModems() {
list.innerHTML = items.map(m => { list.innerHTML = items.map(m => {
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : ''; const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : ''; const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
const badge = _badge(m.deployed);
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})' return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors" class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
data-modem-id="${_esc(m.id)}"> data-modem-id="${_esc(m.id)}">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span> <span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
${hw} <div class="flex items-center gap-2">
${badge}
${hw}
</div>
</div> </div>
${ip} ${ip}
</button>`; </button>`;
@@ -553,16 +573,21 @@ function swapAfterModem() {
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)'; document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id; document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
const oldModem = _swap.location.modem ? _swap.location.modem.id : '(none)'; const oldModemEl = document.getElementById('swap-review-old-modem');
document.getElementById('swap-review-old-modem').textContent = oldModem; if (_swap.location.modem) {
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
let newModem = '(none)'; } else {
if (_swap.modem_action === 'keep' && _swap.location.modem) { oldModemEl.textContent = '(none)';
newModem = _swap.location.modem.id + ' (kept)'; }
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
newModem = _swap.new_modem.id; const newModemEl = document.getElementById('swap-review-new-modem');
if (_swap.modem_action === 'keep' && _swap.location.modem) {
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
} else {
newModemEl.textContent = '(none)';
} }
document.getElementById('swap-review-new-modem').textContent = newModem;
swapGoToStep(5); swapGoToStep(5);
} }