Files
zeekayandHanzo Dev 78ab4f2f83 Declare operations on the group that serves them, and build on Go 1.27
zip's registration is a verb method on the group, so a route that is not a typed
operation says which method it answers with. The security package's own
two-method router goes with it: it existed so both the app and a sub-router could
satisfy one name, and restating a routing surface is what let the two disagree —
the methods it named no longer exist. The app spells itself as a group, which
registers at exactly the paths it did.

The toolchain and the pin move together, because below zip v1.36.50 the JSON
encoder was selected on a build tag: a repo whose go directive rises first writes
a wire that is neither the old release's nor the new one's until the pin catches
up.

metrics_test.go still does not compile, before and after: it compares a metric
registry against a gatherer from a different metrics library, so the two Gather
signatures disagree. That is its own change.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 09:58:39 -07:00

189 lines
5.6 KiB
Go

package api
import (
"errors"
"math"
"net/http"
"net/url"
"sort"
"time"
"github.com/TwiN/logr"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
"github.com/zap-proto/zip"
"hanzo.ai/status/storage/store"
"hanzo.ai/status/storage/store/common"
)
const timeFormat = "3:04PM"
var (
gridStyle = chart.Style{
StrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},
StrokeWidth: 1.0,
}
axisStyle = chart.Style{
FontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},
}
transparentStyle = chart.Style{
FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},
}
)
func ResponseTimeChart(c *zip.Ctx) error {
duration := c.Param("duration")
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
chartTimestampFormatter = chart.TimeDateValueFormatter
case "7d":
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.String(400, "Durations supported: 30d, 7d, 24h")
}
key, err := url.QueryUnescape(c.Param("key"))
if err != nil {
return c.String(400, "invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.String(404, err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.String(400, err.Error())
}
return c.String(500, err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
return c.String(204, "")
}
series := chart.TimeSeries{
Name: "Average response time per hour",
Style: chart.Style{
StrokeWidth: 1.5,
DotWidth: 2.0,
},
}
keys := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
keys = append(keys, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
keys = append(keys, int(earliestTimestamp))
}
sort.Ints(keys)
var maxAverageResponseTime float64
for _, key := range keys {
averageResponseTime := float64(hourlyAverageResponseTime[int64(key)])
if maxAverageResponseTime < averageResponseTime {
maxAverageResponseTime = averageResponseTime
}
series.XValues = append(series.XValues, time.Unix(int64(key), 0))
series.YValues = append(series.YValues, averageResponseTime)
}
graph := chart.Chart{
Canvas: transparentStyle,
Background: transparentStyle,
Width: 1280,
Height: 300,
XAxis: chart.XAxis{
ValueFormatter: chartTimestampFormatter,
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
},
YAxis: chart.YAxis{
Name: "Average response time",
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
Range: &chart.ContinuousRange{
Min: 0,
Max: math.Ceil(maxAverageResponseTime * 1.25),
},
},
Series: []chart.Series{series},
}
c.SetHeader("Content-Type", "image/svg+xml")
c.SetHeader("Cache-Control", "no-cache, no-store")
c.SetHeader("Expires", "0")
c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c.Fiber()); err != nil {
logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
return c.String(500, err.Error())
}
return nil
}
func ResponseTimeHistory(c *zip.Ctx) error {
duration := c.Param("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.String(400, "Durations supported: 30d, 7d, 24h")
}
endpointKey, err := url.QueryUnescape(c.Param("key"))
if err != nil {
return c.String(400, "invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.String(404, err.Error())
}
if errors.Is(err, common.ErrInvalidTimeRange) {
return c.String(400, err.Error())
}
return c.String(500, err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
return writeJSON(c, 200, map[string]interface{}{
"timestamps": []int64{},
"values": []int{},
})
}
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
}
sort.Ints(hourlyTimestamps)
timestamps := make([]int64, 0, len(hourlyTimestamps))
values := make([]int, 0, len(hourlyTimestamps))
for _, hourlyTimestamp := range hourlyTimestamps {
timestamp := int64(hourlyTimestamp)
averageResponseTime := hourlyAverageResponseTime[timestamp]
timestamps = append(timestamps, timestamp*1000)
values = append(values, averageResponseTime)
}
return writeJSON(c, http.StatusOK, map[string]interface{}{
"timestamps": timestamps,
"values": values,
})
}