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
8 changes: 8 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,14 @@
</div>
</aside>
<div class="app-content">
{#if versionInfo?.message && (versionInfo.messageForce || localStorage.getItem('vc_dismissed_msg') !== versionInfo.message)}
<div class="broadcast-banner" class:broadcast-force={versionInfo.messageForce}>
<span>{versionInfo.message}</span>
{#if !versionInfo.messageForce}
<button class="broadcast-dismiss" onclick={() => { localStorage.setItem('vc_dismissed_msg', versionInfo.message); versionInfo = { ...versionInfo, message: '' }; }}>&#x2715;</button>
{/if}
</div>
{/if}
{#if !lastScanned && scanning}
<main>
<div style="max-width:800px;margin:3rem auto;padding:0 1rem">
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ code{

/* ─── App layout ─── */
.app-content{margin-left:200px;min-height:100vh}
.broadcast-banner{background:var(--primary);color:#fff;padding:.5rem 1rem;font-size:.85rem;display:flex;justify-content:space-between;align-items:center}
.broadcast-dismiss{background:none;border:none;color:#fff;cursor:pointer;font-size:1rem;padding:0 .25rem;opacity:.7}
.broadcast-dismiss:hover{opacity:1}
.broadcast-force{background:var(--danger,#dc2626)}

/* ─── Page bar (consistent header across pages) ─── */
.page-bar{
Expand Down
63 changes: 27 additions & 36 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ type server struct {
bindAddr string // "127.0.0.1" or whatever the user passed via --bind
authToken string // bearer token; required for any non-loopback request

versionMu sync.Mutex
latestVersion string
latestFetched time.Time
versionMu sync.Mutex
cachedVersion *versionResponse
versionFetched time.Time
}

type apiSession struct {
Expand Down Expand Up @@ -797,7 +797,7 @@ func detectTerminal() string {
}

const (
githubLatestURL = "https://api.github.com/repos/NJannasch/vibecockpit/releases/latest"
versionCheckURL = "https://vibecockpit.njannasch.workers.dev/api/version"
latestVersionTTL = time.Hour
latestFetchTimeout = 3 * time.Second
)
Expand All @@ -807,6 +807,8 @@ type versionResponse struct {
Latest string `json:"latest,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
ReleaseURL string `json:"releaseUrl,omitempty"`
Message string `json:"message,omitempty"`
MessageForce bool `json:"messageForce,omitempty"`
}

func (s *server) handleCosts(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -985,12 +987,8 @@ func (s *server) handleScanStatus(w http.ResponseWriter, r *http.Request) {
}

func (s *server) handleVersion(w http.ResponseWriter, r *http.Request) {
resp := versionResponse{Current: s.version}
if latest := s.getLatestVersion(); latest != "" {
resp.Latest = latest
resp.UpdateAvailable = compareSemver(s.version, latest) < 0
resp.ReleaseURL = "https://github.com/NJannasch/vibecockpit/releases/tag/" + latest
}
resp := s.getLatestVersion()
resp.Current = s.version
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Expand Down Expand Up @@ -2013,53 +2011,46 @@ func (s *server) handleJobRuns(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(runs)
}

// getLatestVersion returns the cached latest release tag, refreshing it
// from the GitHub API at most once per latestVersionTTL. A failed fetch
// is silent — the caller just gets the previously-cached value (or "").
func (s *server) getLatestVersion() string {
func (s *server) getLatestVersion() versionResponse {
s.versionMu.Lock()
if s.latestVersion != "" && time.Since(s.latestFetched) < latestVersionTTL {
v := s.latestVersion
if s.cachedVersion != nil && time.Since(s.versionFetched) < latestVersionTTL {
v := *s.cachedVersion
s.versionMu.Unlock()
return v
}
s.versionMu.Unlock()

tag := fetchLatestTag()
if tag == "" {
return ""
}
v := fetchVersionCheck(s.version)
s.versionMu.Lock()
s.latestVersion = tag
s.latestFetched = time.Now()
s.cachedVersion = &v
s.versionFetched = time.Now()
s.versionMu.Unlock()
return tag
return v
}

func fetchLatestTag() string {
func fetchVersionCheck(currentVersion string) versionResponse {
u := fmt.Sprintf("%s?v=%s&os=%s&arch=%s",
versionCheckURL, currentVersion, runtime.GOOS, runtime.GOARCH)
ctx, cancel := context.WithTimeout(context.Background(), latestFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", githubLatestURL, nil)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return ""
return versionResponse{}
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "vibecockpit")
req.Header.Set("User-Agent", "vibecockpit/"+currentVersion)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ""
return versionResponse{}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return ""
}
var body struct {
TagName string `json:"tag_name"`
return versionResponse{}
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return ""
var v versionResponse
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return versionResponse{}
}
return body.TagName
return v
}

// compareSemver returns negative if a < b, zero if equal, positive if a > b.
Expand Down
Loading