From c8ab14e4f79e03e3afbc6422859cbb1ed666faf2 Mon Sep 17 00:00:00 2001 From: Ehco Date: Mon, 4 May 2026 21:09:32 +0800 Subject: [PATCH 1/2] webui: dashboard fixes + self-update + node metrics rework (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(webui): track busy state on grouped kill-user button * feat(update): self-update from CLI + dashboard Move update logic from cli/update.go into a single internal/updater package usable by both `ehco update` and the new /api/v1/update/* HTTP endpoints. The /updates page lets the operator pick a channel, check GitHub releases, and trigger an in-place upgrade with a step indicator. Why: - CLI-only updates require ssh-ing into every relay; a dashboard button is much faster for the personal fleet. - Sharing the implementation avoids two divergent download/replace flows. Job state is process-local (atomic.Pointer[JobStatus]); after a successful restart the SPA polls /version and lands on done when the new binary boots. * feat(metrics): 5s node sampler + dashboard Node page Single ticker at 5s; sample /metrics/ on every tick, push accumulated traffic stats to the control plane every SyncInterval/5 ticks. Adds a /node dashboard page that finally renders disk usage and network throughput in real time. Why: - Previously sampling and control-plane sync were welded into one 60s-default loop; the dashboard was minute-resolution at best, and disk usage was never persisted unless control-plane sync ran. - Splitting sample/push lets local persistence be fast (5s) without spamming the control plane. Drops the unused `refresh` parameter from QueryNodeMetrics / QueryRuleMetrics — the background sampler keeps the store warm so on-demand reads can just read the store. --- internal/cli/update.go | 294 +---------------- internal/cmgr/cmgr.go | 52 ++- internal/cmgr/syncer.go | 65 ++-- internal/updater/updater.go | 320 +++++++++++++++++++ internal/updater/updater_test.go | 46 +++ internal/web/handler_api.go | 15 +- internal/web/handler_update.go | 130 ++++++++ internal/web/server.go | 8 +- internal/web/webui/src/App.tsx | 4 + internal/web/webui/src/api/client.ts | 14 + internal/web/webui/src/api/types.ts | 48 +++ internal/web/webui/src/components/Layout.tsx | 4 + internal/web/webui/src/pages/NodeMetrics.tsx | 145 +++++++++ internal/web/webui/src/pages/Updates.tsx | 320 +++++++++++++++++++ internal/web/webui/src/pages/XrayConns.tsx | 11 +- 15 files changed, 1130 insertions(+), 346 deletions(-) create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go create mode 100644 internal/web/handler_update.go create mode 100644 internal/web/webui/src/pages/NodeMetrics.tsx create mode 100644 internal/web/webui/src/pages/Updates.tsx diff --git a/internal/cli/update.go b/internal/cli/update.go index 9e77a6a27..1388e4321 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -2,296 +2,28 @@ package cli import ( "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" "time" "github.com/Ehco1996/ehco/internal/constant" + "github.com/Ehco1996/ehco/internal/updater" cli "github.com/urfave/cli/v2" - "golang.org/x/mod/semver" ) -const ( - githubLatestReleaseAPI = "https://api.github.com/repos/Ehco1996/ehco/releases/latest" - githubReleasesAPI = "https://api.github.com/repos/Ehco1996/ehco/releases?per_page=30" - systemdServiceName = "ehco" - - channelAuto = "auto" - channelStable = "stable" - channelNightly = "nightly" - - nightlyTagSuffix = "-next" -) - -type ghAsset struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` -} - -type ghRelease struct { - TagName string `json:"tag_name"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - PublishedAt time.Time `json:"published_at"` - Assets []ghAsset `json:"assets"` -} - var UpdateCMD = &cli.Command{ Name: "update", Usage: "update ehco to the latest GitHub release and restart the systemd service", Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Usage: "force update even if already at the latest version, or to allow downgrade / channel switch", - }, - &cli.BoolFlag{ - Name: "no-restart", - Usage: "skip systemctl restart after replacing the binary", - }, - &cli.StringFlag{ - Name: "channel", - Value: channelAuto, - Usage: "release channel to track: auto (match current build), stable, or nightly", - }, + &cli.BoolFlag{Name: "force", Usage: "allow downgrade or channel switch"}, + &cli.BoolFlag{Name: "no-restart", Usage: "skip systemctl restart after replacing the binary"}, + &cli.StringFlag{Name: "channel", Value: updater.ChannelAuto, Usage: "auto | stable | nightly"}, + }, + Action: func(c *cli.Context) error { + ctx, cancel := context.WithTimeout(c.Context, 5*time.Minute) + defer cancel() + return updater.Apply(ctx, updater.ApplyOptions{ + Channel: c.String("channel"), + Force: c.Bool("force"), + Restart: !c.Bool("no-restart"), + }, constant.Version, cliLogger, nil) }, - Action: runUpdate, -} - -func runUpdate(c *cli.Context) error { - ctx, cancel := context.WithTimeout(c.Context, 5*time.Minute) - defer cancel() - - channel, err := resolveChannel(c.String("channel"), constant.Version) - if err != nil { - return err - } - - rel, err := fetchTargetRelease(ctx, channel) - if err != nil { - return fmt.Errorf("fetch %s release: %w", channel, err) - } - latest := strings.TrimPrefix(rel.TagName, "v") - cliLogger.Infof("channel=%s current version=%s latest version=%s", channel, constant.Version, latest) - - force := c.Bool("force") - if !force { - if latest == constant.Version { - cliLogger.Info("already up to date, nothing to do") - return nil - } - if cmp := compareVersions(latest, constant.Version); cmp < 0 { - return fmt.Errorf("refusing to downgrade from %s to %s; rerun with --force to override", - constant.Version, latest) - } - } - - asset, err := pickReleaseAsset(rel.Assets) - if err != nil { - return err - } - - binPath, err := resolveBinaryPath() - if err != nil { - return fmt.Errorf("resolve current binary path: %w", err) - } - tmpPath := binPath + ".new" - cliLogger.Infof("downloading %s -> %s", asset.BrowserDownloadURL, tmpPath) - if err := downloadFile(ctx, asset.BrowserDownloadURL, tmpPath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("download asset: %w", err) - } - if err := os.Chmod(tmpPath, 0o755); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("chmod new binary: %w", err) - } - // rename(2) over a running ELF on linux is safe: the kernel keeps the - // old inode alive for the existing process, while new invocations - // (including the post-restart service) resolve to the new file. - if err := os.Rename(tmpPath, binPath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("replace binary at %s: %w", binPath, err) - } - cliLogger.Infof("binary at %s updated to version %s", binPath, latest) - - if c.Bool("no-restart") { - cliLogger.Info("skipping systemd restart (--no-restart); restart ehco manually to pick up the new binary") - return nil - } - return restartSystemdService() -} - -func resolveChannel(flagVal, currentVersion string) (string, error) { - switch flagVal { - case channelStable, channelNightly: - return flagVal, nil - case channelAuto, "": - if isNightlyVersion(currentVersion) { - return channelNightly, nil - } - return channelStable, nil - default: - return "", fmt.Errorf("invalid --channel %q (want one of auto, stable, nightly)", flagVal) - } -} - -func isNightlyVersion(v string) bool { - // goreleaser injects bare versions like "1.1.7-next" or "1.1.6"; both - // stable and nightly builds skip the leading "v". A nightly is anything - // carrying a prerelease suffix (currently "-next"), but we use a generic - // "contains a dash" check so future suffixes (e.g. "-rc.1") still work. - return strings.Contains(v, "-") -} - -func fetchTargetRelease(ctx context.Context, channel string) (*ghRelease, error) { - switch channel { - case channelStable: - return fetchLatestStableRelease(ctx) - case channelNightly: - return fetchLatestNightlyRelease(ctx) - default: - return nil, fmt.Errorf("unknown channel %q", channel) - } -} - -func fetchLatestStableRelease(ctx context.Context) (*ghRelease, error) { - var rel ghRelease - if err := getJSON(ctx, githubLatestReleaseAPI, &rel); err != nil { - return nil, err - } - if rel.TagName == "" { - return nil, fmt.Errorf("empty tag name in github response") - } - return &rel, nil -} - -func fetchLatestNightlyRelease(ctx context.Context) (*ghRelease, error) { - // /releases/latest excludes prereleases by design, so list recent - // releases and pick the freshest nightly ourselves. - var all []ghRelease - if err := getJSON(ctx, githubReleasesAPI, &all); err != nil { - return nil, err - } - var best *ghRelease - for i := range all { - r := &all[i] - if r.Draft || !r.Prerelease { - continue - } - if !strings.HasSuffix(r.TagName, nightlyTagSuffix) { - continue - } - if best == nil || r.PublishedAt.After(best.PublishedAt) { - best = r - } - } - if best == nil { - return nil, fmt.Errorf("no nightly release found (looking for tags ending in %q)", nightlyTagSuffix) - } - return best, nil -} - -func getJSON(ctx context.Context, url string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/vnd.github+json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("github api %s: %s", resp.Status, strings.TrimSpace(string(body))) - } - return json.NewDecoder(resp.Body).Decode(out) -} - -// compareVersions returns -1/0/1 like semver.Compare. Inputs may have or -// omit the leading "v"; unparseable inputs fall back to string compare so a -// malformed version never crashes the updater (it just disables the -// downgrade guard for that case, which --force handles). -func compareVersions(a, b string) int { - va, vb := ensureV(a), ensureV(b) - if semver.IsValid(va) && semver.IsValid(vb) { - return semver.Compare(va, vb) - } - return strings.Compare(a, b) -} - -func ensureV(s string) string { - if strings.HasPrefix(s, "v") { - return s - } - return "v" + s -} - -func pickReleaseAsset(assets []ghAsset) (*ghAsset, error) { - if runtime.GOOS != "linux" { - return nil, fmt.Errorf("update only supports linux releases, current os=%s", runtime.GOOS) - } - want := fmt.Sprintf("ehco_linux_%s", runtime.GOARCH) - for i := range assets { - if assets[i].Name == want { - return &assets[i], nil - } - } - return nil, fmt.Errorf("no release asset matches %s", want) -} - -func resolveBinaryPath() (string, error) { - p, err := os.Executable() - if err != nil { - return "", err - } - resolved, err := filepath.EvalSymlinks(p) - if err != nil { - return "", err - } - return resolved, nil -} - -func downloadFile(ctx context.Context, url, dst string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download returned %s", resp.Status) - } - f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) - if err != nil { - return err - } - if _, err := io.Copy(f, resp.Body); err != nil { - _ = f.Close() - return err - } - return f.Close() -} - -func restartSystemdService() error { - if _, err := exec.LookPath("systemctl"); err != nil { - cliLogger.Warn("systemctl not found on PATH; please restart ehco manually") - return nil - } - unit := systemdServiceName + ".service" - cliLogger.Infof("restarting %s via systemctl", unit) - cmd := exec.Command("systemctl", "restart", unit) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() } diff --git a/internal/cmgr/cmgr.go b/internal/cmgr/cmgr.go index 6e3b79561..126e87475 100644 --- a/internal/cmgr/cmgr.go +++ b/internal/cmgr/cmgr.go @@ -39,8 +39,8 @@ type Cmgr interface { Start(ctx context.Context, errCH chan error) // Metrics related - QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) - QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) + QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error) + QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq) (*ms.QueryRuleMetricsResp, error) } type cmgrImpl struct { @@ -184,53 +184,49 @@ func (cm *cmgrImpl) GetActiveConnectCntByRelayLabel(label string) int { return len(cm.activeConnectionsMap[label]) } +// metricsSampleInterval is the cadence at which we read /metrics/ and +// persist a row to the local store, so the dashboard's Node page has +// sub-minute resolution. SyncInterval (default 60s) controls the coarser +// control-plane push only. +const metricsSampleInterval = 5 * time.Second + func (cm *cmgrImpl) Start(ctx context.Context, errCH chan error) { - cm.l.Infof("Start Cmgr sync interval=%d", cm.cfg.SyncInterval) - ticker := time.NewTicker(time.Second * time.Duration(cm.cfg.SyncInterval)) + cm.l.Infof("Start Cmgr sync interval=%d sample interval=%s", cm.cfg.SyncInterval, metricsSampleInterval) + syncEvery := int(time.Duration(cm.cfg.SyncInterval)*time.Second/metricsSampleInterval) - 1 + if syncEvery < 1 { + syncEvery = 1 + } + ticker := time.NewTicker(metricsSampleInterval) defer ticker.Stop() + tick := 0 for { select { case <-ctx.Done(): cm.l.Info("sync stop") return case <-ticker.C: + cm.sampleMetrics(ctx) + tick++ + if tick%syncEvery != 0 { + continue + } // Tolerate transient sync failures: retryablehttp already does // internal backoff; on final error we just log and wait for the // next tick. The traffic stats accumulated for this interval are // dropped on the floor. // TODO: persist unsent stats locally so they can be retried on // later ticks instead of being lost when the upstream is down. - if err := cm.syncOnce(ctx); err != nil { - cm.l.Errorf("sync failed, will retry on next tick in %ds: %s", cm.cfg.SyncInterval, err) + if err := cm.pushStats(ctx); err != nil { + cm.l.Errorf("sync failed, will retry next tick in %ds: %s", cm.cfg.SyncInterval, err) } } } } -func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) { - if refresh { - nm, _, err := cm.mr.ReadOnce(ctx) - if err != nil { - return nil, err - } - if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { - return nil, err - } - } +func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error) { return cm.ms.QueryNodeMetric(ctx, req) } -func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) { - if refresh { - _, rm, err := cm.mr.ReadOnce(ctx) - if err != nil { - return nil, err - } - for _, m := range rm { - if err := cm.ms.AddRuleMetric(ctx, m); err != nil { - return nil, err - } - } - } +func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq) (*ms.QueryRuleMetricsResp, error) { return cm.ms.QueryRuleMetric(ctx, req) } diff --git a/internal/cmgr/syncer.go b/internal/cmgr/syncer.go index a82987f29..a4c459243 100644 --- a/internal/cmgr/syncer.go +++ b/internal/cmgr/syncer.go @@ -24,15 +24,34 @@ type VersionInfo struct { ShortCommit string `json:"short_commit"` } -type syncReq struct { - Version VersionInfo `json:"version"` - Node metric_reader.NodeMetrics `json:"node"` - Stats []StatsPerRule `json:"stats"` +// sampleMetrics reads /metrics/ once and persists node + per-rule rows +// to the local store. Cheap; called on every fast tick so the dashboard +// has sub-minute resolution regardless of whether control-plane sync is +// configured. +func (cm *cmgrImpl) sampleMetrics(ctx context.Context) { + if !cm.cfg.NeedMetrics() { + return + } + nm, rmm, err := cm.mr.ReadOnce(ctx) + if err != nil { + cm.l.Debugf("metrics sample failed: %v", err) + return + } + if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { + cm.l.Errorf("persist node metric: %v", err) + } + for _, rm := range rmm { + if err := cm.ms.AddRuleMetric(ctx, rm); err != nil { + cm.l.Errorf("persist rule metric: %v", err) + } + } } -func (cm *cmgrImpl) syncOnce(ctx context.Context) error { +// pushStats drains closedConnectionsMap and POSTs accumulated traffic +// stats to the control plane. Called at SyncInterval cadence (default +// 60s); a tighter cadence would just spam the control plane. +func (cm *cmgrImpl) pushStats(ctx context.Context) error { cm.l.Infof("sync once total closed connections: %d", cm.countClosedConnection()) - // todo: opt lock cm.lock.Lock() shortCommit := constant.GitRevision @@ -45,26 +64,15 @@ func (cm *cmgrImpl) syncOnce(ctx context.Context) error { } if cm.cfg.NeedMetrics() { - nm, rmm, err := cm.mr.ReadOnce(ctx) - if err != nil { - cm.l.Errorf("read metrics failed: %v", err) + if nm, _, err := cm.mr.ReadOnce(ctx); err != nil { + cm.l.Errorf("read metrics for sync: %v", err) } else { req.Node = *nm - if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { - cm.l.Errorf("add metrics to store failed: %v", err) - } - for _, rm := range rmm { - if err := cm.ms.AddRuleMetric(ctx, rm); err != nil { - cm.l.Errorf("add rule metrics to store failed: %v", err) - } - } } } for label, conns := range cm.closedConnectionsMap { - s := StatsPerRule{ - RelayLabel: label, - } + s := StatsPerRule{RelayLabel: label} var totalLatency int64 for _, c := range conns { s.ConnectionCnt++ @@ -80,11 +88,16 @@ func (cm *cmgrImpl) syncOnce(ctx context.Context) error { cm.closedConnectionsMap = make(map[string][]conn.RelayConn) cm.lock.Unlock() - if cm.cfg.NeedSync() { - cm.l.Debug("syncing data to server", zap.Any("data", req)) - return myhttp.PostJSONWithRetry(cm.cfg.SyncURL, &req) - } else { - cm.l.Debugf("remove %d closed connections", len(req.Stats)) + if !cm.cfg.NeedSync() { + cm.l.Debugf("removed %d closed connections", len(req.Stats)) + return nil } - return nil + cm.l.Debug("syncing data to server", zap.Any("data", req)) + return myhttp.PostJSONWithRetry(cm.cfg.SyncURL, &req) +} + +type syncReq struct { + Version VersionInfo `json:"version"` + Node metric_reader.NodeMetrics `json:"node"` + Stats []StatsPerRule `json:"stats"` } diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 000000000..e48222e73 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,320 @@ +// Package updater self-updates the ehco binary from GitHub releases. +// Used by both `ehco update` (CLI) and the dashboard's /api/v1/update/* +// endpoints. +package updater + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "go.uber.org/zap" + "golang.org/x/mod/semver" +) + +const ( + ChannelAuto = "auto" + ChannelStable = "stable" + ChannelNightly = "nightly" + + releasesAPI = "https://api.github.com/repos/Ehco1996/ehco/releases" + 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"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + LatestTag string `json:"latest_tag"` + ReleaseName string `json:"release_name"` + ReleaseBody string `json:"release_body"` + ReleaseURL string `json:"release_url"` + PublishedAt time.Time `json:"published_at"` + UpdateAvailable bool `json:"update_available"` + AssetName string `json:"asset_name"` + AssetURL string `json:"asset_url"` +} + +// ApplyOptions doubles as the JSON body of POST /api/v1/update/apply. +type ApplyOptions struct { + Channel string `json:"channel"` + Force bool `json:"force"` + Restart bool `json:"restart"` +} + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type ghRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + PublishedAt time.Time `json:"published_at"` + HTMLURL string `json:"html_url"` + Assets []ghAsset `json:"assets"` +} + +// Check resolves channel against currentVersion and queries GitHub. +func Check(ctx context.Context, channel, currentVersion string) (*CheckResult, error) { + resolved, rel, err := pickRelease(ctx, channel, currentVersion) + if err != nil { + return nil, err + } + latest := strings.TrimPrefix(rel.TagName, "v") + res := &CheckResult{ + Channel: resolved, + CurrentVersion: currentVersion, + LatestVersion: latest, + LatestTag: rel.TagName, + ReleaseName: rel.Name, + ReleaseBody: rel.Body, + ReleaseURL: rel.HTMLURL, + PublishedAt: rel.PublishedAt, + } + res.UpdateAvailable = latest != currentVersion && compareVersions(latest, currentVersion) > 0 + if a := pickAsset(rel.Assets); a != nil { + res.AssetName = a.Name + res.AssetURL = a.BrowserDownloadURL + } + 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 string, log *zap.SugaredLogger, onState func(State)) error { + emit := func(s State) { + if onState != nil { + onState(s) + } + } + + emit(StateChecking) + resolved, rel, err := pickRelease(ctx, opts.Channel, currentVersion) + if err != nil { + return err + } + latest := strings.TrimPrefix(rel.TagName, "v") + log.Infof("channel=%s current=%s latest=%s", resolved, currentVersion, latest) + + if !opts.Force { + if latest == currentVersion { + log.Info("already up to date") + emit(StateDone) + return nil + } + if compareVersions(latest, currentVersion) < 0 { + return fmt.Errorf("refusing to downgrade %s -> %s; use force", currentVersion, latest) + } + } + + asset := pickAsset(rel.Assets) + if asset == nil { + return fmt.Errorf("no release asset for %s/%s", runtime.GOOS, runtime.GOARCH) + } + + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("locate binary: %w", err) + } + if binPath, err = filepath.EvalSymlinks(binPath); err != nil { + return fmt.Errorf("resolve binary symlink: %w", err) + } + 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. + if err := os.Chmod(tmpPath, 0o755); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("chmod: %w", err) + } + if err := os.Rename(tmpPath, binPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("replace %s: %w", binPath, err) + } + log.Infof("installed %s at %s", latest, binPath) + + 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 +} + +func pickRelease(ctx context.Context, channel, currentVersion string) (string, *ghRelease, error) { + resolved, err := resolveChannel(channel, currentVersion) + if err != nil { + return "", nil, err + } + rel, err := fetchLatest(ctx, resolved) + if err != nil { + return "", nil, fmt.Errorf("fetch %s: %w", resolved, err) + } + return resolved, rel, nil +} + +func resolveChannel(flag, currentVersion string) (string, error) { + switch flag { + case ChannelStable, ChannelNightly: + return flag, nil + case ChannelAuto, "": + // goreleaser injects "1.1.7-next" for nightlies, "1.1.7" for + // stable. semver.Prerelease handles "+build" and "-rc.1" too. + if semver.Prerelease("v"+currentVersion) != "" { + return ChannelNightly, nil + } + return ChannelStable, nil + default: + return "", fmt.Errorf("invalid channel %q (auto|stable|nightly)", flag) + } +} + +func fetchLatest(ctx context.Context, channel string) (*ghRelease, error) { + if channel == ChannelStable { + // /releases/latest excludes prereleases, perfect for stable. + var rel ghRelease + if err := getJSON(ctx, releasesAPI+"/latest", &rel); err != nil { + return nil, err + } + if rel.TagName == "" { + return nil, fmt.Errorf("empty tag in github response") + } + return &rel, nil + } + // Nightly: list releases and pick the freshest prerelease. + var all []ghRelease + if err := getJSON(ctx, releasesAPI+"?per_page=30", &all); err != nil { + return nil, err + } + var best *ghRelease + for i := range all { + r := &all[i] + if r.Draft || !r.Prerelease { + continue + } + if best == nil || r.PublishedAt.After(best.PublishedAt) { + best = r + } + } + if best == nil { + return nil, fmt.Errorf("no nightly release found") + } + return best, nil +} + +func getJSON(ctx context.Context, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("github %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// compareVersions returns -1/0/1 like semver.Compare. Falls back to +// string compare for unparseable versions so a malformed constant.Version +// never crashes the updater (--force still works). +func compareVersions(a, b string) int { + va, vb := "v"+strings.TrimPrefix(a, "v"), "v"+strings.TrimPrefix(b, "v") + if semver.IsValid(va) && semver.IsValid(vb) { + return semver.Compare(va, vb) + } + return strings.Compare(a, b) +} + +func pickAsset(assets []ghAsset) *ghAsset { + if runtime.GOOS != "linux" { + return nil + } + want := fmt.Sprintf("ehco_linux_%s", runtime.GOARCH) + for i := range assets { + if assets[i].Name == want { + return &assets[i] + } + } + return nil +} + +func download(ctx context.Context, url, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s", resp.Status) + } + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +func restartSystemd(log *zap.SugaredLogger) error { + if _, err := exec.LookPath("systemctl"); err != nil { + log.Warn("systemctl not found; restart ehco manually") + return nil + } + log.Infof("restarting %s.service via systemctl", systemdServiceName) + cmd := exec.Command("systemctl", "restart", systemdServiceName+".service") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 000000000..0af3dd06b --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,46 @@ +package updater + +import "testing" + +func TestResolveChannel(t *testing.T) { + cases := []struct { + flag, current, want string + wantErr bool + }{ + {"stable", "1.1.7-next", "stable", false}, + {"nightly", "1.1.6", "nightly", false}, + {"auto", "1.1.7-next", "nightly", false}, + {"auto", "1.1.6", "stable", false}, + {"", "1.1.6", "stable", false}, + {"bogus", "1.1.6", "", true}, + } + for _, c := range cases { + got, err := resolveChannel(c.flag, c.current) + if (err != nil) != c.wantErr { + t.Errorf("resolveChannel(%q,%q) err=%v wantErr=%v", c.flag, c.current, err, c.wantErr) + continue + } + if got != c.want { + t.Errorf("resolveChannel(%q,%q) = %q want %q", c.flag, c.current, got, c.want) + } + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.1.6", "1.1.7", -1}, + {"1.1.7", "1.1.6", 1}, + {"1.1.6", "1.1.6", 0}, + {"v1.1.6", "1.1.6", 0}, + {"1.1.7-next", "1.1.7", -1}, // semver: prerelease < release + {"1.1.7", "1.1.7-next", 1}, + } + for _, c := range cases { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compareVersions(%q,%q)=%d want %d", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/web/handler_api.go b/internal/web/handler_api.go index 71a4c3dda..208d3a323 100644 --- a/internal/web/handler_api.go +++ b/internal/web/handler_api.go @@ -19,7 +19,7 @@ const ( type queryParams struct { startTS int64 endTS int64 - refresh bool + latest bool } func parseQueryParams(c echo.Context) (*queryParams, error) { @@ -27,7 +27,6 @@ func parseQueryParams(c echo.Context) (*queryParams, error) { params := &queryParams{ startTS: now - defaultTimeRange, endTS: now, - refresh: false, } if start, err := parseTimestamp(c.QueryParam("start_ts")); err == nil { @@ -38,8 +37,8 @@ func parseQueryParams(c echo.Context) (*queryParams, error) { params.endTS = end } - if refresh, err := strconv.ParseBool(c.QueryParam("latest")); err == nil { - params.refresh = refresh + if latest, err := strconv.ParseBool(c.QueryParam("latest")); err == nil { + params.latest = latest } if params.startTS >= params.endTS { @@ -62,10 +61,10 @@ func (s *Server) GetNodeMetrics(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1} - if params.refresh { + if params.latest { req.Num = 1 } - metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req, params.refresh) + metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -84,11 +83,11 @@ func (s *Server) GetRuleMetrics(c echo.Context) error { RuleLabel: c.QueryParam("label"), Remote: c.QueryParam("remote"), } - if params.refresh { + if params.latest { req.Num = 1 } - metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req, params.refresh) + metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } diff --git a/internal/web/handler_update.go b/internal/web/handler_update.go new file mode 100644 index 000000000..19b211862 --- /dev/null +++ b/internal/web/handler_update.go @@ -0,0 +1,130 @@ +package web + +import ( + "context" + "net/http" + "runtime" + "time" + + "github.com/Ehco1996/ehco/internal/constant" + "github.com/Ehco1996/ehco/internal/updater" + "github.com/labstack/echo/v4" +) + +const updateApplyTimeout = 5 * time.Minute + +type VersionInfo struct { + Version string `json:"version"` + GitBranch string `json:"git_branch"` + GitRevision string `json:"git_revision"` + BuildTime string `json:"build_time"` + StartTime time.Time `json:"start_time"` + GoOS string `json:"go_os"` + 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, + GitBranch: constant.GitBranch, + GitRevision: constant.GitRevision, + BuildTime: constant.BuildTime, + StartTime: constant.StartTime, + GoOS: runtime.GOOS, + GoArch: runtime.GOARCH, + }) +} + +func (s *Server) UpdateCheck(c echo.Context) error { + channel := c.QueryParam("channel") + if channel == "" { + channel = updater.ChannelAuto + } + ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second) + defer cancel() + res, err := updater.Check(ctx, channel, constant.Version) + if err != nil { + return echo.NewHTTPError(http.StatusBadGateway, err.Error()) + } + return c.JSON(http.StatusOK, res) +} + +func (s *Server) UpdateApply(c echo.Context) error { + if runtime.GOOS != "linux" { + return echo.NewHTTPError(http.StatusBadRequest, + "self-update only supported on linux; current platform is "+runtime.GOOS) + } + var opts updater.ApplyOptions + if err := c.Bind(&opts); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.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, 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 +} diff --git a/internal/web/server.go b/internal/web/server.go index ec5b6c2a1..a0e19b006 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -5,6 +5,7 @@ import ( "net" "net/http" _ "net/http/pprof" + "sync/atomic" "github.com/labstack/echo/v4" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -31,7 +32,8 @@ type Server struct { cfg *config.Config auth *authenticator - connMgr cmgr.Cmgr + connMgr cmgr.Cmgr + updateJob atomic.Pointer[JobStatus] } func NewServer( @@ -110,6 +112,10 @@ func setupRoutes(s *Server) { api.GET("/health_check/", s.HandleHealthCheck) api.GET("/node_metrics/", s.GetNodeMetrics) api.GET("/rule_metrics/", s.GetRuleMetrics) + api.GET("/version", s.Version) + api.GET("/update/check", s.UpdateCheck) + api.POST("/update/apply", s.UpdateApply) + api.GET("/update/status", s.UpdateStatus) e.GET("/ws/logs", s.handleWebSocketLogs) diff --git a/internal/web/webui/src/App.tsx b/internal/web/webui/src/App.tsx index 3444815d7..72f347321 100644 --- a/internal/web/webui/src/App.tsx +++ b/internal/web/webui/src/App.tsx @@ -9,6 +9,8 @@ import XrayConns from "./pages/XrayConns"; import XrayUsers from "./pages/XrayUsers"; import Logs from "./pages/Logs"; import Settings from "./pages/Settings"; +import Updates from "./pages/Updates"; +import NodeMetricsPage from "./pages/NodeMetrics"; export default function App() { onMount(probeAuth); @@ -30,6 +32,8 @@ export default function App() { + + diff --git a/internal/web/webui/src/api/client.ts b/internal/web/webui/src/api/client.ts index 2819b79b6..987cd8c3d 100644 --- a/internal/web/webui/src/api/client.ts +++ b/internal/web/webui/src/api/client.ts @@ -34,6 +34,10 @@ import type { HealthCheckResp, QueryNodeMetricsResp, QueryRuleMetricsResp, + VersionInfo, + UpdateCheck, + UpdateStatus, + UpdateApplyOptions, } from "./types"; export const api = { @@ -78,6 +82,16 @@ export const api = { { method: "DELETE" }, ), xrayUsers: () => request("/api/v1/xray/users"), + version: () => request("/api/v1/version"), + updateCheck: (channel: string) => + request(`/api/v1/update/check?channel=${encodeURIComponent(channel)}`), + updateApply: (opts: UpdateApplyOptions) => + request<{ state: string }>("/api/v1/update/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }), + updateStatus: () => request("/api/v1/update/status"), }; export function wsURL(path: string): string { diff --git a/internal/web/webui/src/api/types.ts b/internal/web/webui/src/api/types.ts index 9c8014b7d..9b4252ad7 100644 --- a/internal/web/webui/src/api/types.ts +++ b/internal/web/webui/src/api/types.ts @@ -98,6 +98,54 @@ export interface EhcoConfig { [k: string]: unknown; } +export interface VersionInfo { + version: string; + git_branch: string; + git_revision: string; + build_time: string; + start_time: string; // RFC3339 + go_os: string; + go_arch: string; +} + +export interface UpdateCheck { + channel: string; + current_version: string; + latest_version: string; + latest_tag: string; + release_name: string; + release_body: string; + release_url: string; + published_at: string; // RFC3339 + update_available: boolean; + asset_name: string; + 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; + restart: boolean; +} + export interface LogFrame { level: string; ts?: string; diff --git a/internal/web/webui/src/components/Layout.tsx b/internal/web/webui/src/components/Layout.tsx index a93e77953..04a4900e1 100644 --- a/internal/web/webui/src/components/Layout.tsx +++ b/internal/web/webui/src/components/Layout.tsx @@ -7,6 +7,8 @@ import { Cable, ScrollText, Settings, + Download, + Activity, Menu, X, Sun, @@ -28,6 +30,7 @@ interface NavItem { const liveNav: NavItem[] = [ { href: "/", label: "Overview", icon: LayoutDashboard, end: true }, + { href: "/node", label: "Node", icon: Activity }, { href: "/xray/users", label: "Users", icon: Users }, { href: "/xray/conns", label: "Conns", icon: Cable }, { href: "/logs", label: "Logs", icon: ScrollText }, @@ -35,6 +38,7 @@ const liveNav: NavItem[] = [ const configNav: NavItem[] = [ { href: "/rules", label: "Rules", icon: ServerCog }, + { href: "/updates", label: "Updates", icon: Download }, { href: "/settings", label: "Settings", icon: Settings }, ]; diff --git a/internal/web/webui/src/pages/NodeMetrics.tsx b/internal/web/webui/src/pages/NodeMetrics.tsx new file mode 100644 index 000000000..a195534f1 --- /dev/null +++ b/internal/web/webui/src/pages/NodeMetrics.tsx @@ -0,0 +1,145 @@ +import { createResource, createSignal, Show } from "solid-js"; +import { Activity, Cpu, MemoryStick, HardDrive, Network } from "lucide-solid"; +import PageHeader from "../ui/PageHeader"; +import KpiCard from "../ui/KpiCard"; +import { Card, CardHeader } from "../ui/Card"; +import Chart from "../ui/Chart"; +import Segmented from "../ui/Segmented"; +import RefreshPicker from "../ui/RefreshPicker"; +import EmptyState from "../ui/EmptyState"; +import { api } from "../api/client"; +import { bytes, pct } from "../util/format"; +import { usePolling } from "../util/polling"; + +const WINDOWS = [ + { value: 5 * 60, label: "5m" }, + { value: 60 * 60, label: "1h" }, + { value: 6 * 60 * 60, label: "6h" }, + { value: 24 * 60 * 60, label: "24h" }, +] as const; + +export default function NodeMetricsPage() { + const [latest, { refetch: rcLatest }] = createResource(() => + api.nodeMetrics({ latest: true }), + ); + const [windowSec, setWindow] = createSignal(WINDOWS[1].value); + const [series, { refetch: rcSeries }] = createResource(windowSec, async (sec) => { + const end = Math.floor(Date.now() / 1000); + return api.nodeMetrics({ start_ts: end - sec, end_ts: end }); + }); + + const poll = usePolling( + () => { + rcLatest(); + rcSeries(); + }, + { defaultSec: 5 }, + ); + + const last = () => { + const d = latest()?.data; + return d && d.length > 0 ? d[d.length - 1] : null; + }; + const points = () => series()?.data ?? []; + + return ( + <> + } + /> + + + } + title="No samples yet" + hint="The metrics sampler runs every 5s. If this persists, check that the web server's /metrics/ endpoint is reachable." + /> + + } + > +
+ } value={pct(last()!.cpu_usage)} /> + } value={pct(last()!.memory_usage)} /> + } value={pct(last()!.disk_usage)} /> + } + value={`${bytes(last()!.network_in)}/s`} + hint={↑ {bytes(last()!.network_out)}/s} + /> +
+ + +
+ + ({ value: w.value, label: w.label }))} + value={windowSec()} + onChange={setWindow} + size="sm" + /> +
+ 1} + fallback={ +
+ Collecting samples… come back in a moment. +
+ } + > +
+ + d.timestamp)} + series={[ + { label: "CPU %", stroke: "#10b981", values: points().map((d) => d.cpu_usage) }, + { label: "Mem %", stroke: "#6366f1", values: points().map((d) => d.memory_usage) }, + ]} + yFormat={(v) => pct(v)} + /> + + + d.timestamp)} + series={[{ label: "Disk %", stroke: "#f59e0b", values: points().map((d) => d.disk_usage) }]} + yFormat={(v) => pct(v)} + /> + +
+ + d.timestamp)} + series={[ + { label: "in", stroke: "#0ea5e9", values: points().map((d) => d.network_in) }, + { label: "out", stroke: "#f97316", values: points().map((d) => d.network_out) }, + ]} + yFormat={(v) => bytes(v) + "/s"} + /> + +
+
+
+
+
+ + ); +} + +function ChartCard(props: { title: string; children: any }) { + return ( +
+
+ {props.title} +
+ {props.children} +
+ ); +} diff --git a/internal/web/webui/src/pages/Updates.tsx b/internal/web/webui/src/pages/Updates.tsx new file mode 100644 index 000000000..1cd952071 --- /dev/null +++ b/internal/web/webui/src/pages/Updates.tsx @@ -0,0 +1,320 @@ +import { createResource, createSignal, For, onCleanup, Show } from "solid-js"; +import { Download, RefreshCw, CircleCheck, CircleAlert } from "lucide-solid"; +import PageHeader from "../ui/PageHeader"; +import { Card, CardHeader } from "../ui/Card"; +import Button from "../ui/Button"; +import { Pill } from "../ui/Pill"; +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"; + +type Channel = "auto" | "stable" | "nightly"; + +const STEPS: UpdateState[] = [ + "checking", + "downloading", + "installing", + "restarting", + "done", +]; +const cap = (s: string) => s[0].toUpperCase() + s.slice(1); + +export default function Updates() { + const [version, { refetch: rcVersion }] = createResource(() => + api.version(), + ); + const [channel, setChannel] = createSignal("auto"); + const [checking, setChecking] = createSignal(false); + const [check, setCheck] = createSignal(null); + const [checkErr, setCheckErr] = createSignal(""); + const [status, setStatus] = createSignal(null); + const [applyErr, setApplyErr] = createSignal(""); + + let timer: number | null = null; + const stopTimer = () => { + if (timer != null) { + window.clearInterval(timer); + timer = null; + } + }; + onCleanup(stopTimer); + + // 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(); + } + } + } catch { + // Relay is restarting — keep polling, the new process will answer. + } + }; + + 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(""); + try { + setCheck(await api.updateCheck(channel())); + } catch (e) { + setCheckErr(e instanceof ApiError ? e.message : String(e)); + } finally { + setChecking(false); + } + }; + + const applyUpdate = async () => { + const c = check(); + if (!c) return; + if (!confirm( + `Update to ${c.latest_version}? This replaces the running binary and restarts ehco. Active connections will drop.`, + )) return; + setApplyErr(""); + 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)); + } + }; + + 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 build" : "stable build"} + + } + /> + + + + + +
+ Self-update is only supported on linux. On {version()!.go_os} you'll need to rebuild from source. +
+
+
+ + +
+ +
+ + options={[ + { value: "auto", label: "Auto" }, + { value: "stable", label: "Stable" }, + { value: "nightly", label: "Nightly" }, + ]} + value={channel()} + onChange={setChannel} + size="sm" + /> + +
+
+ + +
+ {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"} + +
+
+
+ + )} + +
+ + + + + +
+ {applyErr()} +
+
+ + +
+ Waiting for the relay to come back online… +
+
+ +
+ {status()!.error} +
+
+
+
+ + ); +} + +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)} + + + ); + }} + +
+ ); +} diff --git a/internal/web/webui/src/pages/XrayConns.tsx b/internal/web/webui/src/pages/XrayConns.tsx index 6fe06219a..3f8a9cc1a 100644 --- a/internal/web/webui/src/pages/XrayConns.tsx +++ b/internal/web/webui/src/pages/XrayConns.tsx @@ -267,9 +267,16 @@ export default function XrayConns() { ); @@ -229,7 +220,7 @@ function SheetTile(props: NavItem & { onClose: () => void }) { props.onClose()} - class="flex flex-col items-center justify-center gap-1 rounded-xl border border-zinc-200 p-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-800" + class="flex flex-col items-center justify-center gap-1 rounded-md border border-zinc-200 p-3 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-800" activeClass="!border-emerald-500/50 !text-emerald-700 dark:!text-emerald-400" > diff --git a/internal/web/webui/src/index.css b/internal/web/webui/src/index.css index a12f1b985..86f4882b9 100644 --- a/internal/web/webui/src/index.css +++ b/internal/web/webui/src/index.css @@ -1,38 +1,42 @@ +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"); @import "tailwindcss"; @import "uplot/dist/uPlot.min.css"; /* ---------- Design tokens ---------- - * Single source of truth for colour, type, motion. Light + dark are - * expressed via Tailwind utilities for component code; CSS vars are - * here only for things referenced by raw CSS (uPlot, scrollbars, - * focus rings). + * Operator's-terminal direction: a single mono typeface drives every + * surface. Hierarchy comes from weight + size, not from sans/mono + * mixing. One signal colour (emerald) on a tight zinc palette. * ----------------------------------- */ @theme { - --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", - Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", - monospace; + --font-sans: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; } :root { --accent: oklch(0.62 0.15 160); + --accent-strong: oklch(0.55 0.16 160); --ring: oklch(0.62 0.15 160 / 0.45); - --surface: oklch(0.99 0 0); + --surface: oklch(0.985 0 0); --surface-2: oklch(1 0 0); + --surface-sunk: oklch(0.965 0 0); --border: oklch(0.92 0 0); - --border-strong: oklch(0.88 0 0); - --text: oklch(0.2 0 0); - --text-muted: oklch(0.55 0 0); + --border-strong: oklch(0.86 0 0); + --text: oklch(0.18 0 0); + --text-muted: oklch(0.5 0 0); } .dark { --accent: oklch(0.78 0.16 160); + --accent-strong: oklch(0.84 0.18 160); --ring: oklch(0.78 0.16 160 / 0.45); - --surface: oklch(0.16 0 0); - --surface-2: oklch(0.20 0 0); + --surface: oklch(0.145 0 0); + --surface-2: oklch(0.185 0 0); + --surface-sunk: oklch(0.115 0 0); --border: oklch(0.27 0 0); - --border-strong: oklch(0.32 0 0); + --border-strong: oklch(0.34 0 0); --text: oklch(0.95 0 0); --text-muted: oklch(0.6 0 0); } @@ -47,26 +51,24 @@ html { body { font-family: var(--font-sans); - font-size: 14px; - line-height: 1.5; + font-size: 13px; + line-height: 1.55; + letter-spacing: -0.005em; background: var(--surface); color: var(--text); + font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } -/* Numbers always align — applied broadly so columns of bytes line up. */ table, .font-mono, .tabular { font-variant-numeric: tabular-nums; } -/* Single canonical focus ring used everywhere. Tailwind's default ring - * gets in the way; we hide focus when the user used the mouse and only - * show it for keyboard nav. */ :where(button, a, input, select, textarea, [tabindex]):focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; - border-radius: 6px; + border-radius: 4px; } /* ---------- uPlot dark mode patch ---------- */ @@ -105,6 +107,14 @@ table, .font-mono, .tabular { } .pulse-dot { animation: pulse-dot 1.6s ease-in-out infinite; } +/* ---------- Caret blink for terminal-style brand ---------- */ + +@keyframes caret-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} +.caret-blink { animation: caret-blink 1.1s step-end infinite; } + /* ---------- Safe-area for mobile bottom tab bar ---------- */ .safe-bottom { diff --git a/internal/web/webui/src/pages/Home.tsx b/internal/web/webui/src/pages/Home.tsx new file mode 100644 index 000000000..d5b661433 --- /dev/null +++ b/internal/web/webui/src/pages/Home.tsx @@ -0,0 +1,341 @@ +import { JSX, createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import { Cable, Users as UsersIcon, ArrowRight, Activity } from "lucide-solid"; +import PageHeader from "../ui/PageHeader"; +import { Card, CardHeader } from "../ui/Card"; +import { Pill } from "../ui/Pill"; +import Sparkline from "../ui/Sparkline"; +import EmptyState from "../ui/EmptyState"; +import Chart from "../ui/Chart"; +import Segmented from "../ui/Segmented"; +import RefreshPicker from "../ui/RefreshPicker"; +import { api } from "../api/client"; +import { bytes, bytesShort, pct, rate, relTime, pickStep } from "../util/format"; +import { ipKind, ipKindLabel, ipKindTone } from "../util/ip"; +import { usePolling } from "../util/polling"; +import { recordUserSnapshot, userSamples } from "../store/userTrafficHistory"; +import type { XrayUser } from "../api/types"; + +const TOP_N = 8; + +const WINDOWS = [ + { value: 5 * 60, label: "5m" }, + { value: 60 * 60, label: "1h" }, + { value: 6 * 60 * 60, label: "6h" }, + { value: 24 * 60 * 60, label: "24h" }, + { value: 7 * 24 * 60 * 60, label: "7d" }, + { value: 30 * 24 * 60 * 60, label: "30d" }, +] as const; + +interface ScoredUser { + user: XrayUser; + recent: number; +} + +export default function Home() { + const nav = useNavigate(); + const [windowSec, setWindowSec] = createSignal(WINDOWS[1].value); + + const [overview, { refetch: rcOverview }] = createResource(() => api.overview()); + const [users, { refetch: rcUsers }] = createResource(() => api.xrayUsers()); + const [conns, { refetch: rcConns }] = createResource(() => api.xrayConns()); + const [history, { refetch: rcHistory }] = createResource( + windowSec, + async (sec) => { + const end = Math.floor(Date.now() / 1000); + return api.nodeMetrics({ + start_ts: end - sec, + end_ts: end, + step: pickStep(sec), + }); + }, + ); + + const poll = usePolling( + () => { + rcOverview(); + rcUsers(); + rcConns(); + rcHistory(); + }, + { defaultSec: 15 }, + ); + + createEffect(() => { + const u = users(); + if (u) recordUserSnapshot(u); + }); + + const allConns = () => conns() ?? []; + // Charts want ascending time; the API returns DESC for LIMIT semantics. + const series = createMemo(() => + [...(history()?.data ?? [])].sort((a, b) => a.timestamp - b.timestamp), + ); + const xs = createMemo(() => series().map((d) => d.timestamp)); + + const xray = () => overview()?.xray; + const host = () => overview()?.host; + + const topUsers = createMemo(() => { + const list = (users() ?? []).map((u) => ({ + user: u, + recent: userSamples(u.user_id).reduce((a, b) => a + b, 0), + })); + list.sort((a, b) => { + if (b.recent !== a.recent) return b.recent - a.recent; + const aTotal = a.user.upload_total + a.user.download_total; + const bTotal = b.user.upload_total + b.user.download_total; + return bTotal - aTotal; + }); + return list.slice(0, TOP_N); + }); + + const recentConns = createMemo(() => + allConns() + .slice() + .sort((a, b) => (a.since < b.since ? 1 : -1)) + .slice(0, TOP_N), + ); + + return ( + <> + + ({ value: w.value, label: w.label }))} + value={windowSec()} + onChange={setWindowSec} + size="sm" + /> + + + } + /> + + + + + {series().length} pts · step{" "} + {pickStep(windowSec()) === 0 ? "raw" : `${pickStep(windowSec())}s`} + + } + > + 1} + fallback={ +
+ + collecting samples… +
+ } + > + d.network_in) }, + { label: "out", stroke: "#f97316", values: series().map((d) => d.network_out) }, + ]} + yFormat={(v) => `${bytesShort(v)}/s`} + /> +
+
+ +
+ + + } title="No users registered" />} + > +
    + + {({ user, recent }) => ( +
  • nav(`/conns?user=${user.user_id}`)} + > + {user.user_id} + + {user.protocol || "—"} + + + + {recent > 0 ? bytes(recent) : "—"} + + + {user.tcp_conn_count}c + +
  • + )} +
    +
+
+
+ + + + } title="No live connections" />} + > +
    + + {(c) => ( +
  • nav(`/conns?user=${c.user_id}`)} + > + {c.network} + {c.user_id} + {ipKindLabel[ipKind(c.source_ip)]} + {c.target} + {relTime(c.since)} +
  • + )} +
    +
+
+
+
+ + 1}> +
+ + d.cpu_usage) }, + { label: "mem", stroke: "#6366f1", values: series().map((d) => d.memory_usage) }, + ]} + yFormat={(v) => pct(v)} + /> + + + d.disk_usage) }, + ]} + yFormat={(v) => pct(v)} + /> + +
+
+ + ); +} + +function ChartCard(props: { + title: string; + subtitle?: string; + right?: JSX.Element; + class?: string; + children: JSX.Element; +}) { + return ( + +
+ + {props.right} +
+
{props.children}
+
+ ); +} + +function ListHeader(props: { title: string; subtitle: string; linkTo: string }) { + const nav = useNavigate(); + return ( +
+ + +
+ ); +} + +function ThroughputAnchor(props: { + rateIn: number; + rateOut: number; + conns: number; + users: number; + rules: number; + cpu?: number; + mem?: number; +}) { + return ( +
+ +
+
+ now · rate +
+
+
+ + + {rate(props.rateIn)} + +
+
+ + + {rate(props.rateOut)} + +
+
+
host nic · 5s sample
+
+
+ +
+ + + + +
+
+
+ ); +} + +function Stat(props: { label: string; value: string | number; hint?: string }) { + return ( +
+
+ {props.label} + {props.hint && ( + · {props.hint} + )} +
+
+ {props.value} +
+
+ ); +} diff --git a/internal/web/webui/src/pages/Logs.tsx b/internal/web/webui/src/pages/Logs.tsx index 88a8ac50c..f202d2f9e 100644 --- a/internal/web/webui/src/pages/Logs.tsx +++ b/internal/web/webui/src/pages/Logs.tsx @@ -93,8 +93,8 @@ export default function Logs() { return ( <> 0}> @@ -178,7 +178,7 @@ export default function Logs() {
- {(l) => ( -
-