From a15aaa3da9c085e18901bb1aba1a5829e841068e Mon Sep 17 00:00:00 2001 From: Ehco1996 Date: Tue, 5 May 2026 14:32:35 +0800 Subject: [PATCH 1/3] fix(updater): use commit SHA, not BuildTime, to detect republish published_at is always slightly later than BuildTime for the same artifact (goreleaser publishes the release seconds after building), so the time-based comparison always fired -> false "update available" for the very release the user just installed. Switch to comparing constant.GitRevision against the commit SHA the release tag points at (GET /commits/{tag}). Bare `go build` (no revision injected) and GitHub fetch failures stay conservative -> no spurious prompts; user can --force. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/update.go | 2 +- internal/updater/updater.go | 68 +++++++++++++++++++++----------- internal/updater/updater_test.go | 49 ++++++----------------- internal/web/handler_update.go | 4 +- 4 files changed, 60 insertions(+), 63 deletions(-) diff --git a/internal/cli/update.go b/internal/cli/update.go index 0a33eff66..4d5930a1e 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -24,6 +24,6 @@ var UpdateCMD = &cli.Command{ Channel: c.String("channel"), Force: c.Bool("force"), Restart: !c.Bool("no-restart"), - }, constant.Version, constant.BuildTime, cliLogger, nil) + }, constant.Version, constant.GitRevision, cliLogger, nil) }, } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 5c333b40f..742575c41 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -80,8 +80,9 @@ type ghRelease struct { } // Check resolves channel against currentVersion and queries GitHub. -// currentBuildTime is the ldflag-injected constant.BuildTime; empty is fine. -func Check(ctx context.Context, channel, currentVersion, currentBuildTime string) (*CheckResult, error) { +// currentRevision is the ldflag-injected constant.GitRevision; empty is +// tolerated (we'll just trust version-string equality in that case). +func Check(ctx context.Context, channel, currentVersion, currentRevision string) (*CheckResult, error) { resolved, rel, err := pickRelease(ctx, channel, currentVersion) if err != nil { return nil, err @@ -98,7 +99,7 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string PublishedAt: rel.PublishedAt, } if latest == currentVersion { - res.UpdateAvailable = nightlyRepublished(rel, currentBuildTime) + res.UpdateAvailable = nightlyRepublished(ctx, rel, currentRevision) } else { res.UpdateAvailable = compareVersions(latest, currentVersion) > 0 } @@ -111,7 +112,7 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string // Apply downloads + swaps + (optionally) restarts. Each phase is reported // to onState so the dashboard can render progress; CLI passes nil. -func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildTime string, log *zap.SugaredLogger, onState func(State)) error { +func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevision string, log *zap.SugaredLogger, onState func(State)) error { emit := func(s State) { if onState != nil { onState(s) @@ -128,9 +129,9 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildT if !opts.Force { if latest == currentVersion { - if nightlyRepublished(rel, currentBuildTime) { - log.Infof("nightly tag %s republished after local build (%s); reinstalling", - rel.TagName, currentBuildTime) + if nightlyRepublished(ctx, rel, currentRevision) { + log.Infof("nightly tag %s now points at a different commit than local %s; reinstalling", + rel.TagName, currentRevision) } else { log.Info("already up to date") emit(StateDone) @@ -269,30 +270,49 @@ func getJSON(ctx context.Context, url string, out any) error { } // nightlyRepublished reports whether a release whose tag matches the -// running version is actually newer than the current binary. Nightly -// uses a rolling tag (v1.1.7-next), so version-string equality alone -// would mask republished builds. Only meaningful for prereleases; stable -// tags don't roll. -func nightlyRepublished(rel *ghRelease, currentBuildTime string) bool { - if !rel.Prerelease || currentBuildTime == "" { +// running version actually points at a different commit than the +// running binary. Nightly uses a rolling tag (v1.1.7-next), so +// version-string equality alone would mask republished builds. +// +// We can't time-compare published_at vs BuildTime: goreleaser publishes +// the release a few seconds after building the artifact, so the same +// artifact would always look "older" than its own release. SHA is the +// only reliable signal. +// +// Conservative on uncertainty: empty currentRevision (bare `go build`) +// or GitHub fetch failure -> false (no spurious update prompts; user +// can --force). +func nightlyRepublished(ctx context.Context, rel *ghRelease, currentRevision string) bool { + if !rel.Prerelease || currentRevision == "" { return false } - built, ok := parseBuildTime(currentBuildTime) - if !ok { + sha, err := fetchTagCommitSHA(ctx, rel.TagName) + if err != nil || sha == "" { return false } - return rel.PublishedAt.After(built) + return !shaMatchesRevision(sha, currentRevision) } -// parseBuildTime accepts both ldflag formats: goreleaser's RFC3339 -// ({{.Date}}) and the Makefile's "2006-01-02-15:04:05". -func parseBuildTime(s string) (time.Time, bool) { - for _, layout := range []string{time.RFC3339, "2006-01-02-15:04:05"} { - if t, err := time.Parse(layout, s); err == nil { - return t, true - } +// shaMatchesRevision compares the short revision baked into the binary +// (goreleaser uses {{.ShortCommit}}, 7 chars; Makefile uses full SHA) +// against the full SHA returned by GitHub. Prefix-match is enough. +func shaMatchesRevision(fullSHA, currentRevision string) bool { + n := len(currentRevision) + if n == 0 || n > len(fullSHA) { + return false + } + return strings.EqualFold(fullSHA[:n], currentRevision) +} + +func fetchTagCommitSHA(ctx context.Context, tag string) (string, error) { + var c struct { + SHA string `json:"sha"` + } + url := "https://api.github.com/repos/Ehco1996/ehco/commits/" + tag + if err := getJSON(ctx, url, &c); err != nil { + return "", err } - return time.Time{}, false + return c.SHA, nil } // compareVersions returns -1/0/1 like semver.Compare. Falls back to diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 46ce7ce8b..98c78e9b2 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -2,7 +2,6 @@ package updater import ( "testing" - "time" ) func TestResolveChannel(t *testing.T) { @@ -48,45 +47,23 @@ func TestCompareVersions(t *testing.T) { } } -func TestParseBuildTime(t *testing.T) { +func TestShaMatchesRevision(t *testing.T) { + full := "1e0e74cabcdef0123456789abcdef0123456789a" cases := []struct { - in string - want bool + fullSHA, revision string + want bool }{ - {"2026-05-04T23:10:16Z", true}, // goreleaser - {"2026-05-04T23:10:16+08:00", true}, // RFC3339 w/ offset - {"2026-05-04-23:10:16", true}, // Makefile - {"", false}, - {"not-a-time", false}, + {full, "1e0e74c", true}, // goreleaser short + {full, "1E0E74C", true}, // case-insensitive + {full, full, true}, // Makefile full SHA + {full, "deadbee", false}, // different commit + {full, "", false}, // empty -> false (caller already guards) + {full, full + "x", false}, // longer than full SHA + {"short", "shortish", false}, // revision longer than fullSHA } for _, c := range cases { - _, ok := parseBuildTime(c.in) - if ok != c.want { - t.Errorf("parseBuildTime(%q) ok=%v want %v", c.in, ok, c.want) - } - } -} - -func TestNightlyRepublished(t *testing.T) { - built := "2026-05-04T23:10:16Z" - older := time.Date(2026, 5, 4, 22, 0, 0, 0, time.UTC) - newer := time.Date(2026, 5, 5, 6, 0, 0, 0, time.UTC) - - cases := []struct { - name string - rel ghRelease - buildTime string - want bool - }{ - {"prerelease republished after build", ghRelease{Prerelease: true, PublishedAt: newer}, built, true}, - {"prerelease same/older than build", ghRelease{Prerelease: true, PublishedAt: older}, built, false}, - {"stable release ignored", ghRelease{Prerelease: false, PublishedAt: newer}, built, false}, - {"empty build time -> conservative false", ghRelease{Prerelease: true, PublishedAt: newer}, "", false}, - {"unparseable build time -> false", ghRelease{Prerelease: true, PublishedAt: newer}, "garbage", false}, - } - for _, c := range cases { - if got := nightlyRepublished(&c.rel, c.buildTime); got != c.want { - t.Errorf("%s: got %v want %v", c.name, got, c.want) + if got := shaMatchesRevision(c.fullSHA, c.revision); got != c.want { + t.Errorf("shaMatchesRevision(%q,%q)=%v want %v", c.fullSHA, c.revision, got, c.want) } } } diff --git a/internal/web/handler_update.go b/internal/web/handler_update.go index 072fdf9ec..88cb08e33 100644 --- a/internal/web/handler_update.go +++ b/internal/web/handler_update.go @@ -54,7 +54,7 @@ func (s *Server) UpdateCheck(c echo.Context) error { } ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second) defer cancel() - res, err := updater.Check(ctx, channel, constant.Version, constant.BuildTime) + res, err := updater.Check(ctx, channel, constant.Version, constant.GitRevision) if err != nil { return echo.NewHTTPError(http.StatusBadGateway, err.Error()) } @@ -105,7 +105,7 @@ func (s *Server) runUpdate(opts updater.ApplyOptions, job *JobStatus) { *job = next } - if err := updater.Apply(ctx, opts, constant.Version, constant.BuildTime, s.l, onState); err != nil { + if err := updater.Apply(ctx, opts, constant.Version, constant.GitRevision, s.l, onState); err != nil { next := *job next.State = updater.StateFailed next.Error = err.Error() From b98ab90a6638e9314928c728daaaa24f10c54684 Mon Sep 17 00:00:00 2001 From: Ehco1996 Date: Tue, 5 May 2026 14:49:36 +0800 Subject: [PATCH 2/3] webui: add windowed totals + lifetime KPIs to home dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home page previously only surfaced instantaneous rates, so there was no way to answer "how much has this node moved in the last N hours". Add two new KPI strips driven by the existing time-window selector: - windowed: total in/out (trapezoidal integration of the rate series already fetched for the throughput chart) + peak in/out - lifetime: in/out from XraySnapshot + last config reload (relative) Also add a lifetime-total column to the top-users list (already available on XrayUser as upload_total + download_total). Backend: OverviewResp gains last_reload_at (sourced from a new Config.LastLoadTime accessor over the existing lastLoadTime field). Boot time is intentionally not duplicated here — settings page already shows it. Note on accuracy: windowed totals are computed from bucketed average rates, so they slightly under-count short bursts; peaks at long windows (30d → 4h step) are bucket-averaged peaks, not instantaneous. Good enough for v1; a monotonic cum_in/cum_out column on node_metrics would make this exact. --- internal/config/config.go | 7 ++ internal/web/handler_api.go | 6 ++ internal/web/webui/src/api/types.ts | 3 + internal/web/webui/src/pages/Home.tsx | 101 +++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 70aff7f8e..d2fc30879 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,13 @@ func (c *Config) NeedSyncFromServer() bool { return strings.Contains(c.PATH, "http") } +// LastLoadTime returns the wall-clock timestamp of the most recent +// successful (or attempted — see LoadConfig) reload. Zero before the +// first call. +func (c *Config) LastLoadTime() time.Time { + return c.lastLoadTime +} + func (c *Config) LoadConfig(force bool) error { if c.ReloadInterval > 0 && time.Since(c.lastLoadTime).Seconds() < float64(c.ReloadInterval) && !force { c.l.Warnf("Skip Load Config, last load time: %s", c.lastLoadTime) diff --git a/internal/web/handler_api.go b/internal/web/handler_api.go index 09339a9be..044dc1a79 100644 --- a/internal/web/handler_api.go +++ b/internal/web/handler_api.go @@ -127,6 +127,11 @@ type OverviewResp struct { Xray *glue.XraySnapshot `json:"xray,omitempty"` Host *ms.NodeMetrics `json:"host,omitempty"` Rules int `json:"rules"` + // LastReloadAt is the wall-clock timestamp of the most recent config + // reload attempt (file or HTTP). The freshness signal for routing + // rules / xray inbounds — distinct from boot time, which is + // surfaced on the settings page. + LastReloadAt time.Time `json:"last_reload_at,omitempty"` } func (s *Server) Overview(c echo.Context) error { @@ -134,6 +139,7 @@ func (s *Server) Overview(c echo.Context) error { if s.cfg != nil { out.Rules = len(s.cfg.RelayConfigs) + out.LastReloadAt = s.cfg.LastLoadTime() } if p := s.xrayStatus.Load(); p != nil && *p != nil { diff --git a/internal/web/webui/src/api/types.ts b/internal/web/webui/src/api/types.ts index e0b120422..4ecf50525 100644 --- a/internal/web/webui/src/api/types.ts +++ b/internal/web/webui/src/api/types.ts @@ -47,6 +47,9 @@ export interface OverviewResp { xray?: XraySnapshot; host?: NodeMetric; rules: number; + // RFC3339; zero-value omitted by the server. Time of last config + // reload attempt (file or remote HTTP). + last_reload_at?: string; } export interface QueryNodeMetricsResp { diff --git a/internal/web/webui/src/pages/Home.tsx b/internal/web/webui/src/pages/Home.tsx index d5b661433..b0504e866 100644 --- a/internal/web/webui/src/pages/Home.tsx +++ b/internal/web/webui/src/pages/Home.tsx @@ -75,6 +75,36 @@ export default function Home() { const xray = () => overview()?.xray; const host = () => overview()?.host; + const lastReload = () => { + const v = overview()?.last_reload_at; + return v && !v.startsWith("0001-01-01") ? v : undefined; + }; + + // Trapezoidal integration of rate samples → bytes over the window. + // Each sample is bytes/sec averaged over its bucket; multiply by the + // gap to the next sample. The first and last samples each contribute + // half a gap so the total tracks the chart area. + const windowedTotals = createMemo(() => { + const s = series(); + if (s.length < 2) return { in: 0, out: 0, peakIn: 0, peakOut: 0 }; + let totIn = 0; + let totOut = 0; + let peakIn = 0; + let peakOut = 0; + for (let i = 0; i < s.length; i++) { + const cur = s[i]; + if (cur.network_in > peakIn) peakIn = cur.network_in; + if (cur.network_out > peakOut) peakOut = cur.network_out; + if (i < s.length - 1) { + const dt = s[i + 1].timestamp - cur.timestamp; + if (dt > 0 && dt < 24 * 3600) { + totIn += ((cur.network_in + s[i + 1].network_in) / 2) * dt; + totOut += ((cur.network_out + s[i + 1].network_out) / 2) * dt; + } + } + } + return { in: totIn, out: totOut, peakIn, peakOut }; + }); const topUsers = createMemo(() => { const list = (users() ?? []).map((u) => ({ @@ -125,6 +155,15 @@ export default function Home() { mem={host()?.memory_usage} /> + 1} + windowSec={windowSec()} + lifetimeIn={xray()?.upload_total ?? 0} + lifetimeOut={xray()?.download_total ?? 0} + lastReload={lastReload()} + /> + - + } title="No users registered" />} @@ -181,6 +224,15 @@ export default function Home() { {recent > 0 ? bytes(recent) : "—"} + {user.tcp_conn_count}c @@ -324,6 +376,53 @@ function ThroughputAnchor(props: { ); } +function windowLabel(sec: number): string { + if (sec < 3600) return `${Math.round(sec / 60)}m`; + if (sec < 86400) return `${Math.round(sec / 3600)}h`; + return `${Math.round(sec / 86400)}d`; +} + +function WindowedAnchor(props: { + totals: { in: number; out: number; peakIn: number; peakOut: number }; + haveSeries: boolean; + windowSec: number; + lifetimeIn: number; + lifetimeOut: number; + lastReload?: string; +}) { + const win = () => windowLabel(props.windowSec); + const dash = (s: string) => (props.haveSeries ? s : "—"); + return ( +
+ +
+ windowed · last {win()} +
+
+ + + + +
+
+ +
+ lifetime · since boot +
+
+ + + +
+
+
+ ); +} + function Stat(props: { label: string; value: string | number; hint?: string }) { return (
From 0f174ee6d4d11d00579d350eb8963b7a4b7fad53 Mon Sep 17 00:00:00 2001 From: Ehco1996 Date: Tue, 5 May 2026 15:04:33 +0800 Subject: [PATCH 3/3] refactor(updater): drop in-process state machine; UI polls /version The systemd restart kills this process before any "restarting/done" state can be polled, so the 5-state machine produced UIs stuck on "Downloading" while the update actually succeeded. Drop the state tracking entirely: - updater.Apply no longer takes onState; runs to completion or returns error. - /api/v1/update/apply is fire-and-forget (HTTP 202); /update/status is removed along with JobStatus and updateJob. - Dashboard polls /api/v1/version every 2s after click; success when git_revision changes; 60s timeout surfaces "check journalctl" hint. - Failure path stays in s.l logger -> journalctl, same as before. Net -193 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/update.go | 2 +- internal/updater/updater.go | 39 +- internal/web/handler_update.go | 76 +-- internal/web/server.go | 4 +- internal/web/webui/src/api/client.ts | 4 +- internal/web/webui/src/api/types.ts | 18 - internal/web/webui/src/pages/UpdatesPanel.tsx | 452 +++++++----------- 7 files changed, 201 insertions(+), 394 deletions(-) diff --git a/internal/cli/update.go b/internal/cli/update.go index 4d5930a1e..8d81d3608 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -24,6 +24,6 @@ var UpdateCMD = &cli.Command{ Channel: c.String("channel"), Force: c.Bool("force"), Restart: !c.Bool("no-restart"), - }, constant.Version, constant.GitRevision, cliLogger, nil) + }, constant.Version, constant.GitRevision, cliLogger) }, } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 742575c41..03e86ca7c 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -29,18 +29,6 @@ const ( systemdServiceName = "ehco" ) -// State is the phase of an Apply run; consumed by the web UI. -type State string - -const ( - StateChecking State = "checking" - StateDownloading State = "downloading" - StateInstalling State = "installing" - StateRestarting State = "restarting" - StateDone State = "done" - StateFailed State = "failed" -) - // CheckResult describes a release relative to the running binary. type CheckResult struct { Channel string `json:"channel"` @@ -110,16 +98,12 @@ func Check(ctx context.Context, channel, currentVersion, currentRevision string) return res, nil } -// Apply downloads + swaps + (optionally) restarts. Each phase is reported -// to onState so the dashboard can render progress; CLI passes nil. -func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevision string, log *zap.SugaredLogger, onState func(State)) error { - emit := func(s State) { - if onState != nil { - onState(s) - } - } - - emit(StateChecking) +// Apply downloads + swaps + (optionally) restarts. Errors are returned +// for the caller to surface; phase tracking has been intentionally +// dropped — the systemd restart kills this process before any +// "restarting/done" state could be polled, making intermediate states +// unreliable. The dashboard polls /version to detect completion. +func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevision string, log *zap.SugaredLogger) error { resolved, rel, err := pickRelease(ctx, opts.Channel, currentVersion) if err != nil { return err @@ -134,7 +118,6 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevisi rel.TagName, currentRevision) } else { log.Info("already up to date") - emit(StateDone) return nil } } else if compareVersions(latest, currentVersion) < 0 { @@ -156,14 +139,12 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevisi } tmpPath := binPath + ".new" - emit(StateDownloading) log.Infof("downloading %s -> %s", asset.BrowserDownloadURL, tmpPath) if err := download(ctx, asset.BrowserDownloadURL, tmpPath); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("download: %w", err) } - emit(StateInstalling) // rename(2) over a running ELF on linux is safe: the kernel keeps the // old inode alive for the running process while new invocations // resolve to the new file. @@ -179,15 +160,9 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevisi if !opts.Restart { log.Info("skipping restart; restart manually to pick up the new binary") - emit(StateDone) return nil } - emit(StateRestarting) - if err := restartSystemd(log); err != nil { - return err - } - emit(StateDone) - return nil + return restartSystemd(log) } func pickRelease(ctx context.Context, channel, currentVersion string) (string, *ghRelease, error) { diff --git a/internal/web/handler_update.go b/internal/web/handler_update.go index 88cb08e33..35ad5a761 100644 --- a/internal/web/handler_update.go +++ b/internal/web/handler_update.go @@ -23,18 +23,6 @@ type VersionInfo struct { GoArch string `json:"go_arch"` } -// JobStatus is the in-memory record of the most-recent update attempt. -// Process-local on purpose: after a successful restart the new process -// boots with no record, the SPA reloads /version and sees the new build. -type JobStatus struct { - State updater.State `json:"state"` - Channel string `json:"channel"` - From string `json:"from"` - To string `json:"to"` - StartedAt time.Time `json:"started_at"` - Error string `json:"error,omitempty"` -} - func (s *Server) Version(c echo.Context) error { return c.JSON(http.StatusOK, VersionInfo{ Version: constant.Version, @@ -61,6 +49,10 @@ func (s *Server) UpdateCheck(c echo.Context) error { return c.JSON(http.StatusOK, res) } +// UpdateApply kicks off the update in a detached goroutine and returns +// immediately. The dashboard polls /version to detect completion (the +// running process restarts mid-flow, so any in-process state machine is +// inherently lossy). Failures are logged via s.l; check journalctl. func (s *Server) UpdateApply(c echo.Context) error { if runtime.GOOS != "linux" { return echo.NewHTTPError(http.StatusBadRequest, @@ -73,58 +65,14 @@ func (s *Server) UpdateApply(c echo.Context) error { if opts.Channel == "" { opts.Channel = updater.ChannelAuto } - - prev := s.updateJob.Load() - if prev != nil && isInProgress(prev.State) { - return echo.NewHTTPError(http.StatusConflict, "another update is already running") - } - - job := &JobStatus{ - State: updater.StateChecking, - Channel: opts.Channel, - From: constant.Version, - StartedAt: time.Now().UTC(), - } - s.updateJob.Store(job) s.l.Infof("update apply requested channel=%s force=%v restart=%v", opts.Channel, opts.Force, opts.Restart) - // Detached context: closing the browser shouldn't abort an in-flight swap. - go s.runUpdate(opts, job) - return c.JSON(http.StatusAccepted, map[string]string{"state": string(updater.StateChecking)}) -} - -func (s *Server) runUpdate(opts updater.ApplyOptions, job *JobStatus) { - ctx, cancel := context.WithTimeout(context.Background(), updateApplyTimeout) - defer cancel() - - onState := func(st updater.State) { - // Copy-on-write so /status readers always see a consistent snapshot. - next := *job - next.State = st - s.updateJob.Store(&next) - *job = next - } - - if err := updater.Apply(ctx, opts, constant.Version, constant.GitRevision, s.l, onState); err != nil { - next := *job - next.State = updater.StateFailed - next.Error = err.Error() - s.updateJob.Store(&next) - s.l.Errorf("update failed: %v", err) - } -} - -func (s *Server) UpdateStatus(c echo.Context) error { - if j := s.updateJob.Load(); j != nil { - return c.JSON(http.StatusOK, j) - } - return c.JSON(http.StatusOK, map[string]string{"state": "idle"}) -} - -func isInProgress(s updater.State) bool { - switch s { - case updater.StateChecking, updater.StateDownloading, updater.StateInstalling, updater.StateRestarting: - return true - } - return false + go func() { + ctx, cancel := context.WithTimeout(context.Background(), updateApplyTimeout) + defer cancel() + if err := updater.Apply(ctx, opts, constant.Version, constant.GitRevision, s.l); err != nil { + s.l.Errorf("update failed: %v", err) + } + }() + return c.NoContent(http.StatusAccepted) } diff --git a/internal/web/server.go b/internal/web/server.go index c1af23aee..331fb8c0a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -32,8 +32,7 @@ type Server struct { cfg *config.Config auth *authenticator - connMgr cmgr.Cmgr - updateJob atomic.Pointer[JobStatus] + connMgr cmgr.Cmgr // xrayStatus is wired post-construction by cli boot once the // XrayServer exists. Always read via Load() — may be nil when @@ -126,7 +125,6 @@ func setupRoutes(s *Server) { api.GET("/version", s.Version) api.GET("/update/check", s.UpdateCheck) api.POST("/update/apply", s.UpdateApply) - api.GET("/update/status", s.UpdateStatus) // Local SQLite store: read-side health snapshot + maintenance ops. // All four mutations are auth-gated through the api group's diff --git a/internal/web/webui/src/api/client.ts b/internal/web/webui/src/api/client.ts index 373cf006d..13446a9d2 100644 --- a/internal/web/webui/src/api/client.ts +++ b/internal/web/webui/src/api/client.ts @@ -35,7 +35,6 @@ import type { QueryNodeMetricsResp, VersionInfo, UpdateCheck, - UpdateStatus, UpdateApplyOptions, OverviewResp, DBHealth, @@ -77,12 +76,11 @@ export const api = { updateCheck: (channel: string) => request(`/api/v1/update/check?channel=${encodeURIComponent(channel)}`), updateApply: (opts: UpdateApplyOptions) => - request<{ state: string }>("/api/v1/update/apply", { + request("/api/v1/update/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), }), - updateStatus: () => request("/api/v1/update/status"), dbHealth: () => request("/api/v1/db/health"), dbCleanup: (older_than_days: number) => request("/api/v1/db/cleanup", { diff --git a/internal/web/webui/src/api/types.ts b/internal/web/webui/src/api/types.ts index 4ecf50525..2e645b31d 100644 --- a/internal/web/webui/src/api/types.ts +++ b/internal/web/webui/src/api/types.ts @@ -122,24 +122,6 @@ export interface UpdateCheck { asset_url: string; } -export type UpdateState = - | "idle" - | "checking" - | "downloading" - | "installing" - | "restarting" - | "done" - | "failed"; - -export interface UpdateStatus { - state: UpdateState; - channel?: string; - from?: string; - to?: string; - started_at?: string; - error?: string; -} - export interface UpdateApplyOptions { channel: string; force: boolean; diff --git a/internal/web/webui/src/pages/UpdatesPanel.tsx b/internal/web/webui/src/pages/UpdatesPanel.tsx index 305b569ec..a53d74f73 100644 --- a/internal/web/webui/src/pages/UpdatesPanel.tsx +++ b/internal/web/webui/src/pages/UpdatesPanel.tsx @@ -1,5 +1,5 @@ -import { createResource, createSignal, For, onCleanup, Show } from "solid-js"; -import { Download, RefreshCw, CircleCheck, CircleAlert } from "lucide-solid"; +import { createResource, createSignal, onCleanup, Show } from "solid-js"; +import { Download, RefreshCw, CircleCheck, CircleAlert, Loader2 } from "lucide-solid"; import { Card, CardHeader } from "../ui/Card"; import Button from "../ui/Button"; import { Pill } from "../ui/Pill"; @@ -7,23 +7,14 @@ import Segmented from "../ui/Segmented"; import DescList from "../ui/DescList"; import { api, ApiError } from "../api/client"; import { relTime } from "../util/format"; -import type { - UpdateCheck, - UpdateState, - UpdateStatus, - VersionInfo, -} from "../api/types"; +import type { UpdateCheck, VersionInfo } from "../api/types"; type Channel = "auto" | "stable" | "nightly"; -const STEPS: UpdateState[] = [ - "checking", - "downloading", - "installing", - "restarting", - "done", -]; -const cap = (s: string) => s[0].toUpperCase() + s.slice(1); +// /version is polled every POLL_MS while an update is in flight; if the +// commit hasn't changed within TIMEOUT_MS we give up and surface a hint. +const POLL_MS = 2000; +const TIMEOUT_MS = 60_000; export default function UpdatesPanel() { const [version, { refetch: rcVersion }] = createResource(() => @@ -33,65 +24,42 @@ export default function UpdatesPanel() { const [checking, setChecking] = createSignal(false); const [check, setCheck] = createSignal(null); const [checkErr, setCheckErr] = createSignal(""); - const [status, setStatus] = createSignal(null); - const [applyErr, setApplyErr] = createSignal(""); + + // Updating state. `startCommit` is captured at click time; we treat + // /version returning a different commit as "done". + const [updating, setUpdating] = createSignal(false); + const [startCommit, setStartCommit] = createSignal(""); + const [updateMsg, setUpdateMsg] = createSignal(""); + const [updateErr, setUpdateErr] = createSignal(""); let timer: number | null = null; - const stopTimer = () => { + let timeoutHandle: number | null = null; + const stopPolling = () => { if (timer != null) { window.clearInterval(timer); timer = null; } + if (timeoutHandle != null) { + window.clearTimeout(timeoutHandle); + timeoutHandle = null; + } }; - onCleanup(stopTimer); + onCleanup(stopPolling); - // Single polling loop: while a job is in progress, hit /update/status. - // Once state==restarting the relay may go down mid-poll; we then probe - // /version and stop when the running version differs from the one we - // started from (i.e. the new binary booted). const tick = async () => { - const before = status()?.from; try { - const s = await api.updateStatus(); - if (s.state !== "idle") { - setStatus(s); - if (s.state === "done" || s.state === "failed") { - stopTimer(); - rcVersion(); - return; - } - } else if (status()?.state === "restarting") { - // New process booted; status reset to idle. Probe /version to - // confirm and land on done. - const v = await api.version(); - if (before && v.version !== before) { - setStatus({ ...(status() as UpdateStatus), state: "done" }); - rcVersion(); - stopTimer(); - } + const v = await api.version(); + if (v.git_revision && v.git_revision !== startCommit()) { + setUpdating(false); + setUpdateMsg(`Updated to ${v.git_revision.slice(0, 7)}.`); + rcVersion(); + stopPolling(); } } catch { - // Relay is restarting — keep polling, the new process will answer. + // Relay restarting; keep polling. } }; - const startPolling = () => { - if (timer != null) return; - timer = window.setInterval(tick, 1500) as unknown as number; - }; - - // Hydrate any in-flight job on mount so a refresh during update - // doesn't lose the indicator. - api - .updateStatus() - .then((s) => { - if (s.state !== "idle") { - setStatus(s); - if (s.state !== "done" && s.state !== "failed") startPolling(); - } - }) - .catch(() => {}); - const runCheck = async () => { setChecking(true); setCheckErr(""); @@ -113,240 +81,178 @@ export default function UpdatesPanel() { ) ) return; - setApplyErr(""); + setUpdateErr(""); + setUpdateMsg(""); try { await api.updateApply({ channel: channel(), force: false, restart: true, }); - setStatus({ - state: "checking", - channel: channel(), - from: version()?.version ?? "", - to: c.latest_version, - started_at: new Date().toISOString(), - }); - startPolling(); } catch (e) { - setApplyErr(e instanceof ApiError ? e.message : String(e)); + setUpdateErr(e instanceof ApiError ? e.message : String(e)); + return; } + setStartCommit(version()?.git_revision ?? ""); + setUpdating(true); + timer = window.setInterval(tick, POLL_MS) as unknown as number; + timeoutHandle = window.setTimeout(() => { + stopPolling(); + setUpdating(false); + setUpdateErr( + "Timed out waiting for the relay to come back. Check journalctl -u ehco for details.", + ); + }, TIMEOUT_MS) as unknown as number; }; - const inProgress = () => { - const s = status()?.state; - return ( - s === "checking" || - s === "downloading" || - s === "installing" || - s === "restarting" - ); - }; const isNightly = () => version()?.version.includes("-") ?? false; const linuxOnly = () => version()?.go_os === "linux"; return ( - <> - - - - - {isNightly() ? "nightly" : "stable"} - - - - options={[ - { value: "auto", label: "Auto" }, - { value: "stable", label: "Stable" }, - { value: "nightly", label: "Nightly" }, - ]} - value={channel()} - onChange={setChannel} - size="sm" - /> - -
- } - /> + + + + + {isNightly() ? "nightly" : "stable"} + + + + options={[ + { value: "auto", label: "Auto" }, + { value: "stable", label: "Stable" }, + { value: "nightly", label: "Nightly" }, + ]} + value={channel()} + onChange={setChannel} + size="sm" + /> + + + } + /> - + - -
- Self-update is only supported on linux. On {version()!.go_os} you'll - need to rebuild from source. -
-
+ +
+ Self-update is only supported on linux. On {version()!.go_os} you'll + need to rebuild from source. +
+
- -
- {checkErr()} -
-
+ +
+ {checkErr()} +
+
- - {(c) => ( -
- - - - Up to date — already on{" "} - {c().current_version} ( - {c().channel} channel). - -
- } - > -
-
- - - New version available:{" "} - {c().latest_version} - - - published {relTime(c().published_at)} - - - release notes - -
- -
-                      {c().release_body}
-                    
-
-
- - - Asset:{" "} - {c().asset_name || "n/a"} - -
+ + {(c) => ( +
+ + + + Up to date — already on{" "} + {c().current_version} ( + {c().channel} channel). +
-
-
- )} -
-
+ } + > +
+
+ + + New version available:{" "} + {c().latest_version} + + + published {relTime(c().published_at)} + + + release notes + +
+ +
+                    {c().release_body}
+                  
+
+
+ + + Asset:{" "} + {c().asset_name || "n/a"} + +
+
+
+ + )} + - - - - -
- {applyErr()} -
-
- - -
- Waiting for the relay to come back online… -
-
- -
- {status()!.error} -
-
-
+ +
+ + Updating… waiting for the relay to come back online. +
- - ); -} -function StepIndicator(props: { state: UpdateState }) { - const cur = () => STEPS.indexOf(props.state); - return ( -
- - {(label, i) => { - const done = () => cur() > i(); - const active = () => cur() === i(); - return ( - <> - 0}> - - - - - {cap(label)} - - - ); - }} - -
+ +
+ + {updateMsg()} +
+
+ + +
+ {updateErr()} +
+
+
); }