Skip to content
Open
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
69 changes: 59 additions & 10 deletions static/templates/pages/graphs.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,24 @@
<div class="card-img-top graph" id="service-status"></div>
<script>
const getChartTheme = () => document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light';
let TEAMMAP = {}
// The API marks only the viewer's own team with Self, which is shown by color alone.
const SELF_COLOR = '#775DD0'
const OTHER_COLORS = ['#008FFB', '#00E396', '#FEB019', '#FF4560'] // ApexCharts' palette without SELF_COLOR
// Moves the viewer's series last: the top row of a heatmap, the topmost line of a line chart.
const pinSelfToTop = (series) => [...series.filter((s) => !s.self), ...series.filter((s) => s.self)]
// Labels are matched by position, since on an anonymized scoreboard they all read "Team".
// Runs after every render, since ApexCharts redraws labels on resize and theme changes.
const highlightSelfLabel = (selfLabelIndex) => (chartContext) => {
const selfIndex = selfLabelIndex()
chartContext.el.querySelectorAll('.apexcharts-yaxis-label').forEach((label, i) => {
label.style.fill = i === selfIndex ? SELF_COLOR : ''
label.style.fontWeight = i === selfIndex ? '700' : ''
})
}
// A heatmap's y-axis labels start with a blank one, then go top row first, where pinSelfToTop puts the viewer.
const heatmapSelfLabel = (hasSelf) => highlightSelfLabel(() => hasSelf() ? 1 : -1)
let TEAMIDS = []
let STATUS_HAS_SELF = false
let ROUNDID = 0
let options = {
series: [],
Expand All @@ -20,13 +37,14 @@
background: 'transparent',
events: {
dataPointSelection: function(event, chartContext, config) {
const teamName = chartContext.w.globals.seriesNames[config.seriesIndex]
const serviceName = chartContext.w.globals.categoryLabels[config.dataPointIndex]
const teamID = TEAMMAP[teamName]
const teamID = TEAMIDS[config.seriesIndex]
if (teamID !== undefined) {
window.location.href = `/services?team=${teamID}&service=${serviceName}&round=${ROUNDID}`
}
}
},
mounted: heatmapSelfLabel(() => STATUS_HAS_SELF),
updated: heatmapSelfLabel(() => STATUS_HAS_SELF),
}
},
stroke: {
Expand Down Expand Up @@ -100,11 +118,10 @@
console.log(data)
ROUNDID = data.roundID
series = data.series.map((service) => {
if (service.Name !== "Team" && service.ID) {
TEAMMAP[service.Name] = service.ID
}
return {
name: service.Name,
teamID: service.ID || undefined,
self: !!service.Self,
data: service.Data.map((status) => {
return {
x: status.X,
Expand All @@ -118,6 +135,9 @@
if (!series.every((s) => s.name === "Team")) {
series.sort((a, b) => b.name.localeCompare(a.name))
}
series = pinSelfToTop(series)
TEAMIDS = series.map((s) => s.teamID)
STATUS_HAS_SELF = series.some((s) => s.self)
})
if (!series || !series.length || !series[0].data.length) {
await STATUSCHART.updateOptions({
Expand Down Expand Up @@ -157,7 +177,7 @@
img = UNKIMG
}

const teamID = TEAMMAP[series[i].name]
const teamID = TEAMIDS[i]
const serviceName = status.x
const pointID = `status-${i}-${j}`
const rowHeight = STATUSCHART.w.globals.gridHeight / series.length;
Expand Down Expand Up @@ -202,11 +222,16 @@
<div class="card overflow-y-scroll">
<div class="card-img-top graph" id="uptime-status"></div>
<script>
let UPTIME_HAS_SELF = false
let options3 = {
series: [],
chart: {
type: 'heatmap',
background: 'transparent',
events: {
mounted: heatmapSelfLabel(() => UPTIME_HAS_SELF),
updated: heatmapSelfLabel(() => UPTIME_HAS_SELF),
},
},
stroke: {
width: 1
Expand Down Expand Up @@ -297,6 +322,7 @@
series = data.series.map((service) => {
return {
name: service.Name,
self: !!service.Self,
data: service.Data.sort((a, b) => { return a.Service.localeCompare(b.Service) }).map((status) => {
if (status.Uptime === null || status.Uptime < 0) {
return {
Expand All @@ -314,6 +340,8 @@
if (!series.every((s) => s.name === "Team")) {
series.sort((a, b) => b.name.localeCompare(a.name))
}
series = pinSelfToTop(series)
UPTIME_HAS_SELF = series.some((s) => s.self)
})

if (!series || !series.length || !series[0].data.length) {
Expand Down Expand Up @@ -382,11 +410,16 @@
const SCORECHART = new ApexCharts(document.querySelector("#score-status"), options1);
SCORECHART.render();

let LEADERBOARD_SELF = -1
let options2 = {
series: [{ data: [] }],
chart: {
type: 'bar',
background: 'transparent',
events: {
mounted: highlightSelfLabel(() => LEADERBOARD_SELF),
updated: highlightSelfLabel(() => LEADERBOARD_SELF),
},
animations: {
enabled: false,
},
Expand Down Expand Up @@ -428,11 +461,13 @@
if (team.Data === null) {
return {
name: team.Name,
self: !!team.Self,
data: []
}
}
return {
name: team.Name,
self: !!team.Self,
data: team.Data.map((point) => {
return {
x: point.Round,
Expand All @@ -459,11 +494,17 @@
return status.x
})

// Later series draw on top, so the viewer's line goes last.
const lines = pinSelfToTop(series)
await SCORECHART.updateOptions({
legend: {
show: !series.every((s) => s.name === "Team"),
show: !lines.every((s) => s.name === "Team"),
},
series: series,
stroke: {
width: lines.map((s) => s.self ? 5 : 2),
},
colors: lines.map((s, i) => s.self ? SELF_COLOR : OTHER_COLORS[i % OTHER_COLORS.length]),
series: lines,
xaxis: {
categories: categories,
}
Expand All @@ -474,18 +515,26 @@
if (team.data.length === 0) {
return {
name: team.name,
self: team.self,
data: 0
}
}
return {
name: team.name,
self: team.self,
data: team.data[team.data.length - 1].y
}
}).sort((a, b) => {
return b.data - a.data
})
LEADERBOARD_SELF = leaderboard_series.findIndex((team) => team.self)

await LEADERBOARDCHART.updateOptions({
plotOptions: {
bar: { horizontal: true, distributed: true },
},
legend: { show: false },
colors: leaderboard_series.map((team) => team.self ? SELF_COLOR : OTHER_COLORS[0]),
series: [{
name: 'Service Score',
data: leaderboard_series.reduce((acc, team) => {
Expand Down
131 changes: 131 additions & 0 deletions tests/integration/graphs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package integration

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/dbaseqp/Quotient/engine"
"github.com/dbaseqp/Quotient/engine/db"
"github.com/dbaseqp/Quotient/tests/testutil"
"github.com/dbaseqp/Quotient/www/api"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type graphSeries struct {
Name string
ID uint
Self bool
}

func graphRequest(t *testing.T, handler http.HandlerFunc, identity *api.Identity) (*httptest.ResponseRecorder, []graphSeries) {
t.Helper()
r := httptest.NewRequest(http.MethodGet, "/", nil)
if identity != nil {
ctx := api.WithIdentity(r.Context(), *identity)
ctx = context.WithValue(ctx, "roles", identity.Roles) //nolint:staticcheck // matches the authentication middleware
r = r.WithContext(ctx)
}
w := httptest.NewRecorder()
handler(w, r)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())

var body struct{ Series []graphSeries }
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
return w, body.Series
}

func TestGraphsMarkOnlyCallersOwnSeries(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}

redis, pg := testutil.StartContainers(t)
require.NoError(t, pg.DB.ResetScores())
eng := engine.NewTestEngine(t, redis, pg, 3)
a := api.NewAPI(eng.Config, eng)
require.NoError(t, pg.DB.SetCompetitionStarted(true))
t.Cleanup(func() { pg.ResetCompetitionState(t) })

suffix := time.Now().UnixNano()
teams := make([]db.TeamSchema, 3)
checks := make([]db.ServiceCheckSchema, 0, len(teams))
for i := range teams {
team, err := pg.DB.CreateTeam(db.TeamSchema{
Name: fmt.Sprintf("graphteam%d-%d", i, suffix),
Identifier: fmt.Sprintf("%02d", i+1),
Active: true,
})
require.NoError(t, err)
teams[i] = team
checks = append(checks, db.ServiceCheckSchema{TeamID: team.ID, ServiceName: "box-web", Points: 1, Result: true})
}
_, err := pg.DB.CreateRound(db.RoundSchema{ID: 1, StartTime: time.Now(), Checks: checks})
require.NoError(t, err)

me := teams[1]
handlers := map[string]http.HandlerFunc{
"services": a.GetServiceStatus,
"scores": a.GetScoreStatus,
"uptimes": a.GetUptimeStatus,
}

for name, handler := range handlers {
t.Run(name+"/own team anonymized", func(t *testing.T) {
w, series := graphRequest(t, handler, &api.Identity{Username: "u", Roles: []string{"team"}, TeamID: me.ID, HasTeam: true})
assert.Equal(t, "private, no-store", w.Header().Get("Cache-Control"))

selfCount := 0
for _, s := range series {
assert.Equal(t, "Team", s.Name)
if s.Self {
selfCount++
if name == "services" {
assert.Equal(t, me.ID, s.ID, "own row keeps its ID so it can link to the services page")
}
} else {
assert.Zero(t, s.ID, "other teams' IDs must stay scrubbed")
}
}
assert.Equal(t, 1, selfCount)
for _, team := range teams {
assert.NotContains(t, w.Body.String(), team.Name)
}
})

for _, viewer := range []struct {
label string
identity *api.Identity
}{
{"red team", &api.Identity{Username: "red", Roles: []string{"red"}}},
{"logged out", nil},
} {
t.Run(name+"/"+viewer.label, func(t *testing.T) {
w, series := graphRequest(t, handler, viewer.identity)
for _, s := range series {
assert.False(t, s.Self)
assert.Zero(t, s.ID)
assert.Equal(t, "Team", s.Name)
}
assert.False(t, strings.Contains(w.Body.String(), `"Self"`))
})
}

t.Run(name+"/admin", func(t *testing.T) {
_, series := graphRequest(t, handler, &api.Identity{Username: "admin", Roles: []string{"admin"}})
names := map[string]bool{}
for _, s := range series {
assert.False(t, s.Self)
names[s.Name] = true
}
assert.True(t, names[me.Name], "admins still see real team names")
})
}
}
19 changes: 17 additions & 2 deletions tests/testutil/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/dbaseqp/Quotient/engine/db"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)

// RedisContainer wraps a Redis client for testing
Expand Down Expand Up @@ -45,7 +47,8 @@ func startRedis(t *testing.T) *RedisContainer {

// PostgresContainer wraps a Postgres connection string for testing
type PostgresContainer struct {
DB *db.DB
DB *db.DB
connString string
}

func (p *PostgresContainer) Close() error {
Expand All @@ -66,7 +69,19 @@ func startPostgres(t *testing.T) *PostgresContainer {
connString := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)

return &PostgresContainer{DB: db.Connect(connString)}
return &PostgresContainer{DB: db.Connect(connString), connString: connString}
}

// ResetCompetitionState forgets that the competition ever started, which the
// DB layer never does on its own: StartedAt is kept once set.
func (p *PostgresContainer) ResetCompetitionState(t *testing.T) {
t.Helper()
conn, err := gorm.Open(postgres.Open(p.connString), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := conn.DB()
require.NoError(t, err)
defer sqlDB.Close() //nolint:errcheck
require.NoError(t, conn.Exec("DELETE FROM competition_state_schemas").Error)
}

// testTeamCounter provides unique team IDs across test runs
Expand Down
Loading
Loading