diff --git a/internal/cli/config.go b/internal/cli/config.go index 0a8c0041c..f2de8ef5e 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -127,6 +127,7 @@ func MustStartComponents(mainCtx context.Context, cfg *config.Config) { } if webS != nil { xrayS.RegisterRoutes(webS.APIGroup()) + webS.SetXrayStatus(xrayS) } if err := xrayS.Start(mainCtx); err != nil { cliLogger.Fatalf("Start XrayServer meet err=%v", err) 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/ms/handler.go b/internal/cmgr/ms/handler.go index f591ef819..c2cb9358a 100644 --- a/internal/cmgr/ms/handler.go +++ b/internal/cmgr/ms/handler.go @@ -2,6 +2,7 @@ package ms import ( "context" + "database/sql" "github.com/Ehco1996/ehco/pkg/metric_reader" ) @@ -20,6 +21,10 @@ type QueryNodeMetricsReq struct { StartTimestamp int64 EndTimestamp int64 Num int64 + // Step buckets samples into N-second windows when > 1, averaging + // every gauge field per bucket. Lets the SPA pull 7d/30d windows + // without dragging back hundreds of thousands of raw points. + Step int64 } type QueryNodeMetricsResp struct { @@ -47,6 +52,11 @@ type QueryRuleMetricsReq struct { StartTimestamp int64 EndTimestamp int64 Num int64 + // Step keeps the last sample per (label, remote) within each + // N-second bucket. Counter-style fields (transmit bytes) keep + // monotonic semantics so the SPA's delta-on-consecutive-points + // trend math still works after bucketing. + Step int64 } type QueryRuleMetricsResp struct { @@ -94,13 +104,33 @@ func (ms *MetricsStore) AddRuleMetric(ctx context.Context, rm *metric_reader.Rul } func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetricsReq) (*QueryNodeMetricsResp, error) { - rows, err := ms.db.QueryContext(ctx, ` - SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out - FROM node_metrics - WHERE timestamp >= ? AND timestamp <= ? - ORDER BY timestamp DESC - LIMIT ? -`, req.StartTimestamp, req.EndTimestamp, req.Num) + var ( + rows *sql.Rows + err error + ) + if req.Step > 1 { + // Floor each timestamp to a step-second bucket and average every + // gauge field. Cheaper than rolling a separate downsample table + // for the windows we care about (≤30d). + rows, err = ms.db.QueryContext(ctx, ` + SELECT (timestamp/?)*? AS bucket_ts, + AVG(cpu_usage), AVG(memory_usage), AVG(disk_usage), + AVG(network_in), AVG(network_out) + FROM node_metrics + WHERE timestamp >= ? AND timestamp <= ? + GROUP BY bucket_ts + ORDER BY bucket_ts DESC + LIMIT ? + `, req.Step, req.Step, req.StartTimestamp, req.EndTimestamp, req.Num) + } else { + rows, err = ms.db.QueryContext(ctx, ` + SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out + FROM node_metrics + WHERE timestamp >= ? AND timestamp <= ? + ORDER BY timestamp DESC + LIMIT ? + `, req.StartTimestamp, req.EndTimestamp, req.Num) + } if err != nil { return nil, err } @@ -119,29 +149,37 @@ func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetri } func (ms *MetricsStore) QueryRuleMetric(ctx context.Context, req *QueryRuleMetricsReq) (*QueryRuleMetricsResp, error) { - query := ` - SELECT timestamp, label, remote, ping_latency, - tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, - udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes - FROM rule_metrics - WHERE timestamp >= ? AND timestamp <= ? - ` - args := []interface{}{req.StartTimestamp, req.EndTimestamp} - + // Bucketed mode keeps the last sample per (label, remote) inside each + // step-second window. The bytes columns are monotonic counters, so + // last-of-bucket preserves the deltas the SPA computes — averaging + // would smear the curve. + const cols = `timestamp, label, remote, ping_latency, + tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, + udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes` + + whereSQL := "WHERE timestamp >= ? AND timestamp <= ?" + whereArgs := []interface{}{req.StartTimestamp, req.EndTimestamp} if req.RuleLabel != "" { - query += " AND label = ?" - args = append(args, req.RuleLabel) + whereSQL += " AND label = ?" + whereArgs = append(whereArgs, req.RuleLabel) } if req.Remote != "" { - query += " AND remote = ?" - args = append(args, req.Remote) + whereSQL += " AND remote = ?" + whereArgs = append(whereArgs, req.Remote) } - query += ` - ORDER BY timestamp DESC - LIMIT ? - ` - args = append(args, req.Num) + var query string + var args []interface{} + if req.Step > 1 { + query = "SELECT " + cols + " FROM rule_metrics WHERE rowid IN (" + + "SELECT MAX(rowid) FROM rule_metrics " + whereSQL + + " GROUP BY (timestamp/?), label, remote) ORDER BY timestamp DESC LIMIT ?" + args = append(append([]interface{}{}, whereArgs...), req.Step, req.Num) + } else { + query = "SELECT " + cols + " FROM rule_metrics " + whereSQL + + " ORDER BY timestamp DESC LIMIT ?" + args = append(whereArgs, req.Num) + } rows, err := ms.db.Query(query, args...) if err != nil { 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/glue/interface.go b/internal/glue/interface.go index 011cdfbfe..1ad3d99f3 100644 --- a/internal/glue/interface.go +++ b/internal/glue/interface.go @@ -12,3 +12,21 @@ type HealthChecker interface { // get relay by ID and check the connection health HealthCheck(ctx context.Context, RelayID string) (int64, error) } + +// XrayStatus is the slice of XrayServer the web admin needs for its +// aggregate /overview endpoint. Defined here so web/ doesn't need to +// import pkg/xray. +type XrayStatus interface { + // Snapshot returns instantaneous counters scraped from the user + // pool and conn tracker. Cheap — no DB hits. + Snapshot() XraySnapshot +} + +type XraySnapshot struct { + Conns int `json:"conns"` + Users int `json:"users"` + EnabledUsers int `json:"enabled_users"` + RunningUsers int `json:"running_users"` + UploadTotal int64 `json:"upload_total"` + DownloadTotal int64 `json:"download_total"` +} diff --git a/internal/metrics/log_level.go b/internal/metrics/log_level.go new file mode 100644 index 000000000..a9bdc2f26 --- /dev/null +++ b/internal/metrics/log_level.go @@ -0,0 +1,21 @@ +package metrics + +import ( + "log/slog" + "strings" +) + +func zapLevelToSlogLevel(zapLevel string) slog.Level { + switch strings.ToLower(zapLevel) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/metrics/node_darwin.go b/internal/metrics/node_darwin.go index d4914d01f..c7bd1ee66 100644 --- a/internal/metrics/node_darwin.go +++ b/internal/metrics/node_darwin.go @@ -3,12 +3,34 @@ package metrics import ( + "fmt" + "log/slog" + "os" + "github.com/Ehco1996/ehco/internal/config" + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/node_exporter/collector" ) -// RegisterNodeExporterMetrics is a no-op on Darwin/macOS -// node_exporter has compatibility issues on macOS, so we disable it +// `thermal` collector logs an ERROR per scrape on most Macs with +// "no CPU power status has been recorded". Disable it via kingpin so +// logs stay quiet. Kept separate from node_linux.go because Linux +// doesn't have the collector and shouldn't carry the flag noise. func RegisterNodeExporterMetrics(cfg *config.Config) error { - // node_exporter is not supported on macOS, skip registration + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: zapLevelToSlogLevel(cfg.LogLeveL), + })) + + if _, err := kingpin.CommandLine.Parse([]string{ + "--no-collector.thermal", + }); err != nil { + return err + } + nc, err := collector.NewNodeCollector(logger) + if err != nil { + return fmt.Errorf("couldn't create collector: %w", err) + } + prometheus.MustRegister(nc) return nil } diff --git a/internal/metrics/node_linux.go b/internal/metrics/node_linux.go index 72a306f20..ec9960511 100644 --- a/internal/metrics/node_linux.go +++ b/internal/metrics/node_linux.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" "os" - "strings" "github.com/Ehco1996/ehco/internal/config" "github.com/alecthomas/kingpin/v2" @@ -14,22 +13,6 @@ import ( "github.com/prometheus/node_exporter/collector" ) -// zapLevelToSlogLevel converts zap log level string to slog.Level -func zapLevelToSlogLevel(zapLevel string) slog.Level { - switch strings.ToLower(zapLevel) { - case "debug": - return slog.LevelDebug - case "info": - return slog.LevelInfo - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } -} - func RegisterNodeExporterMetrics(cfg *config.Config) error { slogLevel := zapLevelToSlogLevel(cfg.LogLeveL) logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 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..6ab487006 100644 --- a/internal/web/handler_api.go +++ b/internal/web/handler_api.go @@ -8,6 +8,7 @@ import ( "time" "github.com/Ehco1996/ehco/internal/cmgr/ms" + "github.com/Ehco1996/ehco/internal/glue" "github.com/labstack/echo/v4" ) @@ -19,7 +20,8 @@ const ( type queryParams struct { startTS int64 endTS int64 - refresh bool + latest bool + step int64 } func parseQueryParams(c echo.Context) (*queryParams, error) { @@ -27,7 +29,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 +39,12 @@ 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 step, err := strconv.ParseInt(c.QueryParam("step"), 10, 64); err == nil && step > 0 { + params.step = step } if params.startTS >= params.endTS { @@ -61,11 +66,11 @@ func (s *Server) GetNodeMetrics(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1} - if params.refresh { + req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1, Step: params.step} + 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()) } @@ -81,14 +86,15 @@ func (s *Server) GetRuleMetrics(c echo.Context) error { StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1, + Step: params.step, 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()) } @@ -135,6 +141,44 @@ func (s *Server) HandleReload(c echo.Context) error { return nil } +// OverviewResp bundles everything the SPA's home page polls — saves +// the front-end the 3 parallel fetches it would otherwise need on +// every refresh tick. Fields stay nil/zero when their subsystem is +// disabled (xray-less deployments, no host sampler yet). +type OverviewResp struct { + Xray *glue.XraySnapshot `json:"xray,omitempty"` + Host *ms.NodeMetrics `json:"host,omitempty"` + Rules int `json:"rules"` +} + +func (s *Server) Overview(c echo.Context) error { + out := OverviewResp{} + + if s.cfg != nil { + out.Rules = len(s.cfg.RelayConfigs) + } + + if p := s.xrayStatus.Load(); p != nil && *p != nil { + snap := (*p).Snapshot() + out.Xray = &snap + } + + if s.connMgr != nil { + now := time.Now() + req := &ms.QueryNodeMetricsReq{ + StartTimestamp: now.Add(-5 * time.Minute).Unix(), + EndTimestamp: now.Unix(), + Num: 1, + } + if resp, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req); err == nil && len(resp.Data) > 0 { + h := resp.Data[0] + out.Host = &h + } + } + + return c.JSON(http.StatusOK, out) +} + func (s *Server) HandleHealthCheck(c echo.Context) error { relayLabel := c.QueryParam("relay_label") if relayLabel == "" { 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..c747f2b92 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,22 @@ type Server struct { cfg *config.Config auth *authenticator - connMgr cmgr.Cmgr + connMgr cmgr.Cmgr + updateJob atomic.Pointer[JobStatus] + + // xrayStatus is wired post-construction by cli boot once the + // XrayServer exists. Always read via Load() — may be nil when + // xray sync is disabled. Atomic pointer keeps it lock-free. + xrayStatus atomic.Pointer[glue.XrayStatus] +} + +// SetXrayStatus is called by cli boot once the XrayServer is +// constructed. The /overview handler picks it up via Load(). +func (s *Server) SetXrayStatus(p glue.XrayStatus) { + if p == nil { + return + } + s.xrayStatus.Store(&p) } func NewServer( @@ -110,6 +126,11 @@ func setupRoutes(s *Server) { api.GET("/health_check/", s.HandleHealthCheck) api.GET("/node_metrics/", s.GetNodeMetrics) api.GET("/rule_metrics/", s.GetRuleMetrics) + api.GET("/overview", s.Overview) + 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..bd5e8d43d 100644 --- a/internal/web/webui/src/App.tsx +++ b/internal/web/webui/src/App.tsx @@ -1,9 +1,9 @@ import { Show, onMount } from "solid-js"; -import { HashRouter, Route } from "@solidjs/router"; +import { HashRouter, Route, Navigate } from "@solidjs/router"; import { authState, probeAuth } from "./store/auth"; import LoginGate from "./components/LoginGate"; import Layout from "./components/Layout"; -import Overview from "./pages/Overview"; +import Home from "./pages/Home"; import Rules from "./pages/Rules"; import XrayConns from "./pages/XrayConns"; import XrayUsers from "./pages/XrayUsers"; @@ -24,13 +24,18 @@ export default function App() { > }> - + + + - - - + {/* Legacy paths kept so bookmarks survive the IA shuffle. */} + } /> + } /> + } /> + } /> + } /> diff --git a/internal/web/webui/src/api/client.ts b/internal/web/webui/src/api/client.ts index 2819b79b6..cd1fd8efb 100644 --- a/internal/web/webui/src/api/client.ts +++ b/internal/web/webui/src/api/client.ts @@ -34,6 +34,11 @@ import type { HealthCheckResp, QueryNodeMetricsResp, QueryRuleMetricsResp, + VersionInfo, + UpdateCheck, + UpdateStatus, + UpdateApplyOptions, + OverviewResp, } from "./types"; export const api = { @@ -44,11 +49,12 @@ export const api = { request( `/api/v1/health_check/?relay_label=${encodeURIComponent(label)}`, ), - nodeMetrics: (params: { start_ts?: number; end_ts?: number; latest?: boolean }) => { + nodeMetrics: (params: { start_ts?: number; end_ts?: number; latest?: boolean; step?: number }) => { const q = new URLSearchParams(); if (params.start_ts != null) q.set("start_ts", String(params.start_ts)); if (params.end_ts != null) q.set("end_ts", String(params.end_ts)); if (params.latest) q.set("latest", "true"); + if (params.step && params.step > 1) q.set("step", String(params.step)); return request(`/api/v1/node_metrics/?${q.toString()}`); }, ruleMetrics: (params: { @@ -57,6 +63,7 @@ export const api = { start_ts?: number; end_ts?: number; latest?: boolean; + step?: number; }) => { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { @@ -64,6 +71,7 @@ export const api = { } return request(`/api/v1/rule_metrics/?${q.toString()}`); }, + overview: () => request("/api/v1/overview"), xrayConns: (userId?: number) => { const q = userId ? `?user=${userId}` : ""; return request(`/api/v1/xray/conns${q}`); @@ -78,6 +86,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..baf3f3f23 100644 --- a/internal/web/webui/src/api/types.ts +++ b/internal/web/webui/src/api/types.ts @@ -30,8 +30,23 @@ export interface NodeMetric { cpu_usage: number; memory_usage: number; disk_usage: number; - network_in: number; - network_out: number; + network_in: number; // bytes/sec + network_out: number; // bytes/sec +} + +export interface XraySnapshot { + conns: number; + users: number; + enabled_users: number; + running_users: number; + upload_total: number; + download_total: number; +} + +export interface OverviewResp { + xray?: XraySnapshot; + host?: NodeMetric; + rules: number; } export interface QueryNodeMetricsResp { @@ -98,6 +113,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..457a61ca0 100644 --- a/internal/web/webui/src/components/Layout.tsx +++ b/internal/web/webui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { JSX, createSignal, Show } from "solid-js"; import { A } from "@solidjs/router"; import { - LayoutDashboard, + Activity, ServerCog, Users, Cable, @@ -22,30 +22,33 @@ const authConfigured = () => authInfo().auth_required; interface NavItem { href: string; label: string; - icon: typeof LayoutDashboard; + icon: typeof Activity; end?: boolean; } -const liveNav: NavItem[] = [ - { href: "/", label: "Overview", icon: LayoutDashboard, end: true }, - { href: "/xray/users", label: "Users", icon: Users }, - { href: "/xray/conns", label: "Conns", icon: Cable }, - { href: "/logs", label: "Logs", icon: ScrollText }, -]; - -const configNav: NavItem[] = [ +// Single flat nav. Home subsumes the old standalone Node page; Settings +// owns the Updates flow as an embedded section. +const nav: NavItem[] = [ + { href: "/", label: "Home", icon: Activity, end: true }, + { href: "/users", label: "Users", icon: Users }, + { href: "/conns", label: "Conns", icon: Cable }, { href: "/rules", label: "Rules", icon: ServerCog }, + { href: "/logs", label: "Logs", icon: ScrollText }, { href: "/settings", label: "Settings", icon: Settings }, ]; // Mobile bottom-bar — five items max for usable touch targets. +// Settings is shoved into "More" sheet to keep tap targets honest. const primaryMobile: NavItem[] = [ - { href: "/", label: "Overview", icon: LayoutDashboard, end: true }, - { href: "/xray/users", label: "Users", icon: Users }, - { href: "/xray/conns", label: "Conns", icon: Cable }, + { href: "/", label: "Home", icon: Activity, end: true }, + { href: "/users", label: "Users", icon: Users }, + { href: "/conns", label: "Conns", icon: Cable }, { href: "/logs", label: "Logs", icon: ScrollText }, ]; -const moreMobile: NavItem[] = configNav; +const moreMobile: NavItem[] = [ + { href: "/rules", label: "Rules", icon: ServerCog }, + { href: "/settings", label: "Settings", icon: Settings }, +]; export default function Layout(props: { children?: JSX.Element }) { const [moreOpen, setMoreOpen] = createSignal(false); @@ -53,11 +56,12 @@ export default function Layout(props: { children?: JSX.Element }) { return (
{/* ===== Desktop sidebar ===== */} -