merge v0.12.0 #51

Merged
serversdown merged 12 commits from dev into main 2026-05-17 19:44:57 -04:00
2 changed files with 232 additions and 0 deletions
Showing only changes of commit 2b8e9168c3 - Show all commits
+28
View File
@@ -138,14 +138,26 @@ def get_deployment_history_data(
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all() p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
} if proj_ids else {} } if proj_ids else {}
# Resolve location names in one batch query (used by the Gantt view
# for per-bar tooltips).
from backend.models import MonitoringLocation
loc_ids = {a.location_id for a in assignments}
loc_name_map = {
l.id: l.name for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_ids else {}
# Compute "active days per project" by walking each assignment and # Compute "active days per project" by walking each assignment and
# adding every day in its [start, end] ∩ [first_date, last_date]. # adding every day in its [start, end] ∩ [first_date, last_date].
# O(N_assignments × avg_window_days); for a typical fleet this is # O(N_assignments × avg_window_days); for a typical fleet this is
# bounded (hundreds of assignments × hundreds of days = manageable). # bounded (hundreds of assignments × hundreds of days = manageable).
# Also collect raw per-assignment bar data for the Gantt view.
project_active_days: dict[str, set[date]] = {} project_active_days: dict[str, set[date]] = {}
project_first_active: dict[str, date] = {} project_first_active: dict[str, date] = {}
project_last_active: dict[str, date] = {} project_last_active: dict[str, date] = {}
project_assignment_count: dict[str, int] = {} project_assignment_count: dict[str, int] = {}
project_bars: dict[str, list[dict]] = {}
distinct_units: set[str] = set() distinct_units: set[str] = set()
for a in assignments: for a in assignments:
@@ -169,6 +181,20 @@ def get_deployment_history_data(
if prev_last is None or end > prev_last: if prev_last is None or end > prev_last:
project_last_active[a.project_id] = end project_last_active[a.project_id] = end
# Per-assignment bar data — used by the Gantt view's renderer.
# `is_active` reflects whether the assignment_until was still NULL
# at fetch time (open-ended deployment); the clipped `end` here
# is just for visual bar drawing.
project_bars.setdefault(a.project_id, []).append({
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
"start": start.isoformat(),
"end": end.isoformat(),
"is_active": a.assigned_until is None,
"source": a.source,
})
# Build the projects array (sorted by first_active ascending so the # Build the projects array (sorted by first_active ascending so the
# legend reads in deployment-order). # legend reads in deployment-order).
projects_data = [] projects_data = []
@@ -186,6 +212,7 @@ def get_deployment_history_data(
"assignment_count": project_assignment_count.get(pid, 0), "assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
"bars": project_bars.get(pid, []),
}) })
continue continue
projects_data.append({ projects_data.append({
@@ -197,6 +224,7 @@ def get_deployment_history_data(
"assignment_count": project_assignment_count.get(pid, 0), "assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
"bars": project_bars.get(pid, []),
}) })
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
+204
View File
@@ -97,6 +97,18 @@
</div> </div>
</div> </div>
<!-- View tabs: Calendar | Gantt -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Calendar
</button>
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Gantt
</button>
</div>
<!-- KPI strip --> <!-- KPI strip -->
<div class="flex flex-wrap items-center gap-4 mb-6"> <div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow"> <div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
@@ -124,6 +136,9 @@
{% endif %} {% endif %}
</div> </div>
<!-- ─── Calendar view ─── -->
<div id="dh-view-calendar">
<!-- Calendar grid --> <!-- Calendar grid -->
{% if calendar.projects %} {% if calendar.projects %}
<div class="dh-calendar-grid mb-6"> <div class="dh-calendar-grid mb-6">
@@ -211,6 +226,52 @@
</div> </div>
{% endif %} {% endif %}
</div> {# /#dh-view-calendar #}
<!-- ─── Gantt view ─── -->
<div id="dh-view-gantt" class="hidden">
{% if calendar.projects %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
One row per project. Each bar is one assignment window (unit at a location).
Hover for details, click to open the project.
Active deployments end at "now" with a small white tip.
</p>
<svg id="dh-gantt-svg" preserveAspectRatio="none"
style="width: 100%; min-width: 800px;"></svg>
</div>
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<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="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
</span>
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#gantt"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<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="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="/tools/deployment-history#gantt"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Recent
</a>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
<p class="text-sm">No deployments in this window.</p>
</div>
{% endif %}
</div> {# /#dh-view-gantt #}
<!-- Day-detail side panel --> <!-- Day-detail side panel -->
<div id="dh-day-panel-backdrop" <div id="dh-day-panel-backdrop"
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity" class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
@@ -342,5 +403,148 @@ function closeDhDayPanel() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDhDayPanel(); if (e.key === 'Escape') closeDhDayPanel();
}); });
// ── Tab switcher ────────────────────────────────────────────────────
function switchDhView(which) {
const cal = document.getElementById('dh-view-calendar');
const gantt = document.getElementById('dh-view-gantt');
const btnC = document.getElementById('dh-tab-calendar');
const btnG = document.getElementById('dh-tab-gantt');
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
if (which === 'gantt') {
cal.classList.add('hidden');
gantt.classList.remove('hidden');
btnG.classList.add(...activeCls); btnG.classList.remove(...dormantCls);
btnC.classList.remove(...activeCls); btnC.classList.add(...dormantCls);
if (window.location.hash !== '#gantt') {
history.replaceState(null, '', window.location.pathname + window.location.search + '#gantt');
}
renderDhGantt();
} else {
gantt.classList.add('hidden');
cal.classList.remove('hidden');
btnC.classList.add(...activeCls); btnC.classList.remove(...dormantCls);
btnG.classList.remove(...activeCls); btnG.classList.add(...dormantCls);
if (window.location.hash === '#gantt') {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
}
// Land on the Gantt view if URL hash is #gantt.
if (window.location.hash === '#gantt') {
document.addEventListener('DOMContentLoaded', () => switchDhView('gantt'));
}
// ── Gantt renderer ──────────────────────────────────────────────────
// Server-rendered data: per-project list with each project's bars
// (assignment windows clipped to the visible window).
const _dhProjects = {{ calendar.projects | tojson }};
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
let _ganttRendered = false;
function renderDhGantt() {
if (_ganttRendered) return; // build once; data doesn't change after page load
_ganttRendered = true;
const svg = document.getElementById('dh-gantt-svg');
if (!svg) return;
if (!_dhProjects || _dhProjects.length === 0) {
svg.innerHTML = '';
return;
}
const isDark = document.documentElement.classList.contains('dark');
const labelColor = isDark ? '#9ca3af' : '#6b7280';
const gridColor = isDark ? '#374151' : '#e5e7eb';
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
const todayColor = '#f48b1c';
// Geometry. The Gantt SVG has a left "labels" gutter and a right
// time-axis area. Width is fluid (matches container); height grows
// with the number of project rows.
const containerW = svg.parentElement.clientWidth || 1000;
const width = Math.max(containerW, 800);
const labelW = 220;
const padTop = 36; // room for month labels above
const padBottom = 16;
const rowH = 36;
const barH = 18;
const height = padTop + padBottom + _dhProjects.length * rowH;
const usableW = width - labelW - 8;
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
const tRange = tEnd - tStart;
const xFor = (d) => {
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
};
// Month gridlines + month labels along the top.
// Walk every 1st-of-month inside the window.
const monthsParts = [];
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
cursor.setUTCDate(1);
while (cursor.getTime() <= tEnd) {
const x = xFor(cursor);
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
// Today marker.
const now = Date.now();
let todayMarker = '';
if (now >= tStart && now <= tEnd) {
const x = xFor(now);
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
}
// Build rows.
const rowParts = [];
_dhProjects.forEach((proj, idx) => {
const y = padTop + idx * rowH;
// Alternating row background.
if (idx % 2 === 1) {
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
}
// Project label (clipped to gutter, clickable).
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
font-family="system-ui,sans-serif"
title="${_dhEsc(proj.name)}">
${_dhEsc(labelText)}
</text>
</a>`);
// Bars for each assignment in this project.
for (const bar of (proj.bars || [])) {
const x1 = xFor(bar.start);
const x2 = xFor(bar.end);
const barW = Math.max(x2 - x1, 2);
const by = y + (rowH - barH) / 2;
const tip = `${bar.unit_id} @ ${bar.location_name}\n${bar.start}${bar.is_active ? 'present' : bar.end}`;
rowParts.push(`<g style="cursor: pointer;">
<title>${_dhEsc(tip)}</title>
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
rx="3"
fill="${proj.color}" opacity="${bar.is_active ? 1.0 : 0.75}"
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
</g>`);
}
});
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('height', height);
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
}
</script> </script>
{% endblock %} {% endblock %}