Skip to content

Commit 0c9d8bd

Browse files
sarg3ntclaude
andcommitted
fix(#87): address Copilot review on metrics insights
Three findings, all valid: 1. The prev-window stats slice was un-trimmed. GetStatsHistory takes a lower bound only, so prevRaw spanned both windows; the `prevOnly` index list I computed was promptly discarded with `_ = prevOnly`, so every prev-window aggregate was actually a union aggregate and the resulting delta_pct was ~0 % regardless of actual change. Fixed by collapsing prevRaw to entries strictly before `since`. 2. APIMetricsLogErrorsHandler returned HTTP 403 when the user lacked `logs:view`, but the README and the frontend's loadLogErrors() both expected the structured {available:false, reason:…} envelope, so users without log access saw the generic "Failed to load log lines" error instead of the documented hint. Switched to the envelope. 3. escapeJSArg only handled the JS string context (\\ and '), but the value is interpolated into a double-quoted HTML onclick attribute, so an embedded " could break out and a < could start a new tag. Hardened to also escape &<>" — defence in depth on the source-IP path where values flow from agent-collected data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e3f2235 commit 0c9d8bd

2 files changed

Lines changed: 36 additions & 10 deletions

File tree

gearbox/internal/framework/handler/api_metrics_insights.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,24 +85,27 @@ func (h *Handler) APIMetricsSummaryHandler(w http.ResponseWriter, r *http.Reques
8585
http.Error(w, "Failed to load stats history", http.StatusInternalServerError)
8686
return
8787
}
88-
prev, err := h.db.GetStatsHistory(boxID, prevSince, 5000)
88+
prevRaw, err := h.db.GetStatsHistory(boxID, prevSince, 5000)
8989
if err != nil {
9090
h.logger.Error("metrics summary: prev stats history", "error", err)
9191
http.Error(w, "Failed to load stats history", http.StatusInternalServerError)
9292
return
9393
}
94-
// prev contains both windows; trim to "previous-window only".
95-
prevOnly := make([]int, 0, len(prev))
96-
for i, s := range prev {
94+
// GetStatsHistory takes a lower bound only, so prevRaw spans both the
95+
// previous AND current windows. Trim to entries strictly before `since`
96+
// so the prev-window aggregates (max counter, avg response time, …)
97+
// reflect the prior window in isolation — otherwise every delta vs.
98+
// prior is ~0% because we'd be comparing each window to its own union
99+
// with the current window.
100+
prev := prevRaw[:0]
101+
for _, s := range prevRaw {
97102
if s.CollectedAt.Before(since) {
98-
prevOnly = append(prevOnly, i)
103+
prev = append(prev, s)
99104
}
100105
}
101106

102107
cards := []kpiCard{}
103108

104-
_ = prevOnly
105-
106109
currReqMax := maxStatField(curr, func(s database.StatsSnapshot) float64 { return float64(s.TotalRequests) })
107110
prevReqMax := maxStatField(prev, func(s database.StatsSnapshot) float64 { return float64(s.TotalRequests) })
108111
currReqRate := perMinute(currReqMax, since, until)
@@ -434,8 +437,18 @@ func (h *Handler) APIMetricsLogErrorsHandler(w http.ResponseWriter, r *http.Requ
434437
http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden)
435438
return
436439
}
440+
// Lack of `logs:view` is the only "you're allowed on the metrics page,
441+
// but this one sub-panel needs more permission" case we have. Return a
442+
// soft envelope rather than 403 so the drawer can render the documented
443+
// "logs unavailable" hint instead of the generic "fetch failed" error.
437444
if !h.authManager.HasPermission(r, "logs", "view") {
438-
http.Error(w, "Forbidden: log access required for log correlation", http.StatusForbidden)
445+
h.writeJSON(w, map[string]interface{}{
446+
"server_id": chi.URLParam(r, "boxID"),
447+
"available": false,
448+
"reason": "Logs permission required to correlate access-log lines with metrics.",
449+
"matchCount": 0,
450+
"matches": []haproxyLogLine{},
451+
})
439452
return
440453
}
441454

gearbox/internal/framework/templates/pages/history.templ

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,9 +1866,22 @@ templ History(user *models.User, servers []models.BoxConfig) {
18661866
}
18671867

18681868
// escapeJSArg escapes a string for safe inclusion as a single-quoted
1869-
// JS argument inside an HTML onclick attribute.
1869+
// JS argument inside a double-quoted HTML attribute, e.g.
1870+
// <div onclick="openDrillDown('source', 'VALUE')">
1871+
// It must therefore neutralise BOTH the JS string context (\\ and ')
1872+
// AND the HTML attribute context (", <, >, & — otherwise a value
1873+
// containing `"` would break out of the attribute and a `<` could
1874+
// start a new tag). Backend names come from operator-controlled
1875+
// HAProxy config, but source IPs and country codes flow from
1876+
// agent-collected data, so defence-in-depth is warranted.
18701877
function escapeJSArg(s) {
1871-
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1878+
return String(s)
1879+
.replace(/\\/g, '\\\\')
1880+
.replace(/'/g, "\\'")
1881+
.replace(/&/g, '&amp;')
1882+
.replace(/"/g, '&quot;')
1883+
.replace(/</g, '&lt;')
1884+
.replace(/>/g, '&gt;');
18721885
}
18731886

18741887
// Move page header content to main header

0 commit comments

Comments
 (0)