Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,8 @@ that: `make web-serve` starts Postgres, migrates, and serves without
reseeding. Already have Postgres running and migrated? `make api` starts
just the server.

Pick a time window (presets or a duration like `2h`), then switch between the
Pick a time window — relative presets or a duration like `2h` (the default), or
switch to absolute UTC `from`/`to` datetimes — then switch between the
**Explain**, **Timeline**, **Compare**, and **Ask** tabs. The **ingestion**
dropdown in the top bar lists your 25 most recent completed ingestions (via
`GET /v1/ingestions`) and defaults to the latest one, matching the CLI; pick a
Expand Down
22 changes: 22 additions & 0 deletions src/api/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,26 @@ body {
border-bottom: 1px solid var(--panel-border);
}

.window-mode,
.presets {
display: flex;
gap: 4px;
}

.window-relative,
.window-absolute {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
}

.window-relative[hidden],
.window-absolute[hidden] {
display: none;
}

.mode-btn,
.preset-btn {
background: var(--bg);
border: 1px solid var(--panel-border);
Expand All @@ -135,6 +150,8 @@ body {
font-size: 12px;
}

.mode-btn.active,
.mode-btn:hover,
.preset-btn.active,
.preset-btn:hover {
color: var(--accent);
Expand All @@ -160,6 +177,11 @@ body {
min-width: 90px;
}

.window-form input[type="datetime-local"] {
min-width: 190px;
color-scheme: dark;
}

.question-label {
flex: 1 1 260px;
}
Expand Down
81 changes: 79 additions & 2 deletions src/api/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ function fmtTime(iso) {
}
}

// datetime-local yields "YYYY-MM-DDTHH:MM" (seconds optional, no timezone).
// The API treats naive timestamps as UTC, so we emit explicit UTC ISO-8601.
const DATETIME_WINDOW_KEYS = {
from_time: true,
to_time: true,
window_a_from: true,
window_a_to: true,
window_b_from: true,
window_b_to: true,
};

function datetimeLocalToIso(value) {
const trimmed = String(value == null ? "" : value).trim();
if (!trimmed) return "";
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(trimmed)) {
return trimmed + ":00Z";
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
return trimmed + "Z";
}
return trimmed;
}

function confidenceBadge(confidence) {
const label = confidence && typeof confidence === "object" ? confidence.label : confidence;
const cls = label === "high" ? "badge-high" : label === "medium" || label === "medium-high" ? "badge-medium" : "badge-low";
Expand Down Expand Up @@ -90,6 +113,43 @@ function initTabs() {
});
}

// ── Window mode (relative duration vs absolute from/to) ──────────────────────

function applyWindowMode(form, mode) {
const next = mode === "absolute" ? "absolute" : "relative";
form.dataset.windowMode = next;

form.querySelectorAll(".mode-btn").forEach((btn) => {
const active = btn.dataset.mode === next;
btn.classList.toggle("active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});

const relative = form.querySelector(".window-relative");
const absolute = form.querySelector(".window-absolute");
if (relative) {
relative.hidden = next !== "relative";
relative.querySelectorAll("input, button").forEach((el) => {
el.disabled = next !== "relative";
});
}
if (absolute) {
absolute.hidden = next !== "absolute";
absolute.querySelectorAll("input").forEach((el) => {
el.disabled = next !== "absolute";
});
}
}

function initWindowModes() {
document.querySelectorAll(".window-form").forEach((form) => {
applyWindowMode(form, form.dataset.windowMode || "relative");
form.querySelectorAll(".mode-btn").forEach((btn) => {
btn.addEventListener("click", () => applyWindowMode(form, btn.dataset.mode));
});
});
}

// ── Presets ──────────────────────────────────────────────────────────────────

function initPresets() {
Expand All @@ -99,7 +159,7 @@ function initPresets() {
btn.addEventListener("click", () => {
form.querySelectorAll(".preset-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
sinceInput.value = btn.dataset.since;
if (sinceInput) sinceInput.value = btn.dataset.since;
});
});
});
Expand All @@ -111,7 +171,13 @@ function formToBody(form) {
const body = {};
new FormData(form).forEach((value, key) => {
const trimmed = String(value).trim();
if (trimmed !== "") body[key] = trimmed;
if (trimmed === "") return;
if (DATETIME_WINDOW_KEYS[key]) {
const iso = datetimeLocalToIso(trimmed);
if (iso) body[key] = iso;
return;
}
body[key] = trimmed;
});

const jobId = selectedIngestionJobId();
Expand Down Expand Up @@ -146,6 +212,16 @@ function initForms() {
resultEl.innerHTML = '<p class="empty-state">Loading…</p>';

try {
if ((form.dataset.windowMode || "relative") === "absolute") {
const missing = Array.from(form.querySelectorAll('input[type="datetime-local"]:not([disabled])')).some(
(input) => !String(input.value || "").trim()
);
if (missing) {
resultEl.innerHTML = '<p class="error-state">Pick from and to datetimes for the absolute window.</p>';
return;
}
}

const resp = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down Expand Up @@ -292,6 +368,7 @@ function renderAsk(el, data) {

document.addEventListener("DOMContentLoaded", () => {
initTabs();
initWindowModes();
initPresets();
initForms();
loadIngestions();
Expand Down
100 changes: 71 additions & 29 deletions src/api/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,24 @@

<!-- Explain -->
<section class="panel tab-panel active" id="panel-explain">
<form class="window-form" data-endpoint="/v1/query/explain" data-render="explain">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
<form class="window-form" data-endpoint="/v1/query/explain" data-render="explain" data-window-mode="relative">
<div class="window-mode" role="group" aria-label="Time window mode">
<button type="button" class="mode-btn active" data-mode="relative" aria-pressed="true">Relative</button>
<button type="button" class="mode-btn" data-mode="absolute" aria-pressed="false">Absolute</button>
</div>
<div class="window-relative">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
</div>
<div class="window-absolute" hidden>
<label>from (UTC) <input type="datetime-local" name="from_time" class="datetime-input" disabled></label>
<label>to (UTC) <input type="datetime-local" name="to_time" class="datetime-input" disabled></label>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
<label>service <input type="text" name="service" placeholder="optional"></label>
<label>env <input type="text" name="env" placeholder="optional"></label>
<button type="submit" class="run-btn">Explain</button>
Expand All @@ -47,14 +57,24 @@

<!-- Timeline -->
<section class="panel tab-panel" id="panel-timeline">
<form class="window-form" data-endpoint="/v1/query/timeline" data-render="timeline">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
<form class="window-form" data-endpoint="/v1/query/timeline" data-render="timeline" data-window-mode="relative">
<div class="window-mode" role="group" aria-label="Time window mode">
<button type="button" class="mode-btn active" data-mode="relative" aria-pressed="true">Relative</button>
<button type="button" class="mode-btn" data-mode="absolute" aria-pressed="false">Absolute</button>
</div>
<div class="window-relative">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
</div>
<div class="window-absolute" hidden>
<label>from (UTC) <input type="datetime-local" name="from_time" class="datetime-input" disabled></label>
<label>to (UTC) <input type="datetime-local" name="to_time" class="datetime-input" disabled></label>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
<label>service <input type="text" name="service" placeholder="optional"></label>
<label>env <input type="text" name="env" placeholder="optional"></label>
<button type="submit" class="run-btn">Build timeline</button>
Expand All @@ -66,15 +86,27 @@

<!-- Compare -->
<section class="panel tab-panel" id="panel-compare">
<form class="window-form" data-endpoint="/v1/query/compare" data-render="compare">
<div class="presets" data-preset-target="since">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn active" data-since="30m">30m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn" data-since="2h">2h</button>
<form class="window-form" data-endpoint="/v1/query/compare" data-render="compare" data-window-mode="relative">
<div class="window-mode" role="group" aria-label="Time window mode">
<button type="button" class="mode-btn active" data-mode="relative" aria-pressed="true">Relative</button>
<button type="button" class="mode-btn" data-mode="absolute" aria-pressed="false">Absolute</button>
</div>
<div class="window-relative">
<div class="presets" data-preset-target="since">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn active" data-since="30m">30m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn" data-since="2h">2h</button>
</div>
<label>since (incident) <input type="text" name="since" value="30m" class="since-input"></label>
<label>baseline <input type="text" name="baseline" value="24h" class="since-input"></label>
</div>
<div class="window-absolute" hidden>
<label>incident from (UTC) <input type="datetime-local" name="window_a_from" class="datetime-input" disabled></label>
<label>incident to (UTC) <input type="datetime-local" name="window_a_to" class="datetime-input" disabled></label>
<label>baseline from (UTC) <input type="datetime-local" name="window_b_from" class="datetime-input" disabled></label>
<label>baseline to (UTC) <input type="datetime-local" name="window_b_to" class="datetime-input" disabled></label>
</div>
<label>since (incident) <input type="text" name="since" value="30m" class="since-input"></label>
<label>baseline <input type="text" name="baseline" value="24h" class="since-input"></label>
<label>service <input type="text" name="service" placeholder="optional"></label>
<label>env <input type="text" name="env" placeholder="optional"></label>
<button type="submit" class="run-btn">Compare</button>
Expand All @@ -86,14 +118,24 @@

<!-- Ask -->
<section class="panel tab-panel" id="panel-ask">
<form class="window-form" data-endpoint="/v1/query/ask" data-render="ask">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
<form class="window-form" data-endpoint="/v1/query/ask" data-render="ask" data-window-mode="relative">
<div class="window-mode" role="group" aria-label="Time window mode">
<button type="button" class="mode-btn active" data-mode="relative" aria-pressed="true">Relative</button>
<button type="button" class="mode-btn" data-mode="absolute" aria-pressed="false">Absolute</button>
</div>
<div class="window-relative">
<div class="presets">
<button type="button" class="preset-btn" data-since="15m">15m</button>
<button type="button" class="preset-btn" data-since="1h">1h</button>
<button type="button" class="preset-btn active" data-since="2h">2h</button>
<button type="button" class="preset-btn" data-since="24h">24h</button>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
</div>
<div class="window-absolute" hidden>
<label>from (UTC) <input type="datetime-local" name="from_time" class="datetime-input" disabled></label>
<label>to (UTC) <input type="datetime-local" name="to_time" class="datetime-input" disabled></label>
</div>
<label>since <input type="text" name="since" value="2h" class="since-input"></label>
<label>service <input type="text" name="service" placeholder="optional"></label>
<label class="question-label">question
<input type="text" name="question" placeholder="e.g. why did checkout fail?" class="question-input" required>
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,17 @@ def test_ui_calls_versioned_endpoints(self):
assert "/v1/ingestions" in js.text
assert 'fetch("/ingestions")' not in js.text

def test_window_forms_support_absolute_mode(self):
html = client.get("/").text
assert 'data-window-mode="relative"' in html
assert 'data-mode="absolute"' in html
assert 'type="datetime-local"' in html
assert 'name="from_time"' in html
assert 'name="window_a_from"' in html
js = client.get("/static/js/app.js").text
assert "function datetimeLocalToIso(" in js
assert "function applyWindowMode(" in js


# ── POST /query/explain ───────────────────────────────────────────────────────

Expand Down
Loading
Loading