diff --git a/balancer/pkg/scoring/scoring.go b/balancer/pkg/scoring/scoring.go index 03156d416..493eccf2f 100644 --- a/balancer/pkg/scoring/scoring.go +++ b/balancer/pkg/scoring/scoring.go @@ -35,6 +35,9 @@ type ScoringService struct { currentScoresSorted []*TeamScore currentScoresMutex *sync.Mutex + lastSeenUpdate time.Time + newUpdate sync.Cond + challengesMap map[string](bundle.JuiceShopChallenge) } @@ -55,6 +58,9 @@ func NewScoringServiceWithInitialScores(b *bundle.Bundle, initialScores map[stri currentScoresSorted: sortTeamsByScoreAndCalculatePositions(initialScores), currentScoresMutex: &sync.Mutex{}, + lastSeenUpdate: time.Now(), + newUpdate: *sync.NewCond(&sync.Mutex{}), + challengesMap: cachedChallengesMap, } } @@ -67,6 +73,33 @@ func (s *ScoringService) GetTopScores() []*TeamScore { return s.currentScoresSorted } +func (s *ScoringService) WaitForUpdatesNewerThan(ctx context.Context, lastSeenUpdate time.Time) []*TeamScore { + if s.lastSeenUpdate.After(lastSeenUpdate) { + // the last update was after the last seen update, so we can return the current scores without waiting + return s.currentScoresSorted + } + + const maxWaitTime = 25 * time.Second + done := make(chan struct{}) + go func() { + s.newUpdate.Wait() + close(done) + }() + + select { + // check for update by subscribing to the newUpdate condition + case <-done: + // new update was received + return s.currentScoresSorted + case <-time.After(maxWaitTime): + // timeout was reached + return nil + case <-ctx.Done(): + // request was aborted + return nil + } +} + // StartingScoringWorker starts a worker that listens for changes in JuiceShop deployments and updates the scores accordingly func (s *ScoringService) StartingScoringWorker(ctx context.Context) { watcher, err := s.bundle.ClientSet.AppsV1().Deployments(s.bundle.RuntimeEnvironment.Namespace).Watch(ctx, metav1.ListOptions{ @@ -101,6 +134,7 @@ func (s *ScoringService) StartingScoringWorker(ctx context.Context) { s.currentScoresMutex.Lock() s.currentScores[score.Name] = score s.currentScoresSorted = sortTeamsByScoreAndCalculatePositions(s.currentScores) + s.newUpdate.Broadcast() s.currentScoresMutex.Unlock() case watch.Deleted: deployment := event.Object.(*appsv1.Deployment) @@ -108,6 +142,7 @@ func (s *ScoringService) StartingScoringWorker(ctx context.Context) { s.currentScoresMutex.Lock() delete(s.currentScores, team) s.currentScoresSorted = sortTeamsByScoreAndCalculatePositions(s.currentScores) + s.newUpdate.Broadcast() s.currentScoresMutex.Unlock() default: s.bundle.Log.Printf("Unknown event type: %v", event.Type) diff --git a/balancer/routes/score-board.go b/balancer/routes/score-board.go index 35687507a..5b0f1bd2b 100644 --- a/balancer/routes/score-board.go +++ b/balancer/routes/score-board.go @@ -3,6 +3,7 @@ package routes import ( "encoding/json" "net/http" + "time" b "github.com/juice-shop/multi-juicer/balancer/pkg/bundle" "github.com/juice-shop/multi-juicer/balancer/pkg/scoring" @@ -16,7 +17,22 @@ type ScoreBoardResponse struct { func handleScoreBoard(bundle *b.Bundle, scoringService *scoring.ScoringService) http.Handler { return http.HandlerFunc( func(responseWriter http.ResponseWriter, req *http.Request) { - totalTeams := scoringService.GetTopScores() + var totalTeams []*scoring.TeamScore + + if req.URL.Query().Get("wait-for-update-after") != "" { + lastSeenUpdate, err := time.Parse(time.RFC3339, req.URL.Query().Get("wait-for-update-after")) + if err != nil { + http.Error(responseWriter, "Invalid time format", http.StatusBadRequest) + return + } + totalTeams = scoringService.WaitForUpdatesNewerThan(req.Context(), lastSeenUpdate) + if totalTeams == nil { + responseWriter.WriteHeader(http.StatusNoContent) + return + } + } else { + totalTeams = scoringService.GetTopScores() + } var topTeams []*scoring.TeamScore // limit score-board to calculate score for the top 24 teams only diff --git a/balancer/ui/src/pages/ScoreOverview.tsx b/balancer/ui/src/pages/ScoreOverview.tsx index 822748fdd..581254640 100644 --- a/balancer/ui/src/pages/ScoreOverview.tsx +++ b/balancer/ui/src/pages/ScoreOverview.tsx @@ -40,7 +40,9 @@ interface Team { } async function fetchTeams(): Promise { - const response = await fetch("/balancer/api/score-board/top"); + const response = await fetch( + `/balancer/api/score-board/top?wait-for-update-after=${new Date().toISOString()}` + ); const { teams } = await response.json(); return teams; } @@ -62,6 +64,38 @@ export function ScoreOverviewPage({ clearInterval(timer); }; }, []); + const [lastUpdateStarted, setLastUpdateStarted] = useState(Date.now()); + + let timeout: number | null = null; + async function updateScoreData() { + try { + setLastUpdateStarted(Date.now()); + const status = await fetchTeams(); + setTeams(status); + + // the request is using a http long polling mechanism to get the updates as soon as possible + // in case the request returns immediatly we wait for at least 3 seconds to ensure we aren't spamming the server + const waitTime = Math.max(3000, 5000 - (Date.now() - lastUpdateStarted)); + console.log( + "Waited for", + Date.now() - lastUpdateStarted, + "ms for status update" + ); + console.log("Waiting for", waitTime, "ms until starting next request"); + timeout = window.setTimeout(() => updateScoreData(), waitTime); + } catch (err) { + console.error("Failed to fetch current teams!", err); + } + } + + useEffect(() => { + updateScoreData(); + return () => { + if (timeout !== null) { + clearTimeout(timeout); + } + }; + }, []); return ( <>