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: 1 addition & 2 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ jobs:
github.event.workflow_run.actor.login == 'dnb-robot[bot]')
uses: drumandbytes/reusable-actions/.github/workflows/auto-merge.yml@v1
with:
# dnb-robot opens release-please PRs (patch-only) and track-map PRs, which
# never touch the manifest and so always count as patch.
# dnb-robot's release-please and track-map PRs never touch the manifest, so always patch.
patch-only-authors: '["dnb-robot[bot]"]'
# A GITHUB_TOKEN merge triggers no workflows: no release, no publish.yml run.
use-app-token-for-merge: true
Expand Down
9 changes: 2 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,15 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o paddock-api .
RUN mkdir /data

# scratch has no CA bundle and no /usr/share/zoneinfo of its own - the CA
# bundle is copied in below, and the binary embeds tzdata itself (see the
# time/tzdata blank import in main.go) since there's nowhere on this image to
# read it from at runtime.
# scratch has no CA bundle or zoneinfo: CAs copied below, tzdata embedded (main.go).
FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
WORKDIR /app
COPY --from=builder /build/paddock-api /app/paddock-api
COPY static ./static

# Durable cache for data that never changes once it exists (finished sessions).
# Owned by the non-root user so a volume mounted here is writable; without a
# volume it just lives in the container's writable layer.
# Durable cache for finished sessions; non-root-owned so a mounted volume is writable.
COPY --from=builder --chown=65532:65532 /data /data
ENV CACHE_DIR=/data

Expand Down
6 changes: 2 additions & 4 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ func (c *cacheStore) set(key string, value any, expiresAt time.Time) {
c.mu.Unlock()
}

// getDurable/setDurable are for data that never changes once it exists (a
// finished session's results). Kept in memory like any entry and, when a
// directory is configured, mirrored to disk so it survives a restart - the
// upstream (OpenF1) can be unreachable for long stretches around sessions.
// getDurable/setDurable: data that never changes (finished sessions), mirrored
// to disk when a dir is set, since OpenF1 can be unreachable for long stretches.
func (c *cacheStore) getDurable(key string, now time.Time) (any, bool) {
if value, ok := c.get(key, now); ok {
return value, true
Expand Down
15 changes: 4 additions & 11 deletions cmd/gentrackmaps/main.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
// Command gentrackmaps pre-renders every known circuit's track-outline SVG
// into static/track_maps/<circuitId>.svg from bacinger/f1-circuits' static
// GeoJSON dataset, so the API's /f1/next_map handler can serve a static file
// on the request path with no live lookup at all.
//
// Run via `go run ./cmd/gentrackmaps` (or the regenerate-track-maps.yml
// workflow, monthly). Always renders at the reference colour #e5d486 -
// map.go swaps that for the request's configured TRACK_COLOUR at serve time.
// Command gentrackmaps pre-renders static/track_maps/<circuitId>.svg from
// bacinger/f1-circuits so /f1/next_map serves a static file. Renders in
// #e5d486; map.go swaps in TRACK_COLOUR at serve time.
package main

import (
Expand Down Expand Up @@ -34,9 +29,7 @@ func main() {
os.Exit(1)
}

// One circuit per goroutine - raw.githubusercontent.com is a static CDN
// built for exactly this, and 26 fetches is nowhere near anything it'd
// blink at.
// one goroutine per circuit; 26 fetches is nothing for raw.githubusercontent.com
client := &http.Client{Timeout: 15 * time.Second}
results := make(chan result, len(trackmap.CircuitGeometryIDs))
var wg sync.WaitGroup
Expand Down
19 changes: 6 additions & 13 deletions internal/trackmap/trackmap.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
// Package trackmap fetches circuit outline geometry from bacinger/f1-circuits
// and renders it as a track-map SVG. Shared by the API's live /f1/next_map
// fallback (main.go) and the offline pre-renderer (cmd/gentrackmaps).
// Package trackmap fetches circuit outlines from bacinger/f1-circuits and renders track-map SVGs.
package trackmap

import (
Expand All @@ -16,10 +14,8 @@ import (

const DefaultGeometryBase = "https://raw.githubusercontent.com/bacinger/f1-circuits/master/circuits"

// CircuitGeometryIDs maps our circuitId (matches the schedule's circuit ids
// and the static SVG filenames) to bacinger/f1-circuits' own
// <country-code>-<year-opened> id. Update when a new circuit joins the
// calendar.
// CircuitGeometryIDs maps our circuitId to bacinger's <cc>-<year-opened> id.
// Add new circuits here when they join the calendar.
var CircuitGeometryIDs = map[string]string{
"albert_park": "au-1953", "shanghai": "cn-2004", "suzuka": "jp-1962", "bahrain": "bh-2002",
"jeddah": "sa-2021", "miami": "us-2022", "imola": "it-1953", "monaco": "mc-1929",
Expand All @@ -30,8 +26,7 @@ var CircuitGeometryIDs = map[string]string{
"losail": "qa-2004", "yas_marina": "ae-2009",
}

// FetchGeometry returns the [lon, lat] outline and circuit name for the
// given geojson id (a CircuitGeometryIDs value).
// FetchGeometry returns the [lon, lat] outline and name for a CircuitGeometryIDs value.
func FetchGeometry(client *http.Client, base, geometryID string) ([][]float64, string, error) {
response, err := client.Get(fmt.Sprintf("%s/%s.geojson", base, geometryID))
if err != nil {
Expand Down Expand Up @@ -59,10 +54,8 @@ func FetchGeometry(client *http.Client, base, geometryID string) ([][]float64, s
return geo.Features[0].Geometry.Coordinates, geo.Features[0].Properties.Name, nil
}

// RenderSVG projects [lon, lat] coordinates to local meters (equirectangular -
// good enough for a track a few km across) and draws them as a track-outline
// SVG: a black outline with the given colour on top, sized to a 300px-wide
// dashboard tile.
// RenderSVG draws a 300px-wide track outline; equirectangular projection is
// fine at a few km.
func RenderSVG(coordinates [][]float64, name, colour string) ([]byte, error) {
if len(coordinates) == 0 {
return nil, errors.New("No track coordinates to draw")
Expand Down
18 changes: 6 additions & 12 deletions latest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@ import (
"github.com/labstack/echo/v4"
)

// raceWindow is how long after its start a race is still treated as running -
// F1 races are capped at 3h including stoppages, but almost all finish inside 2.
// raceWindow: races are capped at 3h incl. stoppages, almost all finish inside 2.
const raceWindow = 2 * time.Hour

var sessionLabels = map[string]string{"fp1": "Free Practice 1", "fp2": "Free Practice 2", "fp3": "Free Practice 3", "sprintQualy": "Sprint Qualifying", "sprintRace": "Sprint Race", "qualy": "Qualifying", "race": "Race"}

var openF1SessionNames = map[string]string{"fp1": "Practice 1", "fp2": "Practice 2", "fp3": "Practice 3", "sprintQualy": "Sprint Qualifying", "sprintRace": "Sprint", "qualy": "Qualifying", "race": "Race"}

// sessionWindows is how long after its scheduled start a session counts as
// still running, so it isn't picked as the "latest" one before it's over.
// sessionWindows keep a running session from being picked as "latest".
var sessionWindows = map[string]time.Duration{"fp1": time.Hour, "fp2": time.Hour, "fp3": time.Hour, "sprintQualy": 45 * time.Minute, "sprintRace": 45 * time.Minute, "qualy": time.Hour}

func matchOpenF1Session(sessions []openF1Session, key string, at time.Time) *openF1Session {
Expand Down Expand Up @@ -89,8 +87,7 @@ func (a *app) latestSession(c echo.Context) error {
latest := sessions[0]
result := a.sessionResponse(year, latest, []any{})
ttl, have := 2*time.Minute, false
// A finished session's classification never changes, so it's kept for
// good - OpenF1's free tier locks out all access while any session is live.
// cached for good: OpenF1's free tier locks out all access while any session is live
cacheKey := sessionResultsKey(year, latest)
if cached, ok := a.cache.getDurable(cacheKey, now); ok {
result["results"], ttl, have = cached, 5*time.Minute, true
Expand All @@ -104,8 +101,7 @@ func (a *app) latestSession(c echo.Context) error {
}
}
if !have {
// Latest results aren't available yet (typically the lockout right after a
// session): show the newest earlier session we still hold, and say what's pending.
// no latest results yet (post-session lockout): show the newest one we hold
for _, s := range sessions[1:] {
if cached, ok := a.cache.getDurable(sessionResultsKey(year, s), now); ok {
fallback := a.sessionResponse(year, s, cached)
Expand Down Expand Up @@ -204,8 +200,7 @@ func sessionTimeCell(duration, gap json.RawMessage, dnf, dns, dsq, leader bool)
return ""
}

// lastNumber reads a JSON number, or the last non-null entry of an array of
// them (OpenF1 returns per-phase values for qualifying, e.g. [Q1, Q2, Q3]).
// lastNumber reads a number or the last non-null array entry (qualifying is [Q1, Q2, Q3]).
func lastNumber(raw json.RawMessage) (float64, bool) {
if len(raw) == 0 || string(raw) == "null" {
return 0, false
Expand Down Expand Up @@ -235,8 +230,7 @@ func formatSessionDuration(seconds float64) string {
return fmt.Sprintf("%d:%02d.%03d", m, s, ms)
}

// shortTeamName maps OpenF1's team names onto the short names the rest of
// the API uses (from Ergast constructor ids), so tiles read consistently.
// shortTeamName maps OpenF1 team names onto the API's Ergast-derived short names.
func shortTeamName(name string) string {
switch name {
case "Red Bull Racing":
Expand Down
5 changes: 2 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ func (a *app) fetchJSON(url string, target any) error {
return decoder.Decode(target)
}

// fetchOpenF1 spaces out calls to OpenF1, whose free tier allows 3 requests
// per second - the tyre and latest-session widgets each need several per
// refresh and load together.
// fetchOpenF1 spaces calls out: OpenF1's free tier allows 3 req/s and widgets
// load together.
func (a *app) fetchOpenF1(url string, target any) error {
a.openF1Mu.Lock()
if wait := time.Until(a.openF1Next); wait > 0 {
Expand Down
11 changes: 4 additions & 7 deletions tyres.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ type openF1Session struct {
End string `json:"date_end"`
}

// currentWeekend is the latest race weekend whose first session has started -
// it stays current until the next weekend's first session starts, so a
// finished race's tyre usage keeps showing through the week.
// currentWeekend is the latest weekend whose first session has started; it
// stays current until the next one's does, so tyre usage shows all week.
func currentWeekend(races []race, now time.Time) *race {
var current *race
for i := range races {
Expand Down Expand Up @@ -61,10 +60,8 @@ func (a *app) tyreUsage(c echo.Context) error {
if at.IsZero() || at.After(now) {
continue
}
// A finished session's stints never change, so once fetched they're
// kept for good - OpenF1's free tier locks out all access (even past
// sessions) while any session is live, which would otherwise blank
// data we already had.
// stints never change once finished; keep them, since OpenF1 locks out
// even past sessions while any session is live
sessionCacheKey := fmt.Sprintf("tyre_session:%d:%d:%s", year, selected.Round, key)
if cached, ok := a.cache.getDurable(sessionCacheKey, now); ok {
sessions[key] = cached
Expand Down
9 changes: 3 additions & 6 deletions widgets/latest-session/f1_latest_session.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@
title: Latest Session
cache: 5m
url: http://${F1_API_URL}:4463/f1/latest_session/
# The most recently finished non-race session of the current weekend
# (practice, sprint qualifying, qualifying, sprint) - the Grand Prix itself
# is Last Race Results' job. Practice/qualifying results only exist on
# OpenF1, which locks out free access while any session is live, so a
# just-finished session can take a while to show up - until it does, the
# previous session's results are shown with a note.
# Latest finished non-race session (the GP is Last Race Results' job). OpenF1
# locks out free access while a session is live, so until the new one shows up
# the previous session's results are shown with a note.
template: |
{{ $session := .JSON.String "session" }}
{{ $rows := .JSON.Array "results" }}
Expand Down
12 changes: 3 additions & 9 deletions widgets/tyre-usage/f1_tyre_usage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,9 @@
title: Tyre Usage
cache: 5m
url: http://${F1_API_URL}:4463/f1/tyre_usage/
# Usage only, not allocation: there's no structured source anywhere for a
# driver's pre-weekend tyre-compound allocation, so this only ever reports
# what OpenF1 recorded being used stint-by-stint once a session's actually
# run. Each session key is only present once its scheduled time has
# passed; a still-null key's sessions.Array returns an empty slice, so
# between race weekends - when every session is still null - this shows a
# deliberate "no data yet" message instead of a blank box under the title
# (Glance itself always renders the widget's title/container regardless of
# template output, so there's no way to hide the whole tile from in here).
# Usage only: there's no source for pre-weekend allocation. Session keys are null
# until scheduled, so between weekends this prints an explicit "no data yet"
# (Glance renders the title regardless, so the tile can't be hidden).
template: |
<div class="flex flex-column gap-15">
{{ $fp1 := .JSON.Array "sessions.fp1" }}
Expand Down
Loading