merge v0.12.0 #51

Merged
serversdown merged 12 commits from dev into main 2026-05-17 19:44:57 -04:00
6 changed files with 906 additions and 0 deletions
Showing only changes of commit 1af5a94f57 - Show all commits
+16
View File
@@ -275,6 +275,22 @@ async def tools_page(request: Request):
return templates.TemplateResponse("tools.html", {"request": request}) return templates.TemplateResponse("tools.html", {"request": request})
@app.get("/deploy", response_class=HTMLResponse)
async def deploy_page(request: Request):
"""Mobile-first field-capture wizard. Pick a seismograph, snap a
photo of the install, optionally add a memo — drop into the pending
hopper for later classification."""
return templates.TemplateResponse("deploy.html", {"request": request})
@app.get("/tools/pending-deployments", response_class=HTMLResponse)
async def pending_deployments_page(request: Request):
"""List of field captures awaiting classification, plus filters for
historical assigned / cancelled rows. Operators promote a capture
into a real UnitAssignment from here."""
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse) @app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request): async def modems_page(request: Request):
"""Field modems management dashboard""" """Field modems management dashboard"""
+44
View File
@@ -67,6 +67,50 @@ def _record_history(
)) ))
@router.get("/seismograph-picker")
def seismograph_picker(
q: str = "",
limit: int = 20,
db: Session = Depends(get_db),
):
"""JSON list of seismograph units for the /deploy mobile picker.
Filters out retired units. Sorts by recency of pending captures
first, then alphabetically — so units the operator is actively
deploying with surface at the top.
"""
q_clean = (q or "").strip()
qb = db.query(RosterUnit).filter(
RosterUnit.device_type == "seismograph",
RosterUnit.retired == False, # noqa: E712
)
if q_clean:
qb = qb.filter(
(RosterUnit.id.ilike(f"%{q_clean}%"))
| (RosterUnit.note.ilike(f"%{q_clean}%"))
)
units = qb.order_by(RosterUnit.id).limit(limit).all()
# Annotate with "has an awaiting pending deployment" so the picker
# can de-emphasize / warn on units that are already mid-deploy.
pending_unit_ids = {
r[0] for r in db.query(PendingDeployment.unit_id)
.filter_by(status="awaiting").distinct().all()
}
return {
"units": [
{
"id": u.id,
"note": u.note,
"deployed": u.deployed,
"has_pending": u.id in pending_unit_ids,
}
for u in units
],
}
@router.post("/capture") @router.post("/capture")
async def capture_deployment( async def capture_deployment(
unit_id: str = Form(...), unit_id: str = Form(...),
+462
View File
@@ -0,0 +1,462 @@
{% extends "base.html" %}
{% block title %}Pending Deployments - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Pending Deployments</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Captures from the field waiting to be classified.
<a href="/deploy" class="text-seismo-orange hover:text-seismo-navy">Capture a new one →</a>
</p>
</div>
<!-- Filter pills -->
<div class="flex gap-2 mb-6">
<button onclick="switchPdStatus('awaiting')" id="pd-tab-awaiting"
class="px-4 py-2 rounded-lg text-sm font-medium bg-seismo-orange text-white">
Awaiting <span id="pd-count-awaiting" class="ml-1 text-xs opacity-80"></span>
</button>
<button onclick="switchPdStatus('assigned')" id="pd-tab-assigned"
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Assigned
</button>
<button onclick="switchPdStatus('cancelled')" id="pd-tab-cancelled"
class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Cancelled
</button>
</div>
<div id="pd-list" class="space-y-4">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>
</div>
<!-- Classify modal -->
<div id="classify-modal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="min-height: 480px;">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Classify pending deployment</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span id="classify-unit-label" class="font-mono text-seismo-orange"></span>
captured at
<span id="classify-captured-at"></span>
</p>
</div>
<button onclick="closeClassifyModal()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 space-y-5">
<!-- Mode toggle -->
<div class="flex gap-2">
<button onclick="setClassifyMode('existing')" id="mode-existing"
class="flex-1 px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white">
Assign to existing location
</button>
<button onclick="setClassifyMode('new')" id="mode-new"
class="flex-1 px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Create new location
</button>
</div>
<!-- Existing mode: project + location pickers -->
<div id="classify-existing-pane" class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<select id="existing-project-select" onchange="onExistingProjectChange()"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">Loading projects…</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
<select id="existing-location-select"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">Pick a project first</option>
</select>
</div>
</div>
<!-- New mode: project (existing/new) + location name -->
<div id="classify-new-pane" class="hidden space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<div class="flex gap-2 mt-1">
<select id="new-project-select" onchange="onNewProjectMode()"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<option value="">— Create new project —</option>
</select>
</div>
</div>
<div id="new-project-name-wrap" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">New project name</label>
<input id="new-project-name" type="text" placeholder="e.g. Carnegie Museum HVAC"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
<p class="text-xs text-gray-500 dark:text-gray-400">Project type will be Vibration Monitoring.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location name</label>
<input id="new-location-name" type="text" placeholder="e.g. NE corner, near loading dock"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" id="use-captured-coords" checked
class="rounded border-gray-300 text-seismo-orange focus:ring-seismo-orange">
Use the photo's GPS coords <span id="captured-coords-hint" class="text-xs text-gray-500 dark:text-gray-400"></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Assignment notes (optional)</label>
<textarea id="classify-notes" rows="2"
class="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm"></textarea>
</div>
<div id="classify-error" class="hidden text-sm text-red-600"></div>
</div>
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
<button onclick="closeClassifyModal()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button id="classify-submit" onclick="submitClassify()"
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Classify
</button>
</div>
</div>
</div>
<script>
let _pdState = {
currentStatus: 'awaiting',
rows: [],
classifyingId: null,
classifyingPd: null,
classifyMode: 'existing', // 'existing' | 'new'
projectsCache: null,
};
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDateTime(iso) {
if (!iso) return '—';
return iso.replace('T', ' ').slice(0, 16);
}
async function loadPdList() {
const list = document.getElementById('pd-list');
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading…</div>';
try {
const r = await fetch(`/api/deployments/pending?status=${encodeURIComponent(_pdState.currentStatus)}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_pdState.rows = data.pending_deployments || [];
renderPdList();
// Refresh awaiting count badge.
if (_pdState.currentStatus === 'awaiting') {
document.getElementById('pd-count-awaiting').textContent = data.count > 0 ? `(${data.count})` : '';
}
} catch (e) {
list.innerHTML = `<div class="text-center py-8 text-red-500 text-sm">Load failed: ${_esc(e.message)}</div>`;
}
}
function renderPdList() {
const list = document.getElementById('pd-list');
if (_pdState.rows.length === 0) {
const blurb = {
awaiting: 'No captures awaiting classification.',
assigned: 'No assigned captures yet.',
cancelled: 'No cancelled captures.',
}[_pdState.currentStatus] || '';
list.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${blurb}</div>`;
return;
}
list.innerHTML = _pdState.rows.map(pd => _renderPdCard(pd)).join('');
}
function _renderPdCard(pd) {
const photoUrl = pd.photo_url || '';
const coords = pd.coordinates
? `<span class="font-mono text-xs">${_esc(pd.coordinates)}</span>`
: '<span class="text-xs italic text-gray-400">no GPS in photo</span>';
const noteHtml = pd.operator_note
? `<p class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">"${_esc(pd.operator_note)}"</p>`
: '';
let footerActions = '';
if (pd.status === 'awaiting') {
footerActions = `<div class="flex gap-2 mt-3">
<button onclick="openClassifyModal('${_esc(pd.id)}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm rounded-lg font-medium">
Classify
</button>
<button onclick="cancelPending('${_esc(pd.id)}')"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-lg">
Cancel
</button>
</div>`;
} else if (pd.status === 'assigned') {
footerActions = `<div class="mt-3 text-xs text-green-700 dark:text-green-400">
Promoted ${_fmtDateTime(pd.promoted_at)} → assignment <span class="font-mono">${_esc((pd.resulting_assignment_id || '').slice(0, 8))}…</span>
</div>`;
} else if (pd.status === 'cancelled') {
footerActions = `<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Cancelled ${_fmtDateTime(pd.cancelled_at)}${pd.cancelled_reason ? ` — ${_esc(pd.cancelled_reason)}` : ''}
</div>`;
}
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex gap-4">
<div class="shrink-0 w-32 h-32 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-hidden">
${photoUrl
? `<img src="${_esc(photoUrl)}" class="w-full h-full object-cover" alt="install">`
: `<div class="w-full h-full flex items-center justify-center text-gray-400 text-xs">(no photo)</div>`}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a href="/unit/${_esc(pd.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">
${_esc(pd.unit_id)}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">captured ${_fmtDateTime(pd.captured_at)}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${coords}</div>
${noteHtml}
${footerActions}
</div>
</div>`;
}
function switchPdStatus(status) {
_pdState.currentStatus = status;
const tabs = { awaiting: 'pd-tab-awaiting', assigned: 'pd-tab-assigned', cancelled: 'pd-tab-cancelled' };
Object.entries(tabs).forEach(([k, id]) => {
const btn = document.getElementById(id);
if (k === status) {
btn.classList.add('bg-seismo-orange', 'text-white');
btn.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
} else {
btn.classList.remove('bg-seismo-orange', 'text-white');
btn.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600');
}
});
loadPdList();
}
// ── Classify modal ──────────────────────────────────────────────────
async function openClassifyModal(pendingId) {
const pd = _pdState.rows.find(r => r.id === pendingId);
if (!pd) return;
_pdState.classifyingId = pendingId;
_pdState.classifyingPd = pd;
document.getElementById('classify-unit-label').textContent = pd.unit_id;
document.getElementById('classify-captured-at').textContent = _fmtDateTime(pd.captured_at);
document.getElementById('classify-notes').value = '';
document.getElementById('classify-error').classList.add('hidden');
document.getElementById('new-project-name').value = '';
document.getElementById('new-location-name').value = '';
// Coords hint for "use captured coords" checkbox.
const hint = document.getElementById('captured-coords-hint');
if (pd.coordinates) {
hint.textContent = `(${pd.coordinates})`;
document.getElementById('use-captured-coords').checked = true;
document.getElementById('use-captured-coords').disabled = false;
} else {
hint.textContent = '(no GPS in photo — uncheck unless you want a placeholder)';
document.getElementById('use-captured-coords').checked = false;
document.getElementById('use-captured-coords').disabled = true;
}
setClassifyMode('existing');
document.getElementById('classify-modal').classList.remove('hidden');
// Load projects if not cached.
if (!_pdState.projectsCache) {
await _loadProjects();
}
_populateProjectSelects();
}
function closeClassifyModal() {
document.getElementById('classify-modal').classList.add('hidden');
}
async function _loadProjects() {
try {
const r = await fetch('/api/projects/list');
const data = r.ok ? await r.json() : { projects: [] };
// Endpoint shape varies; tolerate either { projects: [...] } or a flat array.
_pdState.projectsCache = Array.isArray(data) ? data : (data.projects || []);
} catch (e) {
_pdState.projectsCache = [];
}
}
function _populateProjectSelects() {
// Sort active projects first, then alphabetical.
const projs = (_pdState.projectsCache || []).slice().sort((a, b) => {
if ((a.status || '') !== (b.status || '')) {
if (a.status === 'active') return -1;
if (b.status === 'active') return 1;
}
return (a.name || '').localeCompare(b.name || '');
});
const existingSel = document.getElementById('existing-project-select');
existingSel.innerHTML = '<option value="">— Pick a project —</option>' + projs.map(p =>
`<option value="${_esc(p.id)}">${_esc(p.name)}${p.status && p.status !== 'active' ? ` (${p.status})` : ''}</option>`
).join('');
const newSel = document.getElementById('new-project-select');
newSel.innerHTML = '<option value="">— Create new project —</option>' + projs.map(p =>
`<option value="${_esc(p.id)}">${_esc(p.name)}</option>`
).join('');
}
async function onExistingProjectChange() {
const projectId = document.getElementById('existing-project-select').value;
const locSel = document.getElementById('existing-location-select');
if (!projectId) {
locSel.innerHTML = '<option value="">Pick a project first</option>';
return;
}
locSel.innerHTML = '<option value="">Loading…</option>';
try {
const r = await fetch(`/api/projects/${projectId}/locations-json?location_type=vibration`);
const locs = await r.json();
if (!Array.isArray(locs) || locs.length === 0) {
locSel.innerHTML = '<option value="">(no locations — use Create new location instead)</option>';
return;
}
locSel.innerHTML = '<option value="">— Pick a location —</option>' + locs.map(l =>
`<option value="${_esc(l.id)}">${_esc(l.name)}</option>`
).join('');
} catch (e) {
locSel.innerHTML = `<option value="">Load failed: ${_esc(e.message)}</option>`;
}
}
function setClassifyMode(mode) {
_pdState.classifyMode = mode;
const ex = document.getElementById('mode-existing');
const nw = document.getElementById('mode-new');
document.getElementById('classify-existing-pane').classList.toggle('hidden', mode !== 'existing');
document.getElementById('classify-new-pane').classList.toggle('hidden', mode !== 'new');
const activeCls = ['bg-seismo-orange', 'text-white'];
const dormantCls = ['bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300', 'hover:bg-gray-200', 'dark:hover:bg-gray-600'];
if (mode === 'existing') {
ex.classList.add(...activeCls); ex.classList.remove(...dormantCls);
nw.classList.remove(...activeCls); nw.classList.add(...dormantCls);
} else {
nw.classList.add(...activeCls); nw.classList.remove(...dormantCls);
ex.classList.remove(...activeCls); ex.classList.add(...dormantCls);
}
}
function onNewProjectMode() {
const sel = document.getElementById('new-project-select');
const wrap = document.getElementById('new-project-name-wrap');
wrap.classList.toggle('hidden', !!sel.value);
}
async function submitClassify() {
const btn = document.getElementById('classify-submit');
const errEl = document.getElementById('classify-error');
errEl.classList.add('hidden');
btn.disabled = true; btn.textContent = 'Classifying…';
const body = { notes: document.getElementById('classify-notes').value.trim() };
if (_pdState.classifyMode === 'existing') {
const locId = document.getElementById('existing-location-select').value;
if (!locId) {
errEl.textContent = 'Pick a location.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
body.location_id = locId;
} else {
const existingProj = document.getElementById('new-project-select').value;
const newName = document.getElementById('new-project-name').value.trim();
const locName = document.getElementById('new-location-name').value.trim();
if (!locName) {
errEl.textContent = 'Location name required.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
if (existingProj) {
body.project_id = existingProj;
} else {
if (!newName) {
errEl.textContent = 'Project name (or pick an existing project) required.';
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
return;
}
body.project_name = newName;
body.project_type_id = 'vibration_monitoring';
}
body.location_name = locName;
body.use_captured_coords = document.getElementById('use-captured-coords').checked;
}
try {
const r = await fetch(`/api/deployments/pending/${_pdState.classifyingId}/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
closeClassifyModal();
loadPdList();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Classify';
}
}
async function cancelPending(pendingId) {
const reason = prompt('Cancel this capture?\n\nOptional reason:');
if (reason === null) return; // user hit Cancel on the prompt
try {
const r = await fetch(`/api/deployments/pending/${pendingId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
loadPdList();
} catch (e) {
alert('Cancel failed: ' + e.message);
}
}
// Kick off the initial load.
loadPdList();
// Refresh awaiting count every 30s for the badge.
setInterval(() => {
if (_pdState.currentStatus === 'awaiting') loadPdList();
}, 30000);
</script>
{% endblock %}
+47
View File
@@ -21,6 +21,53 @@
</div> </div>
</div> </div>
<!-- Pending-deployments banner — auto-shows when there are field captures
awaiting classification. Hides itself when count is 0. Polled
alongside the rest of the dashboard's 10-second refresh. -->
<a id="pending-deploy-banner" href="/tools/pending-deployments"
class="hidden mb-6 flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<div class="text-sm font-medium text-amber-900 dark:text-amber-200">
<span id="pending-deploy-count">0</span> field deployment<span id="pending-deploy-plural">s</span> awaiting classification
</div>
<div class="text-xs text-amber-700 dark:text-amber-300">Click to pick project / location for these captures</div>
</div>
</div>
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<script>
async function _refreshPendingDeployBanner() {
try {
const r = await fetch('/api/deployments/pending?status=awaiting');
if (!r.ok) return;
const data = await r.json();
const banner = document.getElementById('pending-deploy-banner');
const countEl = document.getElementById('pending-deploy-count');
const pluralEl = document.getElementById('pending-deploy-plural');
if (data.count > 0) {
countEl.textContent = data.count;
pluralEl.textContent = data.count === 1 ? '' : 's';
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
} catch (e) {
/* silent — banner just stays hidden */
}
}
_refreshPendingDeployBanner();
setInterval(_refreshPendingDeployBanner, 30000);
</script>
<!-- Dashboard cards with auto-refresh --> <!-- Dashboard cards with auto-refresh -->
<div hx-get="/api/status-snapshot" <div hx-get="/api/status-snapshot"
hx-trigger="load, every 10s" hx-trigger="load, every 10s"
+300
View File
@@ -0,0 +1,300 @@
{% extends "base.html" %}
{% block title %}Field Deploy - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Field Deploy</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Capture an install while you're still on site. Project + location can be picked later at a desk.
</p>
</div>
<!-- Stepper -->
<div class="flex items-center justify-between mb-6 text-xs text-gray-500 dark:text-gray-400">
<div id="step-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
<span class="w-6 h-6 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-xs">1</span>
Unit
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
<div id="step-pill-2" class="flex items-center gap-1">
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">2</span>
Photo
</div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-2"></div>
<div id="step-pill-3" class="flex items-center gap-1">
<span class="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center">3</span>
Confirm
</div>
</div>
<!-- Step 1: Pick unit -->
<div id="step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which seismograph?</span>
<input id="unit-search" type="search" autocomplete="off"
placeholder="Type a serial like BE12599…"
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
oninput="onUnitSearch()">
</label>
<div id="unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
</div>
<!-- Step 2: Photo -->
<div id="step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
<p class="font-mono font-semibold text-seismo-orange" id="step2-unit-label"></p>
</div>
<button onclick="goToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange">Change</button>
</div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Install photo</span>
<input id="photo-input" type="file" accept="image/*" capture="environment"
onchange="onPhotoPicked(event)"
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
On mobile, this opens the camera. EXIF GPS is auto-extracted.
</p>
</label>
<div id="photo-preview-wrap" class="hidden">
<img id="photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="Install photo preview">
</div>
</div>
<!-- Step 3: Note + confirm -->
<div id="step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Deploying</p>
<p class="font-mono font-semibold text-seismo-orange" id="step3-unit-label"></p>
</div>
<div id="step3-photo-wrap" class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<img id="step3-photo" class="w-full" alt="Install photo">
</div>
<div id="step3-coords-status" class="text-xs"></div>
<label class="block">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Site memo (optional)</span>
<textarea id="note-input" rows="3"
placeholder="e.g. Carnegie Museum, north entrance loading dock"
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Helpful when classifying later. Free text — anything that helps you remember.
</p>
</label>
<div id="capture-error" class="hidden text-sm text-red-600"></div>
<button id="capture-submit"
onclick="submitCapture()"
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
Capture
</button>
<button onclick="goToStep(2)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Retake photo</button>
</div>
<!-- Success -->
<div id="step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4 text-center">
<div class="w-16 h-16 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Captured</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<span id="done-unit-label" class="font-mono font-semibold text-seismo-orange"></span>
is now in the pending hopper.
You can classify it from <a href="/tools/pending-deployments" class="text-seismo-orange hover:text-seismo-navy underline">Tools → Pending Deployments</a> later.
</p>
<p class="text-xs text-gray-400 mt-2" id="done-coords-label"></p>
</div>
<div class="grid grid-cols-2 gap-2">
<button onclick="resetWizard()"
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Deploy another
</button>
<a href="/tools/pending-deployments"
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium inline-flex items-center justify-center">
View pending
</a>
</div>
</div>
</div>
<script>
let _deployState = {
unit_id: null,
photo_file: null,
photo_preview_url: null,
captured: null, // server response from /capture
};
let _unitSearchDebounce = null;
async function onUnitSearch() {
if (_unitSearchDebounce) clearTimeout(_unitSearchDebounce);
_unitSearchDebounce = setTimeout(_fetchUnitList, 150);
}
async function _fetchUnitList() {
const q = document.getElementById('unit-search').value.trim();
const list = document.getElementById('unit-list');
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Searching…</p>';
try {
const r = await fetch(`/api/deployments/seismograph-picker?q=${encodeURIComponent(q)}&limit=20`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
_renderUnitList(data.units || []);
} catch (e) {
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Search failed: ${e.message}</p>`;
}
}
function _renderUnitList(units) {
const list = document.getElementById('unit-list');
if (units.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching units.</p>';
return;
}
list.innerHTML = units.map(u => {
const noteHtml = u.note ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.note)}</div>` : '';
const pendingBadge = u.has_pending
? '<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 ml-2">already pending</span>'
: '';
return `<button onclick="onPickUnit('${_esc(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">
<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>
${pendingBadge}
</div>
${noteHtml}
</button>`;
}).join('');
}
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function onPickUnit(unitId) {
_deployState.unit_id = unitId;
document.getElementById('step2-unit-label').textContent = unitId;
document.getElementById('step3-unit-label').textContent = unitId;
goToStep(2);
}
function onPhotoPicked(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
_deployState.photo_file = file;
// Show preview.
if (_deployState.photo_preview_url) URL.revokeObjectURL(_deployState.photo_preview_url);
_deployState.photo_preview_url = URL.createObjectURL(file);
document.getElementById('photo-preview').src = _deployState.photo_preview_url;
document.getElementById('step3-photo').src = _deployState.photo_preview_url;
document.getElementById('photo-preview-wrap').classList.remove('hidden');
// Advance after a tiny delay so the user sees the preview.
setTimeout(() => goToStep(3), 400);
}
function goToStep(n) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById('step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
const pill = document.getElementById('step-pill-' + i);
if (pill) {
if (i === n) {
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
pill.classList.add('text-seismo-orange', 'font-medium');
pill.querySelector('span').classList.remove('bg-gray-200', 'dark:bg-gray-700');
pill.querySelector('span').classList.add('bg-seismo-orange', 'text-white');
} else {
pill.classList.add('text-gray-500', 'dark:text-gray-400');
pill.classList.remove('text-seismo-orange', 'font-medium');
pill.querySelector('span').classList.add('bg-gray-200', 'dark:bg-gray-700');
pill.querySelector('span').classList.remove('bg-seismo-orange', 'text-white');
}
}
}
document.getElementById('step-done').classList.add('hidden');
}
async function submitCapture() {
const btn = document.getElementById('capture-submit');
const err = document.getElementById('capture-error');
const note = document.getElementById('note-input').value.trim();
err.classList.add('hidden');
if (!_deployState.unit_id || !_deployState.photo_file) {
err.textContent = 'Need a unit and a photo first.';
err.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = 'Capturing…';
const fd = new FormData();
fd.append('unit_id', _deployState.unit_id);
fd.append('operator_note', note);
fd.append('photo', _deployState.photo_file);
try {
const r = await fetch('/api/deployments/capture', {
method: 'POST',
body: fd,
});
if (!r.ok) {
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(e.detail || 'HTTP ' + r.status);
}
const data = await r.json();
_deployState.captured = data;
_showDone(data);
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Capture';
}
}
function _showDone(data) {
for (let i = 1; i <= 3; i++) {
document.getElementById('step-' + i).classList.add('hidden');
}
document.getElementById('step-done').classList.remove('hidden');
document.getElementById('done-unit-label').textContent = data.pending_deployment.unit_id;
const coords = data.extracted_coords;
const status = document.getElementById('done-coords-label');
if (coords) {
status.textContent = `GPS: ${coords}`;
} else {
status.textContent = 'No GPS in photo EXIF — you can add coordinates when classifying.';
}
}
function resetWizard() {
_deployState = { unit_id: null, photo_file: null, photo_preview_url: null, captured: null };
document.getElementById('unit-search').value = '';
document.getElementById('note-input').value = '';
document.getElementById('photo-input').value = '';
document.getElementById('photo-preview-wrap').classList.add('hidden');
document.getElementById('capture-error').classList.add('hidden');
const btn = document.getElementById('capture-submit');
btn.disabled = false; btn.textContent = 'Capture';
goToStep(1);
_fetchUnitList();
}
// Kick off initial unit list on page load.
_fetchUnitList();
</script>
{% endblock %}
+37
View File
@@ -14,6 +14,43 @@
<!-- Card grid --> <!-- Card grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Field Deploy (mobile-first) -->
<a href="/deploy"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-seismo-orange flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Field Deploy</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
On site? Pick a unit, snap an install photo, leave. GPS is auto-captured. Classify the project/location later from a desk.
</p>
</div>
</div>
</a>
<!-- Pending Deployments (the hopper) -->
<a href="/tools/pending-deployments"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Pending Deployments</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Field captures waiting to be classified into a project + location.
</p>
</div>
</div>
</a>
<!-- Pair Devices --> <!-- Pair Devices -->
<a href="/pair-devices" <a href="/pair-devices"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange"> class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">