From c566b8ff9bf8fe971228bcf9a26e0be899bb0efa Mon Sep 17 00:00:00 2001 From: tbphp Date: Wed, 19 Aug 2026 23:42:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA=E4=B8=8E=E7=A4=BE?= =?UTF-8?q?=E5=8C=BA=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + README_CN.md | 2 + README_JP.md | 2 + internal/container/container.go | 11 +- internal/control/home.go | 27 ++- internal/control/home_statistics_test.go | 3 + internal/control/home_update_test.go | 131 +++++++++++ internal/control/runtime.go | 15 ++ internal/control/runtime_test.go | 32 +++ internal/control/server.go | 18 ++ internal/releasecheck/checker.go | 139 ++++++++++++ internal/releasecheck/checker_test.go | 184 +++++++++++++++ internal/releasecheck/client.go | 116 ++++++++++ internal/releasecheck/client_test.go | 112 +++++++++ internal/releasecheck/selection.go | 214 ++++++++++++++++++ internal/releasecheck/selection_test.go | 138 +++++++++++ web/src/app/resources/home.ts | 41 ++++ .../features/home/HomeReleaseUpdateLink.vue | 61 +++++ web/src/features/home/HomeSummary.vue | 18 +- web/src/features/home/HomeView.vue | 5 +- web/src/features/home/HomeWelcome.vue | 23 +- .../preferences/PreferencesControl.vue | 73 +++++- web/src/i18n/locales/en-US/core.ts | 5 + web/src/i18n/locales/ja-JP/core.ts | 5 + web/src/i18n/locales/zh-CN/core.ts | 5 + 25 files changed, 1373 insertions(+), 9 deletions(-) create mode 100644 internal/control/home_update_test.go create mode 100644 internal/releasecheck/checker.go create mode 100644 internal/releasecheck/checker_test.go create mode 100644 internal/releasecheck/client.go create mode 100644 internal/releasecheck/client_test.go create mode 100644 internal/releasecheck/selection.go create mode 100644 internal/releasecheck/selection_test.go create mode 100644 web/src/features/home/HomeReleaseUpdateLink.vue diff --git a/README.md b/README.md index 93e3d3ca4..532c7d637 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,8 @@ Channel icons identify their respective upstream providers. All trademarks belon For problems or feature ideas, open a [GitHub Issue](https://github.com/tbphp/gpt-load/issues). Report security vulnerabilities through the process in [SECURITY.md](SECURITY.md). +For community chat and usage discussion, join the [Telegram group](https://t.me/+GHpy5SwEllg3MTUx). + If GPT-Load is useful to you, a star is appreciated. ## Sponsors and support diff --git a/README_CN.md b/README_CN.md index 57f9ce0f2..a523cf255 100644 --- a/README_CN.md +++ b/README_CN.md @@ -261,6 +261,8 @@ GPT-Load 自身负责凭据存储、账号选择、调度、重试、健康、 遇到问题或有功能建议,欢迎提交 [GitHub Issue](https://github.com/tbphp/gpt-load/issues)。安全漏洞请按 [SECURITY.md](SECURITY.md) 的流程报告。 +社区交流与使用讨论可加入 [Telegram 群组](https://t.me/+GHpy5SwEllg3MTUx)。 + 如果 GPT-Load 对你有帮助,欢迎点个 Star。 ## 赞助与支持 diff --git a/README_JP.md b/README_JP.md index eb4c80822..a3c2b3544 100644 --- a/README_JP.md +++ b/README_JP.md @@ -262,6 +262,8 @@ GPT-Load の一部機能は以下のプロジェクトを基盤としていま 問題や機能の提案は [GitHub Issue](https://github.com/tbphp/gpt-load/issues) へお寄せください。セキュリティ脆弱性は [SECURITY.md](SECURITY.md) の手順に従って報告してください。 +コミュニティでの交流や利用方法の相談は、[Telegram グループ](https://t.me/+GHpy5SwEllg3MTUx) にご参加ください。 + GPT-Load が役に立ったら、Star をいただけると嬉しいです。 ## スポンサーと支援 diff --git a/internal/container/container.go b/internal/container/container.go index a4e16e8ee..72e8f6374 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -29,6 +29,7 @@ import ( "gpt-load/internal/pricing" "gpt-load/internal/provideradapter" "gpt-load/internal/ratelimit" + "gpt-load/internal/releasecheck" "gpt-load/internal/requestlog" "gpt-load/internal/state" stateloader "gpt-load/internal/state/loader" @@ -110,6 +111,8 @@ func BuildContainer() (*dig.Container, error) { control.NewRuntime, func(runtime *control.Runtime) app.ControlRuntime { return runtime }, httpclient.NewHTTPClientManager, + releasecheck.NewClient, + releasecheck.NewChecker, func(manager *httpclient.HTTPClientManager) *catalog.Client { return catalog.NewClient(manager, "") }, @@ -162,7 +165,13 @@ func BuildContainer() (*dig.Container, error) { control.NewCatalogSyncCoordinator, func(service *control.Service) app.StartupBootstrap { return service }, func(service *control.Service) app.StartupRecovery { return service }, - control.NewServer, + func( + cfg *config.Config, + service *control.Service, + checker *releasecheck.Checker, + ) *control.Server { + return control.NewServerWithReleaseUpdateReader(cfg, service, checker) + }, newHTTPRegistry, func( db *gorm.DB, diff --git a/internal/control/home.go b/internal/control/home.go index 9c03f8638..69a6b894e 100644 --- a/internal/control/home.go +++ b/internal/control/home.go @@ -39,12 +39,19 @@ type HomeBase struct { } type homeResponse struct { - ServerNowMS int64 `json:"server_now_ms"` - StartedAtMS int64 `json:"started_at_ms"` - Version string `json:"version"` + ServerNowMS int64 `json:"server_now_ms"` + StartedAtMS int64 `json:"started_at_ms"` + Version string `json:"version"` + Update *homeUpdateResponse `json:"update"` HomeBase } +type homeUpdateResponse struct { + Version string `json:"version"` + ReleaseURL string `json:"release_url"` + PublishedAtMS int64 `json:"published_at_ms"` +} + type homeCredentialRow struct { ID uint GroupID uint @@ -210,7 +217,8 @@ func (s *Server) handleHome(c *gin.Context) { return } var base HomeBase - if accessKeyID, scoped := currentAccessKeyID(c); scoped { + accessKeyID, scoped := currentAccessKeyID(c) + if scoped { base, err = s.service.ReadAccessKeyHomeBase( c.Request.Context(), serverNowMS, @@ -223,10 +231,21 @@ func (s *Server) handleHome(c *gin.Context) { writeServiceError(c, "home", err) return } + var update *homeUpdateResponse + if !scoped && s.releaseUpdates != nil { + if available := s.releaseUpdates.Snapshot(); available != nil { + update = &homeUpdateResponse{ + Version: available.Version, + ReleaseURL: available.ReleaseURL, + PublishedAtMS: available.PublishedAtMS, + } + } + } response.SuccessI18n(c, "common.success", homeResponse{ ServerNowMS: serverNowMS, StartedAtMS: startedAtMS, Version: version.Version, + Update: update, HomeBase: base, }) } diff --git a/internal/control/home_statistics_test.go b/internal/control/home_statistics_test.go index a89ece0b3..3d979eac5 100644 --- a/internal/control/home_statistics_test.go +++ b/internal/control/home_statistics_test.go @@ -56,6 +56,7 @@ func TestHomeBaseHTTPUsesAuthenticationEnvelopeAndServerClock(t *testing.T) { ServerNowMS int64 `json:"server_now_ms"` StartedAtMS int64 `json:"started_at_ms"` Version string `json:"version"` + Update *homeUpdateResponse `json:"update"` Inventory HomeInventory `json:"inventory"` AccessKeys []HomeAccessKey `json:"access_keys"` CurrentAccessKey *AccessKeyCollectionItem `json:"current_access_key"` @@ -68,6 +69,7 @@ func TestHomeBaseHTTPUsesAuthenticationEnvelopeAndServerClock(t *testing.T) { envelope.Data.ServerNowMS != now.UnixMilli() || envelope.Data.StartedAtMS != startedAt.UnixMilli() || envelope.Data.Version != version.Version || + envelope.Data.Update != nil || envelope.Data.Inventory != (HomeInventory{}) || envelope.Data.AccessKeys == nil || len(envelope.Data.AccessKeys) != 0 || @@ -81,6 +83,7 @@ func TestHomeBaseHTTPUsesAuthenticationEnvelopeAndServerClock(t *testing.T) { "server_now_ms", "started_at_ms", "version", + "update", "inventory", "access_keys", "current_access_key", diff --git a/internal/control/home_update_test.go b/internal/control/home_update_test.go new file mode 100644 index 000000000..f4be3a71a --- /dev/null +++ b/internal/control/home_update_test.go @@ -0,0 +1,131 @@ +package control + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "gpt-load/internal/platform/config" + "gpt-load/internal/releasecheck" +) + +type staticReleaseUpdateReader struct { + update *releasecheck.Update + calls int +} + +func (reader *staticReleaseUpdateReader) Snapshot() *releasecheck.Update { + reader.calls++ + if reader.update == nil { + return nil + } + result := *reader.update + return &result +} + +func TestHomeHTTPExposesConfirmedReleaseUpdateToAdmin(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + reader := &staticReleaseUpdateReader{update: &releasecheck.Update{ + Version: "v2.0.0-beta.8", + ReleaseURL: "https://github.com/tbphp/gpt-load/releases/tag/v2.0.0-beta.8", + PublishedAtMS: time.Date(2026, time.August, 19, 13, 9, 53, 0, time.UTC).UnixMilli(), + }} + server := NewServerWithReleaseUpdateReader( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + reader, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + recorder := performHomeRequest(engine, "/api/home", "test-auth-key") + if recorder.Code != http.StatusOK { + t.Fatalf("GET /api/home = %d %s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data struct { + Update *homeUpdateResponse `json:"update"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + want := homeUpdateResponse{ + Version: reader.update.Version, + ReleaseURL: reader.update.ReleaseURL, + PublishedAtMS: reader.update.PublishedAtMS, + } + if envelope.Data.Update == nil || *envelope.Data.Update != want || reader.calls != 1 { + t.Fatalf("update/calls = %#v/%d, want %#v/1", envelope.Data.Update, reader.calls, want) + } + assertManagementWireObject(t, *envelope.Data.Update, []string{ + "version", "release_url", "published_at_ms", + }) +} + +func TestHomeHTTPHidesReleaseUpdateFromAccessKey(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + accessKey, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "read only"}) + if err != nil { + t.Fatalf("CreateAccessKey() error = %v", err) + } + reader := &staticReleaseUpdateReader{update: &releasecheck.Update{ + Version: "v2.0.1", + ReleaseURL: "https://github.com/tbphp/gpt-load/releases/tag/v2.0.1", + PublishedAtMS: time.Now().UnixMilli(), + }} + server := NewServerWithReleaseUpdateReader( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + reader, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + recorder := performHomeRequest(engine, "/api/home", accessKey.Key) + if recorder.Code != http.StatusOK { + t.Fatalf("GET /api/home = %d %s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data struct { + Update *homeUpdateResponse `json:"update"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if envelope.Data.Update != nil || reader.calls != 0 { + t.Fatalf("AccessKey update/calls = %#v/%d, want nil/0", envelope.Data.Update, reader.calls) + } +} + +func TestHomeHTTPUsesNullWhenNoReleaseUpdateIsConfirmed(t *testing.T) { + fixture := newServiceFixture(t) + reader := &staticReleaseUpdateReader{} + server := NewServerWithReleaseUpdateReader( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + reader, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + recorder := performHomeRequest(engine, "/api/home", "test-auth-key") + if recorder.Code != http.StatusOK || !json.Valid(recorder.Body.Bytes()) { + t.Fatalf("GET /api/home = %d %s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if raw, exists := envelope.Data["update"]; !exists || string(raw) != "null" || reader.calls != 1 { + t.Fatalf("update/calls = %s/%d, want null/1", raw, reader.calls) + } +} diff --git a/internal/control/runtime.go b/internal/control/runtime.go index f2b49c0a4..c2b3d3808 100644 --- a/internal/control/runtime.go +++ b/internal/control/runtime.go @@ -12,6 +12,7 @@ import ( "gpt-load/internal/execution" "gpt-load/internal/health" "gpt-load/internal/platform/encryption" + "gpt-load/internal/releasecheck" "gpt-load/internal/state" ) @@ -43,6 +44,10 @@ type catalogSyncRuntime interface { Run(context.Context) } +type releaseCheckRuntime interface { + Run(context.Context) +} + // RequestLogCleaner is the control-owned scheduling view of request log // retention. The requestlog package owns all cleanup semantics. type RequestLogCleaner interface { @@ -80,6 +85,7 @@ type Runtime struct { operationRecovery operationRecoveryRuntime credentialObservations credentialObservationRuntime catalogSync catalogSyncRuntime + releaseCheck releaseCheckRuntime oauthCallback *OAuthCallbackManager manager *state.Manager autoWeightInterval time.Duration @@ -100,6 +106,7 @@ func NewRuntime( requestLogCleaner RequestLogCleaner, operationRecovery *Service, catalogSync *CatalogSyncCoordinator, + releaseChecker *releasecheck.Checker, ) *Runtime { runtime := &Runtime{ registry: registry, @@ -110,6 +117,7 @@ func NewRuntime( operationRecovery: operationRecovery, credentialObservations: operationRecovery, catalogSync: catalogSync, + releaseCheck: releaseChecker, manager: manager, autoWeightInterval: autoWeightInterval, validationInterval: defaultValidationInterval, @@ -183,6 +191,13 @@ func (runtime *Runtime) Run(ctx context.Context) { runtime.catalogSync.Run(ctx) }() } + if runtime.releaseCheck != nil { + wait.Add(1) + go func() { + defer wait.Done() + runtime.releaseCheck.Run(ctx) + }() + } if runtime.oauthCallback != nil { wait.Add(1) go func() { diff --git a/internal/control/runtime_test.go b/internal/control/runtime_test.go index 9e44a401a..f905cb934 100644 --- a/internal/control/runtime_test.go +++ b/internal/control/runtime_test.go @@ -179,6 +179,38 @@ type controlledOperationRecovery struct { returned chan struct{} } +type controlledReleaseCheckRuntime struct { + started chan struct{} + returned chan struct{} +} + +func (runtime *controlledReleaseCheckRuntime) Run(ctx context.Context) { + close(runtime.started) + <-ctx.Done() + close(runtime.returned) +} + +func TestRuntimeRunsReleaseCheckerUntilCancellation(t *testing.T) { + releaseRuntime := &controlledReleaseCheckRuntime{ + started: make(chan struct{}), + returned: make(chan struct{}), + } + runtime, _, _, created := newRuntimeHarness( + newFakeAutoWeightRegistry(1), + health.NewStatsStore(), + newFakeValidationSweep(false), + time.Now, + ) + runtime.releaseCheck = releaseRuntime + + cancel, done := startRuntime(t, runtime) + awaitTickers(t, created) + awaitSignal(t, releaseRuntime.started) + cancel() + awaitSignal(t, releaseRuntime.returned) + awaitSignal(t, done) +} + type controlledStageCleaner struct { calls chan time.Time } diff --git a/internal/control/server.go b/internal/control/server.go index 69c7ce678..51425db9e 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -22,9 +22,15 @@ import ( "gpt-load/internal/platform/i18n" "gpt-load/internal/platform/response" "gpt-load/internal/platform/utils" + "gpt-load/internal/releasecheck" subscriptionruntime "gpt-load/internal/subscription/runtime" ) +// ReleaseUpdateReader is the control-plane read view of the public release checker. +type ReleaseUpdateReader interface { + Snapshot() *releasecheck.Update +} + type Server struct { authDigest [sha256.Size]byte service *Service @@ -35,6 +41,7 @@ type Server struct { authFailureEvents *utils.RateLimitedEventCounter startedAt time.Time now func() time.Time + releaseUpdates ReleaseUpdateReader } const maxControlJSONBodyBytes int64 = 32 << 20 @@ -61,6 +68,17 @@ func NewServer(cfg *config.Config, service *Service) *Server { } } +// NewServerWithReleaseUpdateReader wires the optional public update snapshot. +func NewServerWithReleaseUpdateReader( + cfg *config.Config, + service *Service, + releaseUpdates ReleaseUpdateReader, +) *Server { + server := NewServer(cfg, service) + server.releaseUpdates = releaseUpdates + return server +} + func (s *Server) handleGetSettings(c *gin.Context) { result, err := s.service.GetSettings(c.Request.Context()) if err != nil { diff --git a/internal/releasecheck/checker.go b/internal/releasecheck/checker.go new file mode 100644 index 000000000..bdbb0f1b5 --- /dev/null +++ b/internal/releasecheck/checker.go @@ -0,0 +1,139 @@ +package releasecheck + +import ( + "context" + "sync" + "time" + + "gpt-load/internal/platform/version" +) + +const ( + successCacheDuration = 6 * time.Hour + failureRetryDuration = 30 * time.Minute +) + +type releaseFetcher interface { + Fetch(context.Context) ([]Release, error) +} + +// Checker maintains the current public update result. +type Checker struct { + fetcher releaseFetcher + current string + + checkMu sync.Mutex + mu sync.RWMutex + update *Update + nextCheckAt time.Time + now func() time.Time + wait func(context.Context, time.Duration) bool +} + +// NewChecker creates the process-local release checker. +func NewChecker(client *Client) *Checker { + return newChecker(client, version.Version) +} + +func newChecker(fetcher releaseFetcher, current string) *Checker { + return &Checker{ + fetcher: fetcher, + current: current, + now: time.Now, + wait: waitForNextCheck, + } +} + +// Snapshot returns the currently confirmed update, if any. +func (checker *Checker) Snapshot() *Update { + if checker == nil { + return nil + } + checker.mu.RLock() + defer checker.mu.RUnlock() + if checker.update == nil { + return nil + } + result := *checker.update + return &result +} + +func (checker *Checker) check(ctx context.Context) time.Duration { + if checker == nil || checker.fetcher == nil { + return failureRetryDuration + } + checker.checkMu.Lock() + defer checker.checkMu.Unlock() + if ctx == nil { + ctx = context.Background() + } + now := checker.currentTime() + checker.mu.RLock() + nextCheckAt := checker.nextCheckAt + checker.mu.RUnlock() + if now.Before(nextCheckAt) { + return nextCheckAt.Sub(now) + } + + releases, err := checker.fetcher.Fetch(ctx) + if ctx.Err() != nil { + return 0 + } + delay := successCacheDuration + var update *Update + if err != nil { + delay = failureRetryDuration + } else { + update = SelectUpdate(checker.current, releases) + } + checkedAt := checker.currentTime() + checker.mu.Lock() + checker.update = update + checker.nextCheckAt = checkedAt.Add(delay) + checker.mu.Unlock() + return delay +} + +// Run checks immediately, then waits according to the result cache policy. +func (checker *Checker) Run(ctx context.Context) { + if checker == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + wait := checker.wait + if wait == nil { + wait = waitForNextCheck + } + for ctx.Err() == nil { + delay := checker.check(ctx) + if ctx.Err() != nil { + return + } + if delay <= 0 { + delay = failureRetryDuration + } + if !wait(ctx, delay) { + return + } + } +} + +func (checker *Checker) currentTime() time.Time { + if checker.now == nil { + return time.Now().UTC() + } + return checker.now().UTC() +} + +func waitForNextCheck(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/internal/releasecheck/checker_test.go b/internal/releasecheck/checker_test.go new file mode 100644 index 000000000..c914d8bc9 --- /dev/null +++ b/internal/releasecheck/checker_test.go @@ -0,0 +1,184 @@ +package releasecheck + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fetchResult struct { + releases []Release + err error +} + +type sequenceFetcher struct { + mu sync.Mutex + results []fetchResult + calls int +} + +func (fetcher *sequenceFetcher) Fetch(context.Context) ([]Release, error) { + fetcher.mu.Lock() + defer fetcher.mu.Unlock() + index := fetcher.calls + fetcher.calls++ + if index >= len(fetcher.results) { + return nil, errors.New("unexpected fetch") + } + return fetcher.results[index].releases, fetcher.results[index].err +} + +func (fetcher *sequenceFetcher) callCount() int { + fetcher.mu.Lock() + defer fetcher.mu.Unlock() + return fetcher.calls +} + +func TestCheckerCachesSuccessForSixHoursAndFailureForThirtyMinutes(t *testing.T) { + base := time.Date(2026, time.August, 19, 12, 0, 0, 0, time.UTC) + now := base + fetcher := &sequenceFetcher{results: []fetchResult{ + {releases: []Release{testRelease("v2.0.1", "2026-08-19T11:00:00Z")}}, + {err: errors.New("offline")}, + {releases: []Release{testRelease("v2.0.2", "2026-08-19T19:00:00Z")}}, + }} + checker := newChecker(fetcher, "v2.0.0") + checker.now = func() time.Time { return now } + + if delay := checker.check(t.Context()); delay != successCacheDuration { + t.Fatalf("first delay = %v, want %v", delay, successCacheDuration) + } + if update := checker.Snapshot(); update == nil || update.Version != "v2.0.1" { + t.Fatalf("first Snapshot() = %#v", update) + } + + now = base.Add(2 * time.Hour) + if delay := checker.check(t.Context()); delay != 4*time.Hour || fetcher.callCount() != 1 { + t.Fatalf("cached delay/calls = %v/%d, want 4h/1", delay, fetcher.callCount()) + } + + now = base.Add(6 * time.Hour) + if delay := checker.check(t.Context()); delay != failureRetryDuration { + t.Fatalf("failure delay = %v, want %v", delay, failureRetryDuration) + } + if update := checker.Snapshot(); update != nil { + t.Fatalf("Snapshot() after failure = %#v, want nil", update) + } + + now = base.Add(6*time.Hour + 15*time.Minute) + if delay := checker.check(t.Context()); delay != 15*time.Minute || fetcher.callCount() != 2 { + t.Fatalf("failure cache delay/calls = %v/%d, want 15m/2", delay, fetcher.callCount()) + } + + now = base.Add(6*time.Hour + 30*time.Minute) + if delay := checker.check(t.Context()); delay != successCacheDuration { + t.Fatalf("recovery delay = %v, want %v", delay, successCacheDuration) + } + if update := checker.Snapshot(); update == nil || update.Version != "v2.0.2" || fetcher.callCount() != 3 { + t.Fatalf("recovered Snapshot/calls = %#v/%d", update, fetcher.callCount()) + } +} + +func TestCheckerCachesSuccessfulNoUpdateResult(t *testing.T) { + fetcher := &sequenceFetcher{results: []fetchResult{{ + releases: []Release{testRelease("v2.0.0-beta.8", "2026-08-19T11:00:00Z")}, + }}} + checker := newChecker(fetcher, "v2.0.0") + if delay := checker.check(t.Context()); delay != successCacheDuration || + checker.Snapshot() != nil || fetcher.callCount() != 1 { + t.Fatalf("delay/update/calls = %v/%#v/%d", delay, checker.Snapshot(), fetcher.callCount()) + } +} + +type concurrentFetcher struct { + calls atomic.Int32 + started chan struct{} + release chan struct{} +} + +func (fetcher *concurrentFetcher) Fetch(context.Context) ([]Release, error) { + if fetcher.calls.Add(1) == 1 { + close(fetcher.started) + } + <-fetcher.release + return nil, nil +} + +func TestCheckerCoalescesConcurrentChecks(t *testing.T) { + fetcher := &concurrentFetcher{ + started: make(chan struct{}), + release: make(chan struct{}), + } + checker := newChecker(fetcher, "v2.0.0") + done := make(chan struct{}, 2) + go func() { + checker.check(t.Context()) + done <- struct{}{} + }() + <-fetcher.started + go func() { + checker.check(t.Context()) + done <- struct{}{} + }() + time.Sleep(25 * time.Millisecond) + if calls := fetcher.calls.Load(); calls != 1 { + close(fetcher.release) + t.Fatalf("concurrent fetch calls = %d, want 1", calls) + } + close(fetcher.release) + <-done + <-done + if calls := fetcher.calls.Load(); calls != 1 { + t.Fatalf("completed fetch calls = %d, want 1", calls) + } +} + +func TestCheckerRunChecksImmediatelyAndUsesResultDelay(t *testing.T) { + fetcher := &sequenceFetcher{results: []fetchResult{{}}} + checker := newChecker(fetcher, "v2.0.0") + var gotDelay time.Duration + checker.wait = func(_ context.Context, delay time.Duration) bool { + gotDelay = delay + return false + } + + checker.Run(t.Context()) + if fetcher.callCount() != 1 || gotDelay != successCacheDuration { + t.Fatalf("Run calls/delay = %d/%v", fetcher.callCount(), gotDelay) + } +} + +type blockingFetcher struct { + started chan struct{} +} + +func (fetcher blockingFetcher) Fetch(ctx context.Context) ([]Release, error) { + close(fetcher.started) + <-ctx.Done() + return nil, ctx.Err() +} + +func TestCheckerRunCancelsActiveFetch(t *testing.T) { + fetcher := blockingFetcher{started: make(chan struct{})} + checker := newChecker(fetcher, "v2.0.0") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + checker.Run(ctx) + close(done) + }() + select { + case <-fetcher.started: + case <-time.After(time.Second): + t.Fatal("Run did not start an immediate release check") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not return after cancellation") + } +} diff --git a/internal/releasecheck/client.go b/internal/releasecheck/client.go new file mode 100644 index 000000000..e4b3c67b6 --- /dev/null +++ b/internal/releasecheck/client.go @@ -0,0 +1,116 @@ +package releasecheck + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "time" + + "gpt-load/internal/platform/httpclient" +) + +const githubReleasesEndpoint = "https://api.github.com/repos/tbphp/gpt-load/releases?per_page=30" + +const maxGitHubResponseBytes int64 = 1 << 20 + +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + +// Client reads public GPT-Load release metadata from GitHub. +type Client struct { + httpClient httpDoer + endpoint string + maxResponseBytes int64 +} + +// NewClient creates the fixed public GitHub release client. +func NewClient(manager *httpclient.HTTPClientManager) *Client { + if manager == nil { + manager = httpclient.NewHTTPClientManager() + } + return &Client{ + httpClient: manager.GetClient(&httpclient.Config{ + ConnectTimeout: 5 * time.Second, + RequestTimeout: 8 * time.Second, + IdleConnTimeout: 30 * time.Second, + MaxIdleConns: 2, + MaxIdleConnsPerHost: 2, + ResponseHeaderTimeout: 5 * time.Second, + DisableCompression: true, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ExpectContinueTimeout: time.Second, + DisableRedirects: true, + }), + endpoint: githubReleasesEndpoint, + maxResponseBytes: maxGitHubResponseBytes, + } +} + +// Fetch returns the public releases used by the selector. +func (client *Client) Fetch(ctx context.Context) ([]Release, error) { + if client == nil || client.httpClient == nil { + return nil, fmt.Errorf("fetch GitHub releases: HTTP client is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + endpoint := client.endpoint + if endpoint == "" { + endpoint = githubReleasesEndpoint + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: create request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + request.Header.Set("User-Agent", "GPT-Load") + + response, err := client.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch GitHub releases: status %d", response.StatusCode) + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || (mediaType != "application/json" && mediaType != "application/vnd.github+json") { + return nil, fmt.Errorf("fetch GitHub releases: invalid content type") + } + limit := client.maxResponseBytes + if limit <= 0 { + limit = maxGitHubResponseBytes + } + payload, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: read response: %w", err) + } + if int64(len(payload)) > limit { + return nil, fmt.Errorf("fetch GitHub releases: response exceeds %d bytes", limit) + } + var upstream []struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` + Draft bool `json:"draft"` + } + if err := json.Unmarshal(payload, &upstream); err != nil { + return nil, fmt.Errorf("fetch GitHub releases: decode response: %w", err) + } + releases := make([]Release, 0, len(upstream)) + for _, release := range upstream { + releases = append(releases, Release{ + TagName: release.TagName, + HTMLURL: release.HTMLURL, + PublishedAt: release.PublishedAt, + Draft: release.Draft, + }) + } + return releases, nil +} diff --git a/internal/releasecheck/client_test.go b/internal/releasecheck/client_test.go new file mode 100644 index 000000000..cb4deebf0 --- /dev/null +++ b/internal/releasecheck/client_test.go @@ -0,0 +1,112 @@ +package releasecheck + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestClientFetchUsesFixedPublicGitHubContract(t *testing.T) { + published := "2026-08-19T13:09:53Z" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/repos/tbphp/gpt-load/releases" || + request.URL.Query().Get("per_page") != "30" || len(request.URL.Query()) != 1 { + t.Errorf("request = %s %s", request.Method, request.URL.String()) + } + if request.Header.Get("Accept") != "application/vnd.github+json" || + request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" || + request.Header.Get("User-Agent") != "GPT-Load" || + request.Header.Get("Authorization") != "" { + t.Errorf("request headers = %#v", request.Header) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(writer, `[ + {"tag_name":"v2.0.0-beta.7","html_url":"%s","published_at":"%s","draft":false,"prerelease":true,"ignored":"value"} + ]`, testReleaseURL("v2.0.0-beta.7"), published) + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30", + maxResponseBytes: maxGitHubResponseBytes, + } + releases, err := client.Fetch(t.Context()) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + if len(releases) != 1 || releases[0].TagName != "v2.0.0-beta.7" || + releases[0].HTMLURL != testReleaseURL("v2.0.0-beta.7") || releases[0].Draft || + !releases[0].PublishedAt.Equal(mustParseReleaseTime(published)) { + t.Fatalf("Fetch() = %#v", releases) + } +} + +func TestClientFetchRejectsUnusableResponses(t *testing.T) { + tests := []struct { + name string + status int + contentType string + body string + limit int64 + }{ + {name: "non success", status: http.StatusForbidden, contentType: "application/json", body: `{"message":"limited"}`}, + {name: "wrong content type", status: http.StatusOK, contentType: "text/html", body: `[]`}, + {name: "invalid json", status: http.StatusOK, contentType: "application/json", body: `[`}, + {name: "trailing json", status: http.StatusOK, contentType: "application/json", body: `[] {}`}, + {name: "oversized", status: http.StatusOK, contentType: "application/json", body: strings.Repeat(" ", 33), limit: 32}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", test.contentType) + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + limit := test.limit + if limit == 0 { + limit = maxGitHubResponseBytes + } + client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: limit} + if releases, err := client.Fetch(context.Background()); err == nil || releases != nil { + t.Fatalf("Fetch() = %#v, %v, want error", releases, err) + } + }) + } +} + +func TestClientFetchHonorsCancellation(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + close(started) + <-request.Context().Done() + })) + defer server.Close() + + client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: maxGitHubResponseBytes} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := client.Fetch(ctx) + done <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("Fetch() did not start an HTTP request") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("Fetch() error = nil after cancellation") + } + case <-time.After(time.Second): + t.Fatal("Fetch() did not return after cancellation") + } +} diff --git a/internal/releasecheck/selection.go b/internal/releasecheck/selection.go new file mode 100644 index 000000000..afd66ad77 --- /dev/null +++ b/internal/releasecheck/selection.go @@ -0,0 +1,214 @@ +package releasecheck + +import ( + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const supportedMajor uint64 = 2 + +const maxSafeInteger int64 = 1<<53 - 1 + +var releaseVersionPattern = regexp.MustCompile( + `^(?:v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`, +) + +// Release is the public GitHub metadata used to select a newer version. +type Release struct { + TagName string + HTMLURL string + PublishedAt time.Time + Draft bool +} + +// Update is the immutable public result exposed to the control plane. +type Update struct { + Version string + ReleaseURL string + PublishedAtMS int64 +} + +// SelectUpdate returns the highest eligible V2 release newer than current. +func SelectUpdate(current string, releases []Release) *Update { + currentVersion, ok := parseVersion(current) + if !ok || currentVersion.major != supportedMajor { + return nil + } + + var selected *Release + var selectedVersion semanticVersion + for index := range releases { + candidate := &releases[index] + candidateVersion, valid := validCandidate(*candidate) + if !valid || candidateVersion.major != supportedMajor || + compareVersions(candidateVersion, currentVersion) <= 0 || + (currentVersion.stable() && !candidateVersion.stable()) { + continue + } + if selected == nil || compareVersions(candidateVersion, selectedVersion) > 0 { + selected = candidate + selectedVersion = candidateVersion + } + } + if selected == nil { + return nil + } + return &Update{ + Version: selected.TagName, + ReleaseURL: selected.HTMLURL, + PublishedAtMS: selected.PublishedAt.UnixMilli(), + } +} + +type semanticVersion struct { + major uint64 + minor uint64 + patch uint64 + prerelease []prereleaseIdentifier +} + +type prereleaseIdentifier struct { + raw string + number uint64 + numeric bool +} + +func (version semanticVersion) stable() bool { + return len(version.prerelease) == 0 +} + +func parseVersion(raw string) (semanticVersion, bool) { + matches := releaseVersionPattern.FindStringSubmatch(strings.TrimSpace(raw)) + if matches == nil { + return semanticVersion{}, false + } + major, ok := parseVersionNumber(matches[1]) + if !ok { + return semanticVersion{}, false + } + minor, ok := parseVersionNumber(matches[2]) + if !ok { + return semanticVersion{}, false + } + patch, ok := parseVersionNumber(matches[3]) + if !ok { + return semanticVersion{}, false + } + result := semanticVersion{major: major, minor: minor, patch: patch} + if matches[4] == "" { + return result, true + } + for _, rawIdentifier := range strings.Split(matches[4], ".") { + identifier := prereleaseIdentifier{raw: rawIdentifier} + if allASCIIBytes(rawIdentifier, '0', '9') { + if len(rawIdentifier) > 1 && rawIdentifier[0] == '0' { + return semanticVersion{}, false + } + identifier.number, ok = parseVersionNumber(rawIdentifier) + if !ok { + return semanticVersion{}, false + } + identifier.numeric = true + } + result.prerelease = append(result.prerelease, identifier) + } + return result, true +} + +func parseVersionNumber(raw string) (uint64, bool) { + value, err := strconv.ParseUint(raw, 10, 64) + return value, err == nil +} + +func allASCIIBytes(value string, minimum, maximum byte) bool { + if value == "" { + return false + } + for index := range len(value) { + if value[index] < minimum || value[index] > maximum { + return false + } + } + return true +} + +func compareVersions(left, right semanticVersion) int { + for _, pair := range [][2]uint64{ + {left.major, right.major}, + {left.minor, right.minor}, + {left.patch, right.patch}, + } { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if left.stable() { + if right.stable() { + return 0 + } + return 1 + } + if right.stable() { + return -1 + } + for index := 0; index < len(left.prerelease) && index < len(right.prerelease); index++ { + leftIdentifier := left.prerelease[index] + rightIdentifier := right.prerelease[index] + switch { + case leftIdentifier.numeric && rightIdentifier.numeric: + if leftIdentifier.number < rightIdentifier.number { + return -1 + } + if leftIdentifier.number > rightIdentifier.number { + return 1 + } + case leftIdentifier.numeric: + return -1 + case rightIdentifier.numeric: + return 1 + case leftIdentifier.raw < rightIdentifier.raw: + return -1 + case leftIdentifier.raw > rightIdentifier.raw: + return 1 + } + } + if len(left.prerelease) < len(right.prerelease) { + return -1 + } + if len(left.prerelease) > len(right.prerelease) { + return 1 + } + return 0 +} + +func validCandidate(release Release) (semanticVersion, bool) { + version, ok := parseVersion(release.TagName) + if !ok || release.Draft || !validPublishedAt(release.PublishedAt) || + !validReleaseURL(release.HTMLURL, release.TagName) { + return semanticVersion{}, false + } + return version, true +} + +func validPublishedAt(publishedAt time.Time) bool { + if publishedAt.IsZero() { + return false + } + milliseconds := publishedAt.UnixMilli() + return milliseconds >= 0 && milliseconds <= maxSafeInteger +} + +func validReleaseURL(raw, tag string) bool { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host != "github.com" || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + return parsed.Path == "/tbphp/gpt-load/releases/tag/"+tag +} diff --git a/internal/releasecheck/selection_test.go b/internal/releasecheck/selection_test.go new file mode 100644 index 000000000..bcff4c4f8 --- /dev/null +++ b/internal/releasecheck/selection_test.go @@ -0,0 +1,138 @@ +package releasecheck + +import ( + "testing" + "time" +) + +func TestSelectUpdateStableCurrentOnlyAcceptsNewerStableV2(t *testing.T) { + releases := []Release{ + testRelease("v2.0.1-beta.1", "2026-08-18T00:00:00Z"), + testRelease("v2.0.1", "2026-08-19T00:00:00Z"), + testRelease("v2.2.0-rc.1", "2026-08-20T00:00:00Z"), + testRelease("v2.1.0", "2026-08-21T00:00:00Z"), + testRelease("v3.0.0", "2026-08-22T00:00:00Z"), + } + + got := SelectUpdate("v2.0.0", releases) + assertUpdate(t, got, "v2.1.0", "2026-08-21T00:00:00Z") +} + +func TestSelectUpdateTestCurrentAcceptsAnyNewerV2Release(t *testing.T) { + tests := []struct { + name string + current string + releases []Release + want string + }{ + { + name: "beta selects later beta over stable patch line", + current: "v2.0.0-beta.7", + releases: []Release{ + testRelease("v2.0.0-beta.8", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + testRelease("v2.1.0-beta.1", "2026-08-20T00:00:00Z"), + }, + want: "v2.1.0-beta.1", + }, + { + name: "rc selects stable", + current: "2.0.0-rc.1", + releases: []Release{ + testRelease("v2.0.0-rc.2", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + }, + want: "v2.0.0", + }, + { + name: "dev participates as an ordinary test version", + current: "2.0.0-dev", + releases: []Release{ + testRelease("v2.0.0-rc.1", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + }, + want: "v2.0.0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := SelectUpdate(test.current, test.releases) + if got == nil || got.Version != test.want { + t.Fatalf("SelectUpdate(%q) = %#v, want %q", test.current, got, test.want) + } + }) + } +} + +func TestSelectUpdateUsesSemVerPrereleasePrecedence(t *testing.T) { + releases := []Release{ + testRelease("v2.0.0-beta.11", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0-rc.2", "2026-08-19T00:00:00Z"), + testRelease("v2.0.0-rc.10", "2026-08-20T00:00:00Z"), + } + + got := SelectUpdate("v2.0.0-beta.9", releases) + assertUpdate(t, got, "v2.0.0-rc.10", "2026-08-20T00:00:00Z") +} + +func TestSelectUpdateRejectsIneligibleReleases(t *testing.T) { + releases := []Release{ + testRelease("v2.0.0-beta.7", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0-beta.6", "2026-08-17T00:00:00Z"), + testRelease("v3.0.0", "2026-08-20T00:00:00Z"), + testRelease("v2.0.0+build.1", "2026-08-21T00:00:00Z"), + testRelease("v2.0.0-rc.01", "2026-08-22T00:00:00Z"), + {TagName: "v2.0.0-rc.1", HTMLURL: testReleaseURL("v2.0.0-rc.1"), Draft: true}, + } + + if got := SelectUpdate("v2.0.0-beta.7", releases); got != nil { + t.Fatalf("SelectUpdate() = %#v, want nil", got) + } + for _, current := range []string{"", "latest", "v1.4.9", "v3.0.0-beta.1"} { + if got := SelectUpdate(current, []Release{testRelease("v2.1.0", "2026-08-23T00:00:00Z")}); got != nil { + t.Fatalf("SelectUpdate(%q) = %#v, want nil", current, got) + } + } +} + +func TestSelectUpdateRejectsUntrustedOrIncompleteMetadata(t *testing.T) { + releases := []Release{ + {TagName: "v2.0.1", HTMLURL: "http://github.com/tbphp/gpt-load/releases/tag/v2.0.1", PublishedAt: time.Now()}, + {TagName: "v2.0.2", HTMLURL: "https://evil.test/tbphp/gpt-load/releases/tag/v2.0.2", PublishedAt: time.Now()}, + {TagName: "v2.0.3", HTMLURL: testReleaseURL("v2.0.2"), PublishedAt: time.Now()}, + {TagName: "v2.0.4", HTMLURL: testReleaseURL("v2.0.4")}, + } + + if got := SelectUpdate("v2.0.0", releases); got != nil { + t.Fatalf("SelectUpdate() = %#v, want nil", got) + } +} + +func testRelease(tag, published string) Release { + return Release{ + TagName: tag, + HTMLURL: testReleaseURL(tag), + PublishedAt: mustParseReleaseTime(published), + } +} + +func testReleaseURL(tag string) string { + return "https://github.com/tbphp/gpt-load/releases/tag/" + tag +} + +func mustParseReleaseTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} + +func assertUpdate(t *testing.T, got *Update, version, published string) { + t.Helper() + if got == nil || got.Version != version || got.ReleaseURL != testReleaseURL(version) || + got.PublishedAtMS != mustParseReleaseTime(published).UnixMilli() { + t.Fatalf("SelectUpdate() = %#v, want version=%q published=%q", got, version, published) + } +} diff --git a/web/src/app/resources/home.ts b/web/src/app/resources/home.ts index 4b9ab3a41..a4bab95c0 100644 --- a/web/src/app/resources/home.ts +++ b/web/src/app/resources/home.ts @@ -28,6 +28,11 @@ export interface HomeBaseDto { server_now_ms: number started_at_ms: number version: string + update: { + version: string + release_url: string + published_at_ms: number + } | null inventory: { group_count: number credential_count: number @@ -113,6 +118,7 @@ const homeBaseFields = [ 'server_now_ms', 'started_at_ms', 'version', + 'update', 'inventory', 'access_keys', 'current_access_key', @@ -124,6 +130,7 @@ const inventoryFields = [ 'model_count', ] as const const accessKeyFields = ['id', 'name', 'masked_key', 'protocols'] as const +const updateFields = ['version', 'release_url', 'published_at_ms'] as const const statisticsFields = [ 'range', 'granularity', @@ -206,6 +213,37 @@ function projectHomeAccessKey(value: unknown): HomeBaseDto['access_keys'][number } } +function projectHomeUpdate(value: unknown): HomeBaseDto['update'] { + if (value === null) return null + const record = projectRecord(value) + assertNoSecretLikeFields(record, updateFields) + const version = projectNonBlankTrimmedString(record.version) + const releaseURL = projectNonBlankTrimmedString(record.release_url) + let parsed: URL + try { + parsed = new URL(releaseURL) + } catch { + return invalidResponse() + } + if ( + parsed.protocol !== 'https:' || + parsed.hostname !== 'github.com' || + parsed.port !== '' || + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' || + parsed.pathname !== `/tbphp/gpt-load/releases/tag/${version}` + ) { + invalidResponse() + } + return { + version, + release_url: releaseURL, + published_at_ms: projectEpochMilliseconds(record.published_at_ms), + } +} + export function projectHomeBase(value: unknown): HomeBaseDto { const record = projectRecord(value) assertNoSecretLikeFields(record, homeBaseFields) @@ -222,6 +260,7 @@ export function projectHomeBase(value: unknown): HomeBaseDto { server_now_ms: serverNowMS, started_at_ms: startedAtMS, version: projectNonBlankTrimmedString(record.version), + update: projectHomeUpdate(record.update), inventory: projectHomeInventory(record.inventory), access_keys: accessKeys, current_access_key: @@ -555,6 +594,8 @@ export function homeBaseQueryOptions(client: ApiClient) { queryFn: ({ signal }) => getHomeBase(client, signal), staleTime: Number.POSITIVE_INFINITY, refetchOnMount: 'always', + refetchInterval: 5 * 60 * 1_000, + refetchIntervalInBackground: false, }) } diff --git a/web/src/features/home/HomeReleaseUpdateLink.vue b/web/src/features/home/HomeReleaseUpdateLink.vue new file mode 100644 index 000000000..77a35bc24 --- /dev/null +++ b/web/src/features/home/HomeReleaseUpdateLink.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/features/home/HomeSummary.vue b/web/src/features/home/HomeSummary.vue index 62263512e..c065f0c13 100644 --- a/web/src/features/home/HomeSummary.vue +++ b/web/src/features/home/HomeSummary.vue @@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n' import type { HomeBaseDto } from '@/app/resources/home' import { formatDuration, formatInteger, formatLocalInstant, formatLocalTime } from '@/lib/format' +import HomeReleaseUpdateLink from './HomeReleaseUpdateLink.vue' + const props = defineProps<{ base: HomeBaseDto observedAtMs: number | null @@ -52,7 +54,14 @@ const updatedTitle = computed(() =>
{{ t('home.ledger.version') }}
-
{{ base.version }}
+
+ {{ base.version }} + +
{{ t('home.ledger.uptime') }}
@@ -124,6 +133,13 @@ const updatedTitle = computed(() => .home-summary__stamp dd { margin: 0; } + +.home-summary__version { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + .home-summary__stamp dt { text-align: right; } diff --git a/web/src/features/home/HomeView.vue b/web/src/features/home/HomeView.vue index 892ae8cd3..86a2f2911 100644 --- a/web/src/features/home/HomeView.vue +++ b/web/src/features/home/HomeView.vue @@ -155,7 +155,10 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer)) /> - +