d3b5a3fd26
Operator no longer has to accept the parser's suggested project /
location verbatim. Each cluster card now has editable typeahead inputs
that search existing projects (and existing locations within the chosen
project), with a "Create new: <typed>" fallback always available.
Solves the I-80-North-Fork case: of the 20+ cluster variants
("I-80-North Fork Bridges-I80 E. Abutment", "I-80- North Fork
Bridges-543 Plank Rd", etc.), operator types "I-80" in the Project
input, picks the existing project from the dropdown, and the cluster
attaches to it. Repeat for the other variants. No need to pre-create
the canonical project — though pre-creation still works fine if you'd
rather.
Backend (backend/routers/metadata_backfill.py):
- GET /api/admin/metadata_backfill/projects_search?q=&limit=
Returns existing projects matching by case-insensitive substring OR
rapidfuzz WRatio score >= 0.50. Substring matches sort to the top
(treated as exact for ordering). Includes location_count and
project_number/client_name in each result for disambiguation. Always
emits a "Create new: <q>" suggestion alongside the matches.
- GET /api/admin/metadata_backfill/locations_search?project_id=&q=&limit=
Same shape, scoped to a single project's vibration locations.
- POST /api/admin/metadata_backfill/apply now accepts four override
keys per cluster (was previously two):
project_id → attach to existing Project (operator picked from
typeahead)
project_name → create new with this name (operator typed a
custom name; existing project_name behaviour)
location_id → attach to existing MonitoringLocation; validated
against the chosen project_id so a stale location
FK can't sneak in
location_name → create new location with this name
Frontend (templates/admin/metadata_backfill.html):
- Each non-blank-meta cluster card now has two editable typeahead inputs
(Project + Location) pre-populated with the parser's suggested
values. Old static "Project: + Create new: X" / "≈ Fuzzy match" pills
replaced with compact hint lines under the inputs showing what the
current value will do.
- Typeahead dropdown opens on focus, debounced 150ms on type. Shows
matched existing entities with score badges (exact / NN%) plus a
"Create new: <typed>" option at the bottom. Click-to-pick fills the
text input and writes the entity id into a hidden field.
- Picking a new project clears the location id (forces re-pick under
the new project, avoids cross-project location FKs).
- _gatherOverrides re-wired to emit the new project_id / location_id
keys when the operator picked from the dropdown, falling back to
*_name when they typed free-form.
Backward-compatible: blank-meta clusters keep their existing "project_name
/ location_name" plain inputs and the override path still honours them.
Verified end-to-end:
- /projects_search?q=I-80 returns the existing "I-80 - North Fork
Bridge" project (score 1.0, has 4 locations) plus a "Create new"
option.
- /locations_search requires project_id (400 without it).
- Wizard page renders with typeahead wiring confirmed in HTML.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
638 lines
34 KiB
HTML
638 lines
34 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Metadata Backfill - Seismo Fleet Manager{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Breadcrumb -->
|
|
<div class="mb-6">
|
|
<nav class="flex items-center space-x-2 text-sm">
|
|
<a href="/settings" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
</svg>
|
|
Settings
|
|
</a>
|
|
<svg class="w-4 h-4 text-gray-400" 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"></path>
|
|
</svg>
|
|
<span class="text-gray-900 dark:text-white font-medium">Metadata Backfill</span>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Backfill from event metadata</h1>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
|
Auto-create projects, locations, and unit assignments from operator-typed metadata on Blastware events.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Summary card (populated after scan) -->
|
|
<div id="summary-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
|
<div id="summary-initial">
|
|
<div class="text-center py-8">
|
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Scan SFM events</h2>
|
|
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6 max-w-xl mx-auto">
|
|
Reads all events from SFM, clusters them by serial & time, matches the
|
|
operator-typed metadata against your existing projects, and proposes
|
|
<strong>Project</strong> / <strong>Location</strong> / <strong>UnitAssignment</strong>
|
|
chains to create.
|
|
</p>
|
|
<button onclick="runScan(false)"
|
|
id="initial-scan-btn"
|
|
class="px-6 py-3 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
|
↻ Run scan
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="summary-results" class="hidden">
|
|
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scan summary</h2>
|
|
<p id="summary-scanned-at" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
|
|
</div>
|
|
<button onclick="runScan(true)"
|
|
class="px-3 py-1.5 text-sm 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">
|
|
↻ Re-scan
|
|
</button>
|
|
</div>
|
|
|
|
<!-- KPI tiles -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events scanned</span>
|
|
<span id="kpi-scanned" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Already attributed</span>
|
|
<span id="kpi-already" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">inside existing assignments</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pending review</span>
|
|
<span id="kpi-pending" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">clusters to attribute</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Conflicts</span>
|
|
<span id="kpi-conflicts" class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">need manual reconciliation</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- One-click bulk apply -->
|
|
<div id="bulk-apply-card" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-4 hidden">
|
|
<div class="flex items-start gap-3">
|
|
<svg class="w-6 h-6 text-seismo-orange shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
</svg>
|
|
<div class="flex-1">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">
|
|
Bulk-apply <span id="bulk-applicable-count">0</span> high-confidence cluster(s)
|
|
</h3>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
|
Apply every cluster scored <strong>high confidence</strong> with no blocking conflicts.
|
|
Will create <span id="bulk-stats" class="font-medium">—</span>.
|
|
Medium and low confidence clusters remain in the list below for individual review.
|
|
</p>
|
|
<button onclick="applyBulkHighConfidence()"
|
|
class="px-5 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
|
Apply all high-confidence
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
|
|
Each cluster below shows the operator-typed metadata, what would be created or matched, and the proposed
|
|
assignment date window. Click <em>Apply</em> to attribute that cluster, <em>Skip</em> to ignore it (won't reappear),
|
|
or <em>Edit</em> to rename before applying.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cluster list -->
|
|
<div id="cluster-list" class="space-y-3"></div>
|
|
|
|
<!-- Apply progress toast -->
|
|
<div id="apply-toast" class="hidden fixed bottom-6 right-6 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 p-4 z-50 max-w-md">
|
|
<div class="flex items-center gap-3">
|
|
<div id="toast-icon" class="shrink-0">
|
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
|
|
</div>
|
|
<div class="flex-1">
|
|
<p id="toast-message" class="text-sm font-medium text-gray-900 dark:text-white">Applying…</p>
|
|
<p id="toast-sub" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Shared event-detail modal (Preview event button uses it) -->
|
|
{% include 'partials/event_detail_modal.html' %}
|
|
<script src="/static/event-modal.js"></script>
|
|
|
|
<script>
|
|
let _scanData = null;
|
|
|
|
function _esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
return iso.slice(0, 10);
|
|
}
|
|
|
|
function _fmtDateTime(iso) {
|
|
if (!iso) return '—';
|
|
return iso.slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
function _confidenceBadge(c) {
|
|
const map = {
|
|
high: { cls: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: '🟢', label: 'high' },
|
|
medium: { cls: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', icon: '🟡', label: 'medium' },
|
|
low: { cls: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: '🔴', label: 'low' },
|
|
};
|
|
const e = map[c] || map.low;
|
|
return `<span class="px-2 py-0.5 rounded text-xs font-medium ${e.cls}">${e.icon} ${e.label}</span>`;
|
|
}
|
|
|
|
function _matchPill(match, score, suggestedName, existingName) {
|
|
if (match === 'exact') {
|
|
return `<span class="font-medium text-green-700 dark:text-green-400">✓ Matches existing: <em>${_esc(existingName || suggestedName)}</em></span>`;
|
|
}
|
|
if (match === 'fuzzy') {
|
|
return `<span class="font-medium text-amber-700 dark:text-amber-400">≈ Fuzzy match (${(score*100).toFixed(0)}%): <em>${_esc(existingName)}</em></span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">(your value: "${_esc(suggestedName)}")</span>`;
|
|
}
|
|
if (match === 'ambiguous') {
|
|
return `<span class="font-medium text-yellow-700 dark:text-yellow-400">? Ambiguous — multiple matches</span>`;
|
|
}
|
|
return `<span class="font-medium text-seismo-orange">+ Create new: <em>${_esc(suggestedName)}</em></span>`;
|
|
}
|
|
|
|
// Compact "hint" line under each typeahead input, showing what the parser
|
|
// thinks the current value will do (match existing vs create new).
|
|
function _matchHint(match, score, existingName, suggestedName) {
|
|
if (match === 'exact') {
|
|
return `<span class="text-green-700 dark:text-green-400">✓ matches existing</span>`;
|
|
}
|
|
if (match === 'fuzzy') {
|
|
return `<span class="text-amber-700 dark:text-amber-400">≈ fuzzy match to "${_esc(existingName)}" (${(score*100).toFixed(0)}%)</span>`;
|
|
}
|
|
if (match === 'ambiguous') {
|
|
return `<span class="text-yellow-700 dark:text-yellow-400">? ambiguous — pick from dropdown</span>`;
|
|
}
|
|
return `<span class="text-seismo-orange">+ will create new</span>`;
|
|
}
|
|
|
|
// ── Typeahead ────────────────────────────────────────────────────────────
|
|
// Per-cluster project + location inputs with debounced typeahead search.
|
|
// Selecting a result writes the existing entity's id into the hidden
|
|
// project_id / location_id input; clearing-and-typing falls back to
|
|
// "create new" semantics.
|
|
|
|
let _typeaheadDebounce = null;
|
|
|
|
function onTypeaheadInput(e, fieldKind) {
|
|
// fieldKind ∈ {'project', 'location'}
|
|
const inp = e.target;
|
|
const cid = inp.dataset.clusterId;
|
|
// Clear the "id" hidden input — operator is typing freely now.
|
|
const hidden = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="${fieldKind}_id"]`);
|
|
if (hidden) hidden.value = '';
|
|
// Debounce the search.
|
|
if (_typeaheadDebounce) clearTimeout(_typeaheadDebounce);
|
|
_typeaheadDebounce = setTimeout(() => _fetchTypeahead(inp, fieldKind), 150);
|
|
}
|
|
|
|
function onTypeaheadFocus(e, fieldKind) {
|
|
_fetchTypeahead(e.target, fieldKind);
|
|
}
|
|
|
|
function onTypeaheadBlur(e) {
|
|
// Delayed hide so a click on the dropdown can register first.
|
|
const dropdown = e.target.parentElement.querySelector('.typeahead-dropdown');
|
|
if (dropdown) {
|
|
setTimeout(() => dropdown.classList.add('hidden'), 150);
|
|
}
|
|
}
|
|
|
|
async function _fetchTypeahead(input, fieldKind) {
|
|
const dropdown = input.parentElement.querySelector('.typeahead-dropdown');
|
|
if (!dropdown) return;
|
|
const q = input.value.trim();
|
|
const cid = input.dataset.clusterId;
|
|
|
|
let url;
|
|
if (fieldKind === 'project') {
|
|
url = `/api/admin/metadata_backfill/projects_search?q=${encodeURIComponent(q)}`;
|
|
} else {
|
|
// For locations, scope to the currently-chosen project (if any).
|
|
const projectIdInput = document.querySelector(`input[type="hidden"][data-cluster-id="${cid}"][data-field="project_id"]`);
|
|
const projectId = projectIdInput ? projectIdInput.value : '';
|
|
if (!projectId) {
|
|
// Operator hasn't picked an existing project — there are no
|
|
// existing locations to match against (location is implicitly
|
|
// "create new" inside a new project).
|
|
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
|
|
${q ? `+ Will create new: <strong>"${_esc(q)}"</strong>` : 'Pick a project first, or type a new location name.'}
|
|
</div>`;
|
|
dropdown.classList.remove('hidden');
|
|
return;
|
|
}
|
|
url = `/api/admin/metadata_backfill/locations_search?project_id=${encodeURIComponent(projectId)}&q=${encodeURIComponent(q)}`;
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
data = await r.json();
|
|
} catch (err) {
|
|
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-red-500">Search failed: ${_esc(err.message)}</div>`;
|
|
dropdown.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
const items = [];
|
|
for (const m of (data.matches || [])) {
|
|
items.push({ kind: 'match', payload: m });
|
|
}
|
|
if (data.create_new && data.create_new.label) {
|
|
items.push({ kind: 'create_new', label: data.create_new.label, name: q });
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
dropdown.innerHTML = `<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">No matches. Type to create.</div>`;
|
|
dropdown.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
dropdown.innerHTML = items.map((it, idx) => {
|
|
if (it.kind === 'match') {
|
|
const m = it.payload;
|
|
const scoreBadge = m.score >= 0.99
|
|
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
|
: `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">${(m.score*100).toFixed(0)}%</span>`;
|
|
const meta = [];
|
|
if (fieldKind === 'project') {
|
|
if (m.project_number) meta.push(_esc(m.project_number));
|
|
if (m.client_name) meta.push(_esc(m.client_name));
|
|
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
|
} else {
|
|
if (m.address) meta.push(_esc(m.address));
|
|
}
|
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
|
return `<button type="button"
|
|
onmousedown="event.preventDefault()"
|
|
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
|
|
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
|
${metaLine}
|
|
</button>`;
|
|
}
|
|
return `<button type="button"
|
|
onmousedown="event.preventDefault()"
|
|
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
|
|
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
|
+ ${_esc(it.label)}
|
|
</button>`;
|
|
}).join('');
|
|
dropdown.classList.remove('hidden');
|
|
}
|
|
|
|
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
|
// entityId is empty string for "create new", or a UUID for matched existing.
|
|
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
|
let textInput = null;
|
|
let idInput = null;
|
|
inputs.forEach(i => {
|
|
if (i.dataset.field === fieldKind) textInput = i;
|
|
if (i.dataset.field === fieldKind + '_id') idInput = i;
|
|
});
|
|
if (textInput) textInput.value = name;
|
|
if (idInput) idInput.value = entityId;
|
|
// Hide this dropdown.
|
|
const dropdown = textInput.parentElement.querySelector('.typeahead-dropdown');
|
|
if (dropdown) dropdown.classList.add('hidden');
|
|
|
|
// If operator just picked a NEW project, clear the location id (forces
|
|
// operator to pick a location under the new project rather than leaving
|
|
// a stale id from another project).
|
|
if (fieldKind === 'project') {
|
|
const locId = document.querySelector(`input[type="hidden"][data-cluster-id="${clusterId}"][data-field="location_id"]`);
|
|
if (locId) locId.value = '';
|
|
}
|
|
}
|
|
|
|
async function runScan(force) {
|
|
const initial = document.getElementById('summary-initial');
|
|
const results = document.getElementById('summary-results');
|
|
const list = document.getElementById('cluster-list');
|
|
|
|
initial.classList.add('hidden');
|
|
results.classList.remove('hidden');
|
|
list.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Scanning events…</div>';
|
|
|
|
document.getElementById('kpi-scanned').textContent = '…';
|
|
document.getElementById('kpi-already').textContent = '…';
|
|
document.getElementById('kpi-pending').textContent = '…';
|
|
document.getElementById('kpi-conflicts').textContent = '…';
|
|
document.getElementById('bulk-apply-card').classList.add('hidden');
|
|
|
|
try {
|
|
const r = await fetch('/api/admin/metadata_backfill/scan' + (force ? '?force=true' : ''));
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
_scanData = await r.json();
|
|
} catch (e) {
|
|
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center text-red-500">Scan failed: ${_esc(e.message)}</div>`;
|
|
return;
|
|
}
|
|
|
|
document.getElementById('kpi-scanned').textContent = _scanData.scanned_event_count.toLocaleString();
|
|
document.getElementById('kpi-already').textContent = _scanData.already_attributed.toLocaleString();
|
|
document.getElementById('kpi-pending').textContent = _scanData.pending_count.toLocaleString();
|
|
document.getElementById('kpi-conflicts').textContent = _scanData.blocking_conflict_count.toLocaleString();
|
|
document.getElementById('summary-scanned-at').textContent =
|
|
'Scanned ' + new Date(_scanData.scanned_at * 1000).toLocaleString();
|
|
|
|
// Configure bulk-apply card.
|
|
const highApplicable = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
|
|
const newProjects = new Set(), newLocations = new Set();
|
|
for (const s of highApplicable) {
|
|
if (s.project_match === 'create_new') newProjects.add(s.project_suggested_name.toLowerCase());
|
|
if (s.location_match === 'create_new') newLocations.add(s.location_suggested_name.toLowerCase());
|
|
}
|
|
if (highApplicable.length > 0) {
|
|
document.getElementById('bulk-apply-card').classList.remove('hidden');
|
|
document.getElementById('bulk-applicable-count').textContent = highApplicable.length;
|
|
const parts = [];
|
|
if (newProjects.size > 0) parts.push(`${newProjects.size} project${newProjects.size === 1 ? '' : 's'}`);
|
|
if (newLocations.size > 0) parts.push(`${newLocations.size} location${newLocations.size === 1 ? '' : 's'}`);
|
|
parts.push(`${highApplicable.length} assignment${highApplicable.length === 1 ? '' : 's'}`);
|
|
document.getElementById('bulk-stats').textContent = parts.join(' · ');
|
|
}
|
|
|
|
renderClusterList();
|
|
}
|
|
|
|
function renderClusterList() {
|
|
const list = document.getElementById('cluster-list');
|
|
const all = [
|
|
..._scanData.by_confidence.high,
|
|
..._scanData.by_confidence.medium,
|
|
..._scanData.by_confidence.low,
|
|
];
|
|
|
|
if (all.length === 0) {
|
|
list.innerHTML = `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center">
|
|
<svg class="w-16 h-16 mx-auto mb-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">✅ All caught up</h3>
|
|
<p class="text-gray-500 dark:text-gray-400">Every event in SFM is either attributed to an existing assignment or has been skipped.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = all.map(_renderCluster).join('');
|
|
}
|
|
|
|
function _renderCluster(s) {
|
|
const spanDays = (new Date(s.last_event_ts) - new Date(s.first_event_ts)) / 86400000;
|
|
const consistencyNote = s.metadata_consistency < 1.0
|
|
? `<span class="ml-2 text-xs text-amber-600 dark:text-amber-400" title="Some events in this cluster have slightly different metadata — possibly a typo or mid-stream change.">⚠ ${(s.metadata_consistency*100).toFixed(0)}% consistent</span>`
|
|
: '';
|
|
|
|
const blockingBanner = s.blocking_conflict
|
|
? `<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mt-3 text-sm text-red-800 dark:text-red-300">
|
|
<strong>⚠ Blocking conflict.</strong>
|
|
${s.conflicts.map(c => `Unit ${_esc(s.serial)} is already assigned to <em>${_esc(c.other_project_name)} / ${_esc(c.other_location_name)}</em> during this window.`).join(' ')}
|
|
Resolve manually before this cluster can be applied.
|
|
</div>`
|
|
: '';
|
|
|
|
const orphanInputs = s.is_blank_meta
|
|
? `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 mt-3">
|
|
<p class="text-sm text-amber-800 dark:text-amber-300 mb-2"><strong>⚠ Blank metadata.</strong> Operator didn't type project / location for these events. Fill in manually:</p>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<input type="text" placeholder="Project name" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_name"
|
|
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
<input type="text" placeholder="Location name" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_name"
|
|
class="px-2 py-1 text-sm border border-amber-300 dark:border-amber-700 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
</div>
|
|
</div>`
|
|
: '';
|
|
|
|
return `<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4" data-cluster-id="${_esc(s.cluster_id)}">
|
|
<div class="flex items-start justify-between gap-3 mb-3 flex-wrap">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
|
${_confidenceBadge(s.confidence)}
|
|
<a href="/unit/${_esc(s.serial)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${_esc(s.serial)}</a>
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">${_fmtDate(s.first_event_ts)} → ${_fmtDate(s.last_event_ts)}</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">(${s.event_count} event${s.event_count === 1 ? '' : 's'}, ${spanDays.toFixed(0)}d span)</span>
|
|
${consistencyNote}
|
|
</div>
|
|
<div class="text-sm text-gray-700 dark:text-gray-300 mt-2 space-y-2">
|
|
<!-- Project typeahead -->
|
|
<div class="flex items-start gap-2">
|
|
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Project:</span>
|
|
<div class="flex-1 relative">
|
|
<input type="text"
|
|
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
|
value="${_esc(s.project_existing_name || s.project_suggested_name)}"
|
|
data-cluster-id="${_esc(s.cluster_id)}"
|
|
data-field="project"
|
|
data-initial-project-id="${_esc(s.project_existing_id || '')}"
|
|
placeholder="Type to search or create…"
|
|
oninput="onTypeaheadInput(event, 'project')"
|
|
onfocus="onTypeaheadFocus(event, 'project')"
|
|
onblur="onTypeaheadBlur(event)"
|
|
autocomplete="off">
|
|
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="project_id" value="${_esc(s.project_existing_id || '')}">
|
|
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
|
<div class="text-xs mt-0.5">${_matchHint(s.project_match, s.project_match_score, s.project_existing_name, s.project_suggested_name)}</div>
|
|
${(s.project_root && s.project_raw && s.project_root !== s.project_raw)
|
|
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">↳ stripped trailing "Loc N" suffix; operator typed: <em>"${_esc(s.project_raw)}"</em></div>`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location typeahead -->
|
|
<div class="flex items-start gap-2">
|
|
<span class="text-gray-500 dark:text-gray-400 w-24 shrink-0 pt-1.5">Location:</span>
|
|
<div class="flex-1 relative">
|
|
<input type="text"
|
|
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
|
value="${_esc(s.location_existing_name || s.location_suggested_name)}"
|
|
data-cluster-id="${_esc(s.cluster_id)}"
|
|
data-field="location"
|
|
data-initial-location-id="${_esc(s.location_existing_id || '')}"
|
|
placeholder="Type to search or create…"
|
|
oninput="onTypeaheadInput(event, 'location')"
|
|
onfocus="onTypeaheadFocus(event, 'location')"
|
|
onblur="onTypeaheadBlur(event)"
|
|
autocomplete="off">
|
|
<input type="hidden" data-cluster-id="${_esc(s.cluster_id)}" data-field="location_id" value="${_esc(s.location_existing_id || '')}">
|
|
<div class="typeahead-dropdown hidden absolute z-20 left-0 right-0 mt-1 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
|
<div class="text-xs mt-0.5">${_matchHint(s.location_match, s.location_match_score, s.location_existing_name, s.location_suggested_name)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Assignment:</span> ${_fmtDateTime(s.proposed_assigned_at)} → ${s.proposed_assigned_until ? _fmtDateTime(s.proposed_assigned_until) : '<span class="text-green-700 dark:text-green-400 font-medium">present (active)</span>'}</div>
|
|
${s.client_raw ? `<div><span class="text-gray-500 dark:text-gray-400 w-24 inline-block">Client:</span> <em>${_esc(s.client_raw)}</em></div>` : ''}
|
|
</div>
|
|
${blockingBanner}
|
|
${orphanInputs}
|
|
</div>
|
|
<div class="flex flex-col gap-2 shrink-0">
|
|
<button onclick="showEventDetail('${_esc(s.sample_event_id)}')"
|
|
class="px-3 py-1.5 text-xs 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">
|
|
Preview event
|
|
</button>
|
|
${s.blocking_conflict
|
|
? `<button disabled class="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-400 rounded cursor-not-allowed">Apply</button>`
|
|
: `<button onclick="applyOne('${_esc(s.cluster_id)}')"
|
|
class="px-3 py-1.5 text-xs bg-seismo-orange hover:bg-seismo-navy text-white rounded font-medium">
|
|
Apply
|
|
</button>`}
|
|
<button onclick="skipOne('${_esc(s.cluster_id)}')"
|
|
class="px-3 py-1.5 text-xs 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">
|
|
Skip
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function _gatherOverrides(clusterIds) {
|
|
// Per-cluster overrides sent to /apply. The backend understands four
|
|
// keys per cluster: project_id, project_name, location_id, location_name.
|
|
// We emit project_id+location_id when the operator picked from the
|
|
// typeahead dropdown; we emit project_name+location_name when they
|
|
// typed a free-form value (no id selected) that differs from the
|
|
// parser's original suggestion.
|
|
const overrides = {};
|
|
for (const cid of clusterIds) {
|
|
const inputs = document.querySelectorAll(`input[data-cluster-id="${cid}"]`);
|
|
if (inputs.length === 0) continue;
|
|
const o = {};
|
|
let projectText = null, projectId = null;
|
|
let locationText = null, locationId = null;
|
|
// Old-style flat fields (kept for blank-meta cluster inputs which
|
|
// use data-field="project_name" / "location_name").
|
|
let projectNameRaw = null, locationNameRaw = null;
|
|
inputs.forEach(i => {
|
|
const v = (i.value || '').trim();
|
|
const f = i.dataset.field;
|
|
if (f === 'project') projectText = v;
|
|
else if (f === 'project_id') projectId = v;
|
|
else if (f === 'location') locationText = v;
|
|
else if (f === 'location_id') locationId = v;
|
|
else if (f === 'project_name') projectNameRaw = v;
|
|
else if (f === 'location_name') locationNameRaw = v;
|
|
});
|
|
|
|
if (projectId) {
|
|
o.project_id = projectId;
|
|
} else if (projectText) {
|
|
o.project_name = projectText;
|
|
} else if (projectNameRaw) {
|
|
o.project_name = projectNameRaw;
|
|
}
|
|
|
|
if (locationId) {
|
|
o.location_id = locationId;
|
|
} else if (locationText) {
|
|
o.location_name = locationText;
|
|
} else if (locationNameRaw) {
|
|
o.location_name = locationNameRaw;
|
|
}
|
|
|
|
if (Object.keys(o).length > 0) overrides[cid] = o;
|
|
}
|
|
return overrides;
|
|
}
|
|
|
|
function _showToast(message, sub, kind) {
|
|
const toast = document.getElementById('apply-toast');
|
|
const icon = document.getElementById('toast-icon');
|
|
document.getElementById('toast-message').textContent = message;
|
|
document.getElementById('toast-sub').textContent = sub || '';
|
|
if (kind === 'success') {
|
|
icon.innerHTML = '<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
|
} else if (kind === 'error') {
|
|
icon.innerHTML = '<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>';
|
|
} else {
|
|
icon.innerHTML = '<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>';
|
|
}
|
|
toast.classList.remove('hidden');
|
|
}
|
|
|
|
function _hideToast(after) {
|
|
setTimeout(() => document.getElementById('apply-toast').classList.add('hidden'), after || 3000);
|
|
}
|
|
|
|
async function _apply(clusterIds) {
|
|
if (clusterIds.length === 0) return;
|
|
_showToast(`Applying ${clusterIds.length} cluster${clusterIds.length === 1 ? '' : 's'}…`);
|
|
try {
|
|
const r = await fetch('/api/admin/metadata_backfill/apply', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
cluster_ids: clusterIds,
|
|
overrides: _gatherOverrides(clusterIds),
|
|
}),
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const d = await r.json();
|
|
const sub = `${d.applied} applied · ${d.project_ids_created.length} new project(s) · ${d.location_ids_created.length} new location(s)` + (d.failed.length ? ` · ${d.failed.length} failed` : '');
|
|
_showToast(`${d.applied} cluster${d.applied === 1 ? '' : 's'} applied`, sub, d.failed.length ? 'error' : 'success');
|
|
_hideToast(4000);
|
|
await runScan(true); // refresh
|
|
} catch (e) {
|
|
_showToast('Apply failed', e.message, 'error');
|
|
_hideToast(5000);
|
|
}
|
|
}
|
|
|
|
async function applyOne(clusterId) { return _apply([clusterId]); }
|
|
|
|
async function applyBulkHighConfidence() {
|
|
const high = _scanData.by_confidence.high.filter(s => !s.blocking_conflict);
|
|
const ids = high.map(s => s.cluster_id);
|
|
if (ids.length === 0) return;
|
|
if (!confirm(`Apply ${ids.length} high-confidence cluster${ids.length === 1 ? '' : 's'}? This will create projects, locations, and assignments without further prompting.`)) return;
|
|
return _apply(ids);
|
|
}
|
|
|
|
async function skipOne(clusterId) {
|
|
if (!confirm('Skip this cluster? It will not reappear in future scans.')) return;
|
|
_showToast('Skipping…');
|
|
try {
|
|
const r = await fetch('/api/admin/metadata_backfill/skip', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ cluster_ids: [clusterId] }),
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
_showToast('Skipped', '', 'success');
|
|
_hideToast(2000);
|
|
await runScan(true);
|
|
} catch (e) {
|
|
_showToast('Skip failed', e.message, 'error');
|
|
_hideToast(4000);
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|