Skip to content

Commit 045e171

Browse files
authored
Merge pull request #33 from mattmezza/feat/custom-time-range-and-refresh
feat: custom time range input + error-tracker refresh
2 parents c6216c8 + e78fbe9 commit 045e171

6 files changed

Lines changed: 164 additions & 53 deletions

File tree

error-tracker/src/error_listing.zig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub const ListParams = struct {
77
project: ?[]const u8 = null,
88
source: ?[]const u8 = null, // "browser", "server", or null (all)
99
session_id: ?[]const u8 = null, // filter by session_id in occurrence extra JSON
10+
since: ?[]const u8 = null, // ISO 8601 timestamp to filter last_seen >=
1011
resolved: bool = false,
1112
limit: u32 = 50,
1213
offset: u32 = 0,
@@ -42,6 +43,8 @@ pub fn parseQueryParams(target: []const u8) ListParams {
4243
} else {
4344
params.resolved = false;
4445
}
46+
} else if (std.mem.eql(u8, key, "since")) {
47+
if (value.len > 0 and value.len <= 30) params.since = value;
4548
} else if (std.mem.eql(u8, key, "limit")) {
4649
const parsed = std.fmt.parseInt(u32, value, 10) catch continue;
4750
params.limit = if (parsed > 200) 200 else if (parsed == 0) 50 else parsed;
@@ -104,6 +107,12 @@ fn buildWhereClause(params: *const ListParams) WhereResult {
104107
result.len += frag.len;
105108
result.bind_count += 1;
106109
}
110+
if (params.since != null) {
111+
const frag = " AND last_seen >= ?";
112+
@memcpy(result.buf[result.len .. result.len + frag.len], frag);
113+
result.len += frag.len;
114+
result.bind_count += 1;
115+
}
107116

108117
return result;
109118
}
@@ -126,6 +135,10 @@ fn bindFilterParams(stmt: sqlite.Statement, params: *const ListParams) !void {
126135
try stmt.bindText(pos, sid);
127136
pos += 1;
128137
}
138+
if (params.since) |since| {
139+
try stmt.bindText(pos, since);
140+
pos += 1;
141+
}
129142
}
130143

131144
/// Query the total count of errors matching the given filters.

error-tracker/src/static/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,25 @@
1818
<h1 class="text-xl sm:text-2xl font-bold text-white">Error Tracker</h1>
1919
<p class="text-gray-400 text-xs sm:text-sm mt-1">Monitor and manage application errors</p>
2020
</div>
21-
<div class="flex items-center gap-2">
21+
<div class="flex flex-wrap items-center gap-2">
22+
<input type="text" x-model="time" list="time-presets" placeholder="e.g. 5m, 1h, 7d"
23+
class="w-[120px] bg-gray-800 border border-gray-700 text-gray-200 text-xs sm:text-sm rounded px-2 sm:px-3 py-1.5 focus:outline-none focus:border-blue-500">
24+
<datalist id="time-presets">
25+
<option value="">All time</option>
26+
<option value="1h">Last 1 hour</option>
27+
<option value="24h">Last 24 hours</option>
28+
<option value="7d">Last 7 days</option>
29+
<option value="30d">Last 30 days</option>
30+
</datalist>
2231
<select x-model="refresh" class="bg-gray-800 border border-gray-700 text-gray-200 text-xs sm:text-sm rounded px-2 sm:px-3 py-1.5 focus:outline-none focus:border-blue-500">
2332
<option value="0">Auto-refresh: off</option>
2433
<option value="5">Every 5s</option>
2534
<option value="30">Every 30s</option>
2635
<option value="60">Every 1m</option>
2736
<option value="300">Every 5m</option>
2837
</select>
38+
<button @click="loadErrors()" title="Refresh"
39+
class="bg-gray-800 border border-gray-700 text-gray-200 text-xs sm:text-sm rounded px-2 sm:px-3 py-1.5 hover:bg-gray-700 transition-colors">Refresh</button>
2940
<span x-show="refresh !== '0'" class="text-green-400 text-xs">&#9679; live</span>
3041
</div>
3142
</header>
@@ -163,6 +174,7 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
163174
project: '',
164175
source: '',
165176
resolved: false,
177+
time: '',
166178
refresh: '0',
167179
sessionId: '',
168180
offset: 0,
@@ -184,6 +196,7 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
184196
this.$watch('project', () => { this.offset = 0; this.loadErrors(); });
185197
this.$watch('source', () => { this.offset = 0; this.loadErrors(); });
186198
this.$watch('resolved', () => { this.offset = 0; this.loadErrors(); });
199+
this.$watch('time', () => { this.offset = 0; this.loadErrors(); });
187200
this.$watch('refresh', () => this.setupAutoRefresh());
188201

189202
window.addEventListener('popstate', () => {
@@ -208,6 +221,7 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
208221
this.project = p.get('project') || '';
209222
this.source = p.get('source') || '';
210223
this.resolved = p.get('resolved') === 'true';
224+
this.time = p.get('time') || '';
211225
this.refresh = p.get('refresh') || '0';
212226
const off = parseInt(p.get('offset') || '0', 10);
213227
this.offset = isNaN(off) ? 0 : off;
@@ -221,6 +235,7 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
221235
if (this.project) p.set('project', this.project);
222236
if (this.source) p.set('source', this.source);
223237
if (this.resolved) p.set('resolved', 'true');
238+
if (this.time) p.set('time', this.time);
224239
if (this.refresh && this.refresh !== '0') p.set('refresh', this.refresh);
225240
if (this.offset > 0) p.set('offset', String(this.offset));
226241
const qs = p.toString();
@@ -233,6 +248,23 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
233248
this.loadErrors();
234249
},
235250

251+
get sinceTimestamp() {
252+
if (!this.time) return null;
253+
const m = this.time.match(/^(\d+)([smhd])$/);
254+
if (!m) return null;
255+
const n = parseInt(m[1], 10);
256+
const unit = m[2];
257+
const now = new Date();
258+
switch (unit) {
259+
case 's': now.setSeconds(now.getSeconds() - n); break;
260+
case 'm': now.setMinutes(now.getMinutes() - n); break;
261+
case 'h': now.setHours(now.getHours() - n); break;
262+
case 'd': now.setDate(now.getDate() - n); break;
263+
default: return null;
264+
}
265+
return now.toISOString();
266+
},
267+
236268
// ─── Helpers ────────────────────────────────────────────────
237269
headers() {
238270
const h = { 'Content-Type': 'application/json' };
@@ -300,6 +332,8 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
300332
if (this.source) params.set('source', this.source);
301333
if (this.sessionId) params.set('session_id', this.sessionId);
302334
if (this.resolved) params.set('resolved', 'true');
335+
const since = this.sinceTimestamp;
336+
if (since) params.set('since', since);
303337
params.set('limit', this.limit);
304338
params.set('offset', this.offset);
305339

log-viewer/src/static/index.html

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,17 @@ <h1 class="text-xl sm:text-2xl font-bold text-white">Log Viewer</h1>
6161
</div>
6262
<div>
6363
<label class="block text-xs text-gray-400 mb-1">Time range</label>
64-
<select x-model="time" class="w-full lg:w-auto bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500">
64+
<input type="text" x-model="time" list="time-presets" placeholder="e.g. 5m, 1h, 7d"
65+
class="w-full lg:w-[120px] bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500">
66+
<datalist id="time-presets">
6567
<option value="">All time</option>
68+
<option value="30s">Last 30 seconds</option>
69+
<option value="5m">Last 5 minutes</option>
6670
<option value="1h">Last 1 hour</option>
6771
<option value="24h">Last 24 hours</option>
6872
<option value="7d">Last 7 days</option>
69-
</select>
73+
<option value="30d">Last 30 days</option>
74+
</datalist>
7075
</div>
7176
<div>
7277
<label class="block text-xs text-gray-400 mb-1">Auto-refresh</label>
@@ -259,11 +264,16 @@ <h2 class="text-lg font-bold text-white mb-2">API Key Required</h2>
259264

260265
get sinceTimestamp() {
261266
if (!this.time) return null;
267+
const m = this.time.match(/^(\d+)([smhd])$/);
268+
if (!m) return null;
269+
const n = parseInt(m[1], 10);
270+
const unit = m[2];
262271
const now = new Date();
263-
switch (this.time) {
264-
case '1h': now.setHours(now.getHours() - 1); break;
265-
case '24h': now.setDate(now.getDate() - 1); break;
266-
case '7d': now.setDate(now.getDate() - 7); break;
272+
switch (unit) {
273+
case 's': now.setSeconds(now.getSeconds() - n); break;
274+
case 'm': now.setMinutes(now.getMinutes() - n); break;
275+
case 'h': now.setHours(now.getHours() - n); break;
276+
case 'd': now.setDate(now.getDate() - n); break;
267277
default: return null;
268278
}
269279
return now.toISOString();

metrics-collector/src/main.zig

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ fn handleApiDashboard(request: *std.http.Server.Request, db: *sqlite.Database) v
275275
// Parse period from query params (default: 24h)
276276
const target = request.head.target;
277277
const period = parseDashboardPeriod(target);
278-
const offset = periodToOffset(period);
278+
var offset_buf: [48]u8 = undefined;
279+
const offset = periodToOffset(period, &offset_buf);
279280
const project = parseDashboardProject(target);
280281

281282
var response = std.ArrayList(u8){};
@@ -291,6 +292,16 @@ fn handleApiDashboard(request: *std.http.Server.Request, db: *sqlite.Database) v
291292
sendJsonResponse(request, .ok, response.items) catch {};
292293
}
293294

295+
/// Parse a period string like "30s", "5m", "1h", "24h", "7d" into (number, unit).
296+
fn parsePeriod(p: []const u8) ?struct { n: u32, unit: u8 } {
297+
if (p.len < 2 or p.len > 10) return null;
298+
const unit = p[p.len - 1];
299+
if (unit != 's' and unit != 'm' and unit != 'h' and unit != 'd') return null;
300+
const n = std.fmt.parseInt(u32, p[0 .. p.len - 1], 10) catch return null;
301+
if (n == 0) return null;
302+
return .{ .n = n, .unit = unit };
303+
}
304+
294305
fn parseDashboardPeriod(target: []const u8) []const u8 {
295306
const query_start = std.mem.indexOf(u8, target, "?") orelse return "24h";
296307
const query_string = target[query_start + 1 ..];
@@ -300,12 +311,7 @@ fn parseDashboardPeriod(target: []const u8) []const u8 {
300311
const key = pair[0..eq_pos];
301312
const value = pair[eq_pos + 1 ..];
302313
if (std.mem.eql(u8, key, "period")) {
303-
if (std.mem.eql(u8, value, "1m") or std.mem.eql(u8, value, "5m") or
304-
std.mem.eql(u8, value, "1h") or std.mem.eql(u8, value, "24h") or
305-
std.mem.eql(u8, value, "7d") or std.mem.eql(u8, value, "30d"))
306-
{
307-
return value;
308-
}
314+
if (parsePeriod(value) != null) return value;
309315
}
310316
}
311317
return "24h";
@@ -326,14 +332,29 @@ fn parseDashboardProject(target: []const u8) ?[]const u8 {
326332
return null;
327333
}
328334

329-
fn periodToOffset(period: []const u8) []const u8 {
330-
if (std.mem.eql(u8, period, "1m")) return "-1 minutes";
331-
if (std.mem.eql(u8, period, "5m")) return "-5 minutes";
332-
if (std.mem.eql(u8, period, "1h")) return "-1 hours";
333-
if (std.mem.eql(u8, period, "24h")) return "-24 hours";
334-
if (std.mem.eql(u8, period, "7d")) return "-7 days";
335-
if (std.mem.eql(u8, period, "30d")) return "-30 days";
336-
return "-24 hours";
335+
fn periodToOffset(period: []const u8, buf: *[48]u8) []const u8 {
336+
const parsed = parsePeriod(period) orelse {
337+
const fb = "-24 hours";
338+
@memcpy(buf[0..fb.len], fb);
339+
return buf[0..fb.len];
340+
};
341+
const unit_word: []const u8 = switch (parsed.unit) {
342+
's' => " seconds",
343+
'm' => " minutes",
344+
'h' => " hours",
345+
'd' => " days",
346+
else => " hours",
347+
};
348+
const dash = "-";
349+
@memcpy(buf[0..1], dash);
350+
const num_slice = std.fmt.bufPrint(buf[1..], "{d}", .{parsed.n}) catch {
351+
const fb = "-24 hours";
352+
@memcpy(buf[0..fb.len], fb);
353+
return buf[0..fb.len];
354+
};
355+
const num_end = 1 + num_slice.len;
356+
@memcpy(buf[num_end .. num_end + unit_word.len], unit_word);
357+
return buf[0 .. num_end + unit_word.len];
337358
}
338359

339360
fn buildDashboardJson(
@@ -552,11 +573,18 @@ fn buildDashboardJson(
552573
// For each vital, bucket by time and compute approximate p75
553574
try writer.writeAll(", \"timeseries\": [");
554575
{
555-
// Determine bucket format based on period
556-
const bucket_fmt = if (std.mem.eql(u8, period, "1h") or std.mem.eql(u8, period, "24h"))
557-
"%Y-%m-%dT%H:%M:00Z"
558-
else
559-
"%Y-%m-%dT%H:00:00Z";
576+
// Determine bucket format based on period (minute buckets for <=24h)
577+
const bucket_fmt = blk: {
578+
const pp = parsePeriod(period) orelse break :blk "%Y-%m-%dT%H:%M:00Z";
579+
const total_s: u64 = switch (pp.unit) {
580+
's' => pp.n,
581+
'm' => @as(u64, pp.n) * 60,
582+
'h' => @as(u64, pp.n) * 3600,
583+
'd' => @as(u64, pp.n) * 86400,
584+
else => 86400,
585+
};
586+
break :blk if (total_s <= 86400) "%Y-%m-%dT%H:%M:00Z" else "%Y-%m-%dT%H:00:00Z";
587+
};
560588

561589
var ts_buf: [896]u8 = undefined;
562590
const ts_p1 = "SELECT strftime('";

metrics-collector/src/metrics_query.zig

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ pub fn parseQueryParams(target: []const u8) QueryParams {
4646
return params;
4747
}
4848

49+
/// Parse a period string like "30s", "5m", "1h", "24h", "7d" into (number, unit).
50+
fn parsePeriod(p: []const u8) ?struct { n: u32, unit: u8 } {
51+
if (p.len < 2 or p.len > 10) return null;
52+
const unit = p[p.len - 1];
53+
if (unit != 's' and unit != 'm' and unit != 'h' and unit != 'd') return null;
54+
const n = std.fmt.parseInt(u32, p[0 .. p.len - 1], 10) catch return null;
55+
if (n == 0) return null;
56+
return .{ .n = n, .unit = unit };
57+
}
58+
4959
fn isValidPeriod(p: []const u8) bool {
50-
return std.mem.eql(u8, p, "1m") or
51-
std.mem.eql(u8, p, "5m") or
52-
std.mem.eql(u8, p, "1h") or
53-
std.mem.eql(u8, p, "24h") or
54-
std.mem.eql(u8, p, "7d") or
55-
std.mem.eql(u8, p, "30d");
60+
return parsePeriod(p) != null;
5661
}
5762

5863
fn isValidResolution(r: []const u8) bool {
@@ -65,25 +70,43 @@ fn isValidResolution(r: []const u8) bool {
6570
/// Auto: minute for <=24h, hour for >24h.
6671
fn resolveResolution(period: []const u8, resolution: []const u8) []const u8 {
6772
if (!std.mem.eql(u8, resolution, "auto")) return resolution;
68-
69-
// Auto: minute for short periods, hour for long periods
70-
if (std.mem.eql(u8, period, "1m") or std.mem.eql(u8, period, "5m") or
71-
std.mem.eql(u8, period, "1h") or std.mem.eql(u8, period, "24h"))
72-
{
73-
return "minute";
74-
}
75-
return "hour";
73+
// Auto: minute for <=24h, hour for >24h
74+
const parsed = parsePeriod(period) orelse return "minute";
75+
const total_seconds: u64 = switch (parsed.unit) {
76+
's' => parsed.n,
77+
'm' => @as(u64, parsed.n) * 60,
78+
'h' => @as(u64, parsed.n) * 3600,
79+
'd' => @as(u64, parsed.n) * 86400,
80+
else => 86400,
81+
};
82+
return if (total_seconds <= 86400) "minute" else "hour";
7683
}
7784

78-
/// Get the SQLite time offset string for a given period.
79-
fn periodToOffset(period: []const u8) []const u8 {
80-
if (std.mem.eql(u8, period, "1m")) return "-1 minutes";
81-
if (std.mem.eql(u8, period, "5m")) return "-5 minutes";
82-
if (std.mem.eql(u8, period, "1h")) return "-1 hours";
83-
if (std.mem.eql(u8, period, "24h")) return "-24 hours";
84-
if (std.mem.eql(u8, period, "7d")) return "-7 days";
85-
if (std.mem.eql(u8, period, "30d")) return "-30 days";
86-
return "-24 hours";
85+
/// Format a period string into a SQLite time offset, e.g. "5m" → "-5 minutes".
86+
fn periodToOffset(period: []const u8, buf: *[48]u8) []const u8 {
87+
const parsed = parsePeriod(period) orelse {
88+
const fb = "-24 hours";
89+
@memcpy(buf[0..fb.len], fb);
90+
return buf[0..fb.len];
91+
};
92+
const unit_word: []const u8 = switch (parsed.unit) {
93+
's' => " seconds",
94+
'm' => " minutes",
95+
'h' => " hours",
96+
'd' => " days",
97+
else => " hours",
98+
};
99+
// Format: "-N unit"
100+
const dash = "-";
101+
@memcpy(buf[0..1], dash);
102+
const num_slice = std.fmt.bufPrint(buf[1..], "{d}", .{parsed.n}) catch {
103+
const fb = "-24 hours";
104+
@memcpy(buf[0..fb.len], fb);
105+
return buf[0..fb.len];
106+
};
107+
const num_end = 1 + num_slice.len;
108+
@memcpy(buf[num_end .. num_end + unit_word.len], unit_word);
109+
return buf[0 .. num_end + unit_word.len];
87110
}
88111

89112
/// Query aggregated metrics and write JSON response to the writer.
@@ -95,7 +118,8 @@ pub fn queryMetrics(
95118
) !usize {
96119
const name = params.name orelse return error.MissingName;
97120
const effective_resolution = resolveResolution(params.period, params.resolution);
98-
const offset = periodToOffset(params.period);
121+
var offset_buf: [48]u8 = undefined;
122+
const offset = periodToOffset(params.period, &offset_buf);
99123

100124
// Build SQL query
101125
var sql_buf: [512]u8 = undefined;

metrics-collector/src/static/index.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@ <h1 class="text-xl sm:text-2xl font-bold text-white">Metrics Dashboard</h1>
5454
<option :value="p" x-text="p"></option>
5555
</template>
5656
</select>
57-
<select x-model="period" class="w-full sm:w-auto">
57+
<input type="text" x-model.debounce.500ms="period" list="period-presets" placeholder="e.g. 5m, 1h, 7d"
58+
class="w-full sm:w-[130px]">
59+
<datalist id="period-presets">
5860
<option value="1m">Last 1 minute</option>
5961
<option value="5m">Last 5 minutes</option>
6062
<option value="1h">Last 1 hour</option>
6163
<option value="24h">Last 24 hours</option>
6264
<option value="7d">Last 7 days</option>
6365
<option value="30d">Last 30 days</option>
64-
</select>
66+
</datalist>
6567
<select x-model="refresh" class="w-full sm:w-auto">
6668
<option value="0">Auto-refresh: off</option>
6769
<option value="5">Every 5s</option>

0 commit comments

Comments
 (0)