Close DV-01–04,07,09,13,14: unify operator-deck UX in server/web.
Standardize neon cyan on #00e8f5, wire SacredPageHeader across Builds/Path Tracer/LOTL, dedupe Pages.css, align Path Tracer chrome, drop dead wealth-deck CSS, add prefers-color-scheme shell + HelpTip, sidebar version from package.json build inject, and Vitest CSS regression guards.
This commit is contained in:
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.PHONY: test test-quick test-fast
|
||||||
|
|
||||||
|
# Full suite (Go + Vitest + builds + Playwright). Windows: requires PowerShell.
|
||||||
|
test:
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1
|
||||||
|
|
||||||
|
# Phases 1–7b without Playwright — default post-landing smoke.
|
||||||
|
test-quick:
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1 -SkipE2E
|
||||||
|
|
||||||
|
# Go + Vitest only.
|
||||||
|
test-fast:
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1 -SkipE2E -SkipBuild
|
||||||
@@ -99,14 +99,6 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07.
|
|||||||
| Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. |
|
| Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. |
|
||||||
| Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. |
|
| Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. |
|
||||||
|
|
||||||
## Product decisions (document-only)
|
|
||||||
|
|
||||||
| Topic | Notes |
|
|
||||||
|-------|-------|
|
|
||||||
| Dual storage sync | Session vs localStorage; `aetherforge-auth` on logout; no full cross-tab policy. |
|
|
||||||
| MatrixRain / CursorFire | Layout mounts effects on all routes; route-gating deferred. |
|
|
||||||
| CI scope | `.github/workflows/ci-docker-mining.yml` only; no root Makefile test target. |
|
|
||||||
|
|
||||||
## Scrubbed 2026-06-07 (prod garbage removed)
|
## Scrubbed 2026-06-07 (prod garbage removed)
|
||||||
|
|
||||||
| Item | Action |
|
| Item | Action |
|
||||||
|
|||||||
@@ -1013,6 +1013,19 @@ func (c *AgentClient) stratumEgress(stratumOverlay bool) string {
|
|||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeMinerStatsFile persists live hashrate for container host relay (MINER_STATS_FILE env).
|
||||||
|
func (c *AgentClient) writeMinerStatsFile(hps float64) {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MINER_STATS_FILE"))
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]float64{"hashrate_hps": hps})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(path, payload, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -1034,6 +1047,12 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
hps := c.pool.HashesPerSecond()
|
hps := c.pool.HashesPerSecond()
|
||||||
c.pool.ResetHashCounter()
|
c.pool.ResetHashCounter()
|
||||||
|
if c.hostMiningDisabled.Load() && c.containerMiner != nil && c.containerMiner.Running() {
|
||||||
|
if ch := c.containerMiner.ProbeHashrate(); ch > 0 {
|
||||||
|
hps = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.writeMinerStatsFile(hps)
|
||||||
samples = append(samples, hps)
|
samples = append(samples, hps)
|
||||||
if len(samples) > 90 {
|
if len(samples) > 90 {
|
||||||
samples = samples[len(samples)-90:]
|
samples = samples[len(samples)-90:]
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ func TestStatsPayloadJSONRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsPayloadFailedMethodsJSONRoundTrip(t *testing.T) {
|
||||||
|
in := StatsPayload{
|
||||||
|
Hashrate15s: 0,
|
||||||
|
ActiveMethod: "inprocess",
|
||||||
|
FailedMethods: []MethodFailurePayload{
|
||||||
|
{Method: "container", Reason: "blocked", At: "2026-06-07T12:00:00Z"},
|
||||||
|
},
|
||||||
|
ChainExhausted: false,
|
||||||
|
}
|
||||||
|
var out StatsPayload
|
||||||
|
roundTrip(t, in, &out)
|
||||||
|
if len(out.FailedMethods) != 1 || out.FailedMethods[0].Method != "container" {
|
||||||
|
t.Fatalf("failed_methods: %+v", out.FailedMethods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatsPayloadSpreadGenealogyJSONRoundTrip(t *testing.T) {
|
func TestStatsPayloadSpreadGenealogyJSONRoundTrip(t *testing.T) {
|
||||||
in := StatsPayload{
|
in := StatsPayload{
|
||||||
Hashrate15s: 1, Hashrate1m: 1, Hashrate15m: 1,
|
Hashrate15s: 1, Hashrate1m: 1, Hashrate15m: 1,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package miner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,17 @@ import (
|
|||||||
|
|
||||||
const defaultMinerImage = "aetherforge/agent-worker:latest"
|
const defaultMinerImage = "aetherforge/agent-worker:latest"
|
||||||
|
|
||||||
|
// ContainerStatsPath is where the worker writes hashrate for host relay (docker exec cat).
|
||||||
|
const ContainerStatsPath = "/tmp/miner-stats.json"
|
||||||
|
|
||||||
|
// WorkerImageBuildHint is shown in Forge when container/auto execution is selected.
|
||||||
|
const WorkerImageBuildHint = "Build worker image: docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest . (override with AETHERFORGE_MINER_IMAGE on host)"
|
||||||
|
|
||||||
|
// DefaultWorkerImage returns the OCI reference used when AETHERFORGE_MINER_IMAGE is unset.
|
||||||
|
func DefaultWorkerImage() string {
|
||||||
|
return defaultMinerImage
|
||||||
|
}
|
||||||
|
|
||||||
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
|
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
|
||||||
var containerExecCommand = exec.Command
|
var containerExecCommand = exec.Command
|
||||||
|
|
||||||
@@ -170,6 +182,39 @@ func (l *ContainerLauncher) Running() bool {
|
|||||||
return l.running
|
return l.running
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProbeHashrate reads container-side hashrate from ContainerStatsPath via docker/podman exec.
|
||||||
|
func (l *ContainerLauncher) ProbeHashrate() float64 {
|
||||||
|
l.mu.Lock()
|
||||||
|
if !l.running || l.runtime.CLI == "" {
|
||||||
|
l.mu.Unlock()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
name := l.name
|
||||||
|
cli := l.runtime.CLI
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
cmd := containerExecCommand(cli, "exec", name, "cat", ContainerStatsPath)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ParseContainerStatsJSON(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseContainerStatsJSON extracts hashrate_hps from worker stats JSON (tests + exec probe).
|
||||||
|
func ParseContainerStatsJSON(data []byte) float64 {
|
||||||
|
var snap struct {
|
||||||
|
HashrateHPS float64 `json:"hashrate_hps"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(data), &snap); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if snap.HashrateHPS < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return snap.HashrateHPS
|
||||||
|
}
|
||||||
|
|
||||||
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
|
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
|
||||||
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
|
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -234,6 +279,7 @@ func (l *ContainerLauncher) containerEnv() []string {
|
|||||||
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
||||||
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
|
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
|
||||||
"MINER_LOG_FILE": "/tmp/miner.log",
|
"MINER_LOG_FILE": "/tmp/miner.log",
|
||||||
|
"MINER_STATS_FILE": ContainerStatsPath,
|
||||||
}
|
}
|
||||||
if l.cfg.RVNWallet != "" {
|
if l.cfg.RVNWallet != "" {
|
||||||
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
|
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func TestContainerLauncherStartWithFakeRuntime(t *testing.T) {
|
|||||||
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
||||||
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
|
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
|
||||||
"MINER_LOG_FILE": "/tmp/miner.log",
|
"MINER_LOG_FILE": "/tmp/miner.log",
|
||||||
|
"MINER_STATS_FILE": ContainerStatsPath,
|
||||||
}
|
}
|
||||||
for k, want := range wantEnv {
|
for k, want := range wantEnv {
|
||||||
if got := env[k]; got != want {
|
if got := env[k]; got != want {
|
||||||
@@ -292,6 +293,106 @@ func TestContainerLauncherStopInvokesRm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultWorkerImageAndBuildHint(t *testing.T) {
|
||||||
|
if DefaultWorkerImage() != "aetherforge/agent-worker:latest" {
|
||||||
|
t.Fatalf("DefaultWorkerImage()=%q", DefaultWorkerImage())
|
||||||
|
}
|
||||||
|
if WorkerImageBuildHint == "" || !strings.Contains(WorkerImageBuildHint, "Dockerfile.agent") {
|
||||||
|
t.Fatalf("WorkerImageBuildHint=%q", WorkerImageBuildHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseContainerStatsJSON(t *testing.T) {
|
||||||
|
if got := ParseContainerStatsJSON([]byte(`{"hashrate_hps":1234.5}`)); got != 1234.5 {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
if ParseContainerStatsJSON([]byte(`invalid`)) != 0 {
|
||||||
|
t.Fatal("invalid json should return 0")
|
||||||
|
}
|
||||||
|
if ParseContainerStatsJSON([]byte(`{"hashrate_hps":-1}`)) != 0 {
|
||||||
|
t.Fatal("negative hashrate should return 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerLauncherProbeHashrate(t *testing.T) {
|
||||||
|
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||||
|
if len(args) >= 4 && args[0] == "exec" && args[2] == "cat" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return exec.Command("powershell", "-NoProfile", "-Command", `Write-Output '{"hashrate_hps":987.6}'`)
|
||||||
|
}
|
||||||
|
return exec.Command("sh", "-c", "printf '%s' '{\"hashrate_hps\":987.6}'")
|
||||||
|
}
|
||||||
|
if len(args) > 0 && args[0] == "rm" {
|
||||||
|
return quickExitTestCmd()
|
||||||
|
}
|
||||||
|
return longRunningTestCmd()
|
||||||
|
})
|
||||||
|
defer SetContainerExecCommand(nil)
|
||||||
|
|
||||||
|
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "probe-test"}}
|
||||||
|
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||||
|
launcher, err := NewContainerLauncher(cfg, rt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := launcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer launcher.Stop()
|
||||||
|
|
||||||
|
if got := launcher.ProbeHashrate(); got != 987.6 {
|
||||||
|
t.Fatalf("ProbeHashrate()=%v want 987.6", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerLauncherDefaultImageWithoutEnvOverride(t *testing.T) {
|
||||||
|
t.Setenv("AETHERFORGE_MINER_IMAGE", "")
|
||||||
|
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "default-img"}}
|
||||||
|
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||||
|
launcher, err := NewContainerLauncher(cfg, rt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if launcher.Image() != DefaultWorkerImage() {
|
||||||
|
t.Fatalf("Image()=%q want %q", launcher.Image(), DefaultWorkerImage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerLauncherStatsFileEnv(t *testing.T) {
|
||||||
|
var gotArgs []string
|
||||||
|
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||||
|
if len(args) > 0 && args[0] == "rm" {
|
||||||
|
return quickExitTestCmd()
|
||||||
|
}
|
||||||
|
gotArgs = append([]string(nil), args...)
|
||||||
|
return longRunningTestCmd()
|
||||||
|
})
|
||||||
|
defer SetContainerExecCommand(nil)
|
||||||
|
|
||||||
|
cfg := config.RuntimeConfig{
|
||||||
|
BuiltinConfig: config.BuiltinConfig{
|
||||||
|
BuildID: "stats-env",
|
||||||
|
Wallet: "XMR:wallet",
|
||||||
|
PoolHost: "pool.example.com",
|
||||||
|
PoolPort: 3333,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||||
|
launcher, err := NewContainerLauncher(cfg, rt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := launcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer launcher.Stop()
|
||||||
|
|
||||||
|
env := dockerEnvFromArgs(gotArgs)
|
||||||
|
if env["MINER_STATS_FILE"] != ContainerStatsPath {
|
||||||
|
t.Fatalf("MINER_STATS_FILE=%q want %q", env["MINER_STATS_FILE"], ContainerStatsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContainerLauncherGPUFlags(t *testing.T) {
|
func TestContainerLauncherGPUFlags(t *testing.T) {
|
||||||
var gotArgs []string
|
var gotArgs []string
|
||||||
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||||
|
|||||||
@@ -301,6 +301,60 @@ func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOnMethodFailedReporterIncludesFailedMethods(t *testing.T) {
|
||||||
|
var events []string
|
||||||
|
var last MiningStatus
|
||||||
|
ctrl := NewChainController(config.RuntimeConfig{
|
||||||
|
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
|
||||||
|
}, ChainHooks{}, func(status MiningStatus, eventType string) {
|
||||||
|
events = append(events, eventType)
|
||||||
|
last = status
|
||||||
|
})
|
||||||
|
|
||||||
|
ctrl.OnMethodFailed(MethodContainer, "docker blocked")
|
||||||
|
if len(last.FailedMethods) != 1 {
|
||||||
|
t.Fatalf("failed_methods=%v", last.FailedMethods)
|
||||||
|
}
|
||||||
|
if last.FailedMethods[0].Method != MethodContainer || last.FailedMethods[0].Reason != "docker blocked" {
|
||||||
|
t.Fatalf("failure=%+v", last.FailedMethods[0])
|
||||||
|
}
|
||||||
|
if last.FailedMethods[0].At == "" {
|
||||||
|
t.Fatal("expected RFC3339 timestamp on failure")
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0] != "mining_fallback" {
|
||||||
|
t.Fatalf("events=%v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartChainBypassesCooldown(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
hooks := ChainHooks{
|
||||||
|
StartInProcess: func() error {
|
||||||
|
attempts++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctrl := NewChainController(config.RuntimeConfig{
|
||||||
|
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
|
||||||
|
}, hooks, nil)
|
||||||
|
ctrl.chain = []MiningMethod{MethodInProcess}
|
||||||
|
|
||||||
|
if _, err := ctrl.TryChain(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ctrl.TryChain(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Fatalf("attempts=%d want 1 before restart", attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.RestartChain(context.Background())
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("attempts=%d want 2 after RestartChain clears cooldown", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTryChainRespectsCooldown(t *testing.T) {
|
func TestTryChainRespectsCooldown(t *testing.T) {
|
||||||
attempts := 0
|
attempts := 0
|
||||||
hooks := ChainHooks{
|
hooks := ChainHooks{
|
||||||
|
|||||||
50
server/internal/api/architecture_deferred_test.go
Normal file
50
server/internal/api/architecture_deferred_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArchitectureDeferredHonestStubs(t *testing.T) {
|
||||||
|
t.Run("tunnel_stream WS stub", func(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("websocket.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := string(src)
|
||||||
|
if !strings.Contains(body, `case "tunnel_stream":`) || !strings.Contains(body, `"implemented": false`) {
|
||||||
|
t.Fatal("expected dashboard tunnel_stream not-implemented stub")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WS init pagination option", func(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("dashboard_ws_init.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "init_limit") {
|
||||||
|
t.Fatal("expected init_limit query param parser")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Path Tracer SQLite persistence", func(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("pathtracer_handler.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "loadPersistedSessions") {
|
||||||
|
t.Fatal("expected Path Tracer startup restore from SQLite")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SQLite single-writer ceiling documented", func(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("../db/sqlite.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "SetMaxOpenConns(1)") {
|
||||||
|
t.Fatal("expected SQLite single-writer guard")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
35
server/internal/api/dashboard_ws_init.go
Normal file
35
server/internal/api/dashboard_ws_init.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dashboardInitDefaultLimit = 100
|
||||||
|
dashboardInitMaxLimit = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseDashboardInitFilter reads optional init_limit / init_offset on /ws/dashboard.
|
||||||
|
func parseDashboardInitFilter(r *http.Request) (db.AgentListFilter, bool) {
|
||||||
|
limitStr := r.URL.Query().Get("init_limit")
|
||||||
|
if limitStr == "" {
|
||||||
|
return db.AgentListFilter{}, false
|
||||||
|
}
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
limit = dashboardInitDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > dashboardInitMaxLimit {
|
||||||
|
limit = dashboardInitMaxLimit
|
||||||
|
}
|
||||||
|
offset := 0
|
||||||
|
if offStr := r.URL.Query().Get("init_offset"); offStr != "" {
|
||||||
|
if o, err := strconv.Atoi(offStr); err == nil && o >= 0 {
|
||||||
|
offset = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.AgentListFilter{Limit: limit, Offset: offset}, true
|
||||||
|
}
|
||||||
@@ -102,10 +102,112 @@ func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
|
|||||||
sessions: make(map[string]*TraceSession),
|
sessions: make(map[string]*TraceSession),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
h.loadPersistedSessions()
|
||||||
go h.sessionCleanupLoop()
|
go h.sessionCleanupLoop()
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathTraceSessionPersist is the SQLite JSON shape (includes WG client keys for QR after restart).
|
||||||
|
type pathTraceSessionPersist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AgentIDs []string `json:"agent_ids"`
|
||||||
|
Hops []*HopInfo `json:"hops"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ServiceGraph map[string]ServiceGraphHost `json:"service_graph,omitempty"`
|
||||||
|
DiscoverInProgress bool `json:"discover_in_progress,omitempty"`
|
||||||
|
DiscoverError string `json:"discover_error,omitempty"`
|
||||||
|
DiscoveredAt *time.Time `json:"discovered_at,omitempty"`
|
||||||
|
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
|
||||||
|
ClientPrivKey string `json:"client_priv_key,omitempty"`
|
||||||
|
ClientPubKey string `json:"client_pub_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PathTracerHandler) loadPersistedSessions() {
|
||||||
|
if h.hub == nil || h.hub.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-pathTraceSessionTTL)
|
||||||
|
if n, err := h.hub.db.DeleteExpiredPathTraceSessions(cutoff); err == nil && n > 0 {
|
||||||
|
log.Printf("[pathtrace] startup sweep removed %d expired session(s)", n)
|
||||||
|
}
|
||||||
|
rows, err := h.hub.db.ListPathTraceSessions()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pathtrace] load sessions failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for _, row := range rows {
|
||||||
|
if time.Since(row.CreatedAt) > pathTraceSessionTTL {
|
||||||
|
_ = h.hub.db.DeletePathTraceSession(row.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec pathTraceSessionPersist
|
||||||
|
if err := json.Unmarshal(row.Payload, &rec); err != nil {
|
||||||
|
_ = h.hub.db.DeletePathTraceSession(row.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.sessions[rec.ID] = &TraceSession{
|
||||||
|
ID: rec.ID,
|
||||||
|
AgentIDs: rec.AgentIDs,
|
||||||
|
Hops: rec.Hops,
|
||||||
|
Ready: rec.Ready,
|
||||||
|
Error: rec.Error,
|
||||||
|
CreatedAt: rec.CreatedAt,
|
||||||
|
ServiceGraph: rec.ServiceGraph,
|
||||||
|
DiscoverInProgress: rec.DiscoverInProgress,
|
||||||
|
DiscoverError: rec.DiscoverError,
|
||||||
|
DiscoveredAt: rec.DiscoveredAt,
|
||||||
|
NetworkHints: rec.NetworkHints,
|
||||||
|
clientPrivKey: rec.ClientPrivKey,
|
||||||
|
clientPubKey: rec.ClientPubKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
log.Printf("[pathtrace] restored %d session(s) from SQLite", len(h.sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PathTracerHandler) persistSession(sess *TraceSession) {
|
||||||
|
if h.hub == nil || h.hub.db == nil || sess == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
rec := pathTraceSessionPersist{
|
||||||
|
ID: sess.ID,
|
||||||
|
AgentIDs: sess.AgentIDs,
|
||||||
|
Hops: sess.Hops,
|
||||||
|
Ready: sess.Ready,
|
||||||
|
Error: sess.Error,
|
||||||
|
CreatedAt: sess.CreatedAt,
|
||||||
|
ServiceGraph: sess.ServiceGraph,
|
||||||
|
DiscoverInProgress: sess.DiscoverInProgress,
|
||||||
|
DiscoverError: sess.DiscoverError,
|
||||||
|
DiscoveredAt: sess.DiscoveredAt,
|
||||||
|
NetworkHints: sess.NetworkHints,
|
||||||
|
ClientPrivKey: sess.clientPrivKey,
|
||||||
|
ClientPubKey: sess.clientPubKey,
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
raw, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pathtrace] marshal session %s: %v", sess.ID[:min(8, len(sess.ID))], err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.hub.db.UpsertPathTraceSession(sess.ID, sess.CreatedAt, raw); err != nil {
|
||||||
|
log.Printf("[pathtrace] persist session %s: %v", sess.ID[:min(8, len(sess.ID))], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PathTracerHandler) deletePersistedSession(id string) {
|
||||||
|
if h.hub == nil || h.hub.db == nil || id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.hub.db.DeletePathTraceSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PathTracerHandler) sessionCleanupLoop() {
|
func (h *PathTracerHandler) sessionCleanupLoop() {
|
||||||
ticker := time.NewTicker(pathTraceCleanupInterval)
|
ticker := time.NewTicker(pathTraceCleanupInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -133,6 +235,7 @@ func (h *PathTracerHandler) expireSessions() {
|
|||||||
for _, sess := range expired {
|
for _, sess := range expired {
|
||||||
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
|
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
|
||||||
h.teardownHops(sess.Hops)
|
h.teardownHops(sess.Hops)
|
||||||
|
h.deletePersistedSession(sess.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +304,7 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessions[sess.ID] = sess
|
h.sessions[sess.ID] = sess
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.persistSession(sess)
|
||||||
|
|
||||||
// Orchestrate asynchronously so the HTTP response returns quickly.
|
// Orchestrate asynchronously so the HTTP response returns quickly.
|
||||||
go h.orchestrate(sess)
|
go h.orchestrate(sess)
|
||||||
@@ -301,6 +405,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, id)
|
delete(h.sessions, id)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.deletePersistedSession(id)
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{"ok": true})
|
writeJSON(w, map[string]interface{}{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -777,6 +882,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
|
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
|
||||||
|
h.persistSession(sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {
|
func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {
|
||||||
|
|||||||
48
server/internal/api/pathtracer_persist_test.go
Normal file
48
server/internal/api/pathtracer_persist_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathTracerSessionSurvivesHandlerRestart(t *testing.T) {
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
sess := &TraceSession{
|
||||||
|
ID: "sess-restart-abcdef12",
|
||||||
|
AgentIDs: []string{"agent-1"},
|
||||||
|
CreatedAt: time.Now().Add(-10 * time.Minute),
|
||||||
|
Ready: true,
|
||||||
|
clientPrivKey: "PRIV",
|
||||||
|
clientPubKey: "PUB",
|
||||||
|
Hops: []*HopInfo{{
|
||||||
|
AgentID: "agent-1",
|
||||||
|
AgentName: "Hop One",
|
||||||
|
ExternalIP: "203.0.113.10",
|
||||||
|
Port: 51820,
|
||||||
|
Status: HopReady,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 := NewPathTracerHandler(hub)
|
||||||
|
h1.mu.Lock()
|
||||||
|
h1.sessions[sess.ID] = sess
|
||||||
|
h1.mu.Unlock()
|
||||||
|
h1.persistSession(sess)
|
||||||
|
|
||||||
|
h2 := NewPathTracerHandler(hub)
|
||||||
|
restored := h2.getSession(sess.ID)
|
||||||
|
if restored == nil {
|
||||||
|
t.Fatal("session missing after handler restart")
|
||||||
|
}
|
||||||
|
if !restored.Ready || restored.clientPubKey != "PUB" {
|
||||||
|
t.Fatalf("restored session incomplete: ready=%v pub=%q", restored.Ready, restored.clientPubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"WSDashboardInit": [
|
"WSDashboardInit": [
|
||||||
"agents"
|
"agents",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"total"
|
||||||
],
|
],
|
||||||
"WSAgentOffline": [
|
"WSAgentOffline": [
|
||||||
"agent_id"
|
"agent_id"
|
||||||
|
|||||||
@@ -241,8 +241,7 @@ func (h *WSHub) runStaleAgentSweep() {
|
|||||||
if h.db == nil {
|
if h.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const staleness = 3 * time.Minute
|
ticker := time.NewTicker(StaleAgentSweepInterval)
|
||||||
ticker := time.NewTicker(45 * time.Second)
|
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
// Only sweep agents that are NOT currently connected in memory.
|
// Only sweep agents that are NOT currently connected in memory.
|
||||||
@@ -254,15 +253,12 @@ func (h *WSHub) runStaleAgentSweep() {
|
|||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
|
|
||||||
agents, err := h.db.ListAgents()
|
agents, err := h.db.ListStaleOnlineAgents(StaleAgentThreshold)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
if a == nil || a.Status != "online" || liveIDs[a.ID] {
|
if a == nil || liveIDs[a.ID] {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if time.Since(a.LastSeen) < staleness {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Row claims online, no live socket, last_seen is stale — fix it.
|
// Row claims online, no live socket, last_seen is stale — fix it.
|
||||||
@@ -1672,7 +1668,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Send initial data — reconcile DB status against live hub state so a
|
// Send initial data — reconcile DB status against live hub state so a
|
||||||
// freshly loaded dashboard never shows stale "online" phantoms.
|
// freshly loaded dashboard never shows stale "online" phantoms.
|
||||||
agents, _ := h.db.ListAgents()
|
initFilter, paginated := parseDashboardInitFilter(r)
|
||||||
|
var agents []*models.Agent
|
||||||
|
var initTotal int
|
||||||
|
if paginated {
|
||||||
|
agents, _ = h.db.ListAgentsFiltered(initFilter)
|
||||||
|
initTotal, _ = h.db.CountAgentsFiltered(initFilter)
|
||||||
|
} else {
|
||||||
|
agents, _ = h.db.ListAgents()
|
||||||
|
}
|
||||||
h.enrichAgentsCapabilities(agents)
|
h.enrichAgentsCapabilities(agents)
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
@@ -1687,10 +1691,16 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
stats, _ := h.db.GetFleetStats()
|
stats, _ := h.db.GetFleetStats()
|
||||||
|
|
||||||
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
initPayload := map[string]interface{}{
|
||||||
"agents": agents,
|
"agents": agents,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
})})
|
}
|
||||||
|
if paginated {
|
||||||
|
initPayload["total"] = initTotal
|
||||||
|
initPayload["limit"] = initFilter.Limit
|
||||||
|
initPayload["offset"] = initFilter.Offset
|
||||||
|
}
|
||||||
|
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(initPayload)})
|
||||||
_ = dc.WriteJSON(Message{Type: "presence_snapshot", Payload: mustMarshal(map[string]interface{}{
|
_ = dc.WriteJSON(Message{Type: "presence_snapshot", Payload: mustMarshal(map[string]interface{}{
|
||||||
"comrades": h.presenceSnapshotLocked(),
|
"comrades": h.presenceSnapshotLocked(),
|
||||||
})})
|
})})
|
||||||
@@ -1733,14 +1743,17 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
h.broadcastNotesTyping(username, body.Active)
|
h.broadcastNotesTyping(username, body.Active)
|
||||||
|
case "tunnel_stream":
|
||||||
|
_ = dc.WriteJSON(Message{Type: "tunnel_stream", Payload: mustMarshal(map[string]interface{}{
|
||||||
|
"implemented": false,
|
||||||
|
"error": "tunnel_stream TCP reverse relay is not implemented; use tunnel_cloudflared or tunnel_ssh_forward on fleet agents",
|
||||||
|
})})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statsBatchInterval = 250 * time.Millisecond
|
|
||||||
|
|
||||||
// mergeStatsPayload shallow-merges two stats maps so stats + mining_status in the
|
// mergeStatsPayload shallow-merges two stats maps so stats + mining_status in the
|
||||||
// same 250ms window both land in one stats_batch update for dashboards.
|
// same coalesce window both land in one stats_batch update for dashboards.
|
||||||
func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage {
|
func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage {
|
||||||
var base, patch map[string]interface{}
|
var base, patch map[string]interface{}
|
||||||
if json.Unmarshal(existing, &base) != nil || base == nil {
|
if json.Unmarshal(existing, &base) != nil || base == nil {
|
||||||
@@ -1795,7 +1808,7 @@ func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) {
|
|||||||
h.statsBatch[agentID] = data
|
h.statsBatch[agentID] = data
|
||||||
h.cacheAgentTelemetry(agentID, payload)
|
h.cacheAgentTelemetry(agentID, payload)
|
||||||
if h.statsBatchTimer == nil {
|
if h.statsBatchTimer == nil {
|
||||||
h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch)
|
h.statsBatchTimer = time.AfterFunc(StatsBatchCoalesceInterval, h.flushStatsBatch)
|
||||||
}
|
}
|
||||||
h.statsBatchMu.Unlock()
|
h.statsBatchMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,116 @@ func TestHandleDashboardWSAuthorizedInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboardWSInitLimit(t *testing.T) {
|
||||||
|
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if err := database.UpsertAgent(&models.Agent{
|
||||||
|
ID: fmt.Sprintf("agent-%d", i),
|
||||||
|
Name: fmt.Sprintf("Worker %d", i),
|
||||||
|
Status: "offline",
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
|
||||||
|
"?token=" + wsDashboardToken(testAuthUser, testAuthPass) + "&init_limit=2&init_offset=1"
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
|
t.Fatalf("read init: %v", err)
|
||||||
|
}
|
||||||
|
if msg.Type != "init" {
|
||||||
|
t.Fatalf("expected init, got %q", msg.Type)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(msg.Payload, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
agents, _ := body["agents"].([]interface{})
|
||||||
|
if len(agents) != 2 {
|
||||||
|
t.Fatalf("expected 2 agents in init page, got %d", len(agents))
|
||||||
|
}
|
||||||
|
if int(body["total"].(float64)) != 5 {
|
||||||
|
t.Fatalf("total=%v want 5", body["total"])
|
||||||
|
}
|
||||||
|
if int(body["limit"].(float64)) != 2 || int(body["offset"].(float64)) != 1 {
|
||||||
|
t.Fatalf("limit/offset = %v/%v", body["limit"], body["offset"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDashboardWSTunnelStreamStub(t *testing.T) {
|
||||||
|
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
// Drain init + presence_snapshot.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
var msg Message
|
||||||
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
|
t.Fatalf("read bootstrap %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(Message{Type: "tunnel_stream", Payload: json.RawMessage(`{"port":8989}`)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("timed out waiting for tunnel_stream response")
|
||||||
|
}
|
||||||
|
var resp Message
|
||||||
|
if err := conn.ReadJSON(&resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Type != "tunnel_stream" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["implemented"] != false {
|
||||||
|
t.Fatalf("expected implemented=false, got %+v", body)
|
||||||
|
}
|
||||||
|
if _, ok := body["error"].(string); !ok {
|
||||||
|
t.Fatalf("expected error string, got %+v", body)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resetWSAuthUsersMulti(t *testing.T, creds map[string]string) {
|
func resetWSAuthUsersMulti(t *testing.T, creds map[string]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
users := make(map[string]string, len(creds))
|
users := make(map[string]string, len(creds))
|
||||||
@@ -795,6 +905,105 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsBatchRelaysFailedMethods(t *testing.T) {
|
||||||
|
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||||
|
t.Cleanup(dashSrv.Close)
|
||||||
|
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||||
|
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial dashboard: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = dashConn.Close() })
|
||||||
|
|
||||||
|
type batchResult struct {
|
||||||
|
updates []map[string]interface{}
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
batchCh := make(chan batchResult, 1)
|
||||||
|
go func() {
|
||||||
|
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
for {
|
||||||
|
var msg Message
|
||||||
|
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||||
|
batchCh <- batchResult{err: err.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.Type != "stats_batch" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Updates []json.RawMessage `json:"updates"`
|
||||||
|
}
|
||||||
|
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
|
||||||
|
batchCh <- batchResult{err: parseErr.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := make([]map[string]interface{}, 0, len(body.Updates))
|
||||||
|
for _, raw := range body.Updates {
|
||||||
|
var u map[string]interface{}
|
||||||
|
if json.Unmarshal(raw, &u) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updates = append(updates, u)
|
||||||
|
}
|
||||||
|
if len(updates) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batchCh <- batchResult{updates: updates}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
agentID := "failed-methods-agent"
|
||||||
|
conn := connectTestAgent(t, hub, agentID)
|
||||||
|
statsPayload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"hashrate_15s": 0.0,
|
||||||
|
"active_method": "inprocess",
|
||||||
|
"chain_exhausted": false,
|
||||||
|
"failed_methods": []map[string]interface{}{
|
||||||
|
{"method": "container", "reason": "image not found", "at": "2026-06-07T12:00:00Z"},
|
||||||
|
{"method": "docker_load", "reason": "no tar", "at": "2026-06-07T12:00:01Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stopStatsBatchTimer(hub)
|
||||||
|
hub.flushStatsBatch()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-batchCh:
|
||||||
|
if r.err != "" {
|
||||||
|
t.Fatalf("dashboard did not receive stats_batch: %s", r.err)
|
||||||
|
}
|
||||||
|
if len(r.updates) != 1 {
|
||||||
|
t.Fatalf("expected 1 update, got %d: %+v", len(r.updates), r.updates)
|
||||||
|
}
|
||||||
|
u := r.updates[0]
|
||||||
|
if u["agent_id"] != agentID {
|
||||||
|
t.Errorf("agent_id = %v", u["agent_id"])
|
||||||
|
}
|
||||||
|
failed, ok := u["failed_methods"].([]interface{})
|
||||||
|
if !ok || len(failed) != 2 {
|
||||||
|
t.Fatalf("failed_methods = %T %v", u["failed_methods"], u["failed_methods"])
|
||||||
|
}
|
||||||
|
first, _ := failed[0].(map[string]interface{})
|
||||||
|
if first["method"] != "container" || first["reason"] != "image not found" {
|
||||||
|
t.Errorf("first failure = %v", first)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for stats_batch with failed_methods")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stopStatsBatchTimer cancels the 250ms flush timer so tests can read statsBatch
|
// stopStatsBatchTimer cancels the 250ms flush timer so tests can read statsBatch
|
||||||
// without racing flushStatsBatch clearing the pending map.
|
// without racing flushStatsBatch clearing the pending map.
|
||||||
func stopStatsBatchTimer(hub *WSHub) {
|
func stopStatsBatchTimer(hub *WSHub) {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ package api
|
|||||||
|
|
||||||
type WSDashboardInit struct {
|
type WSDashboardInit struct {
|
||||||
Agents []interface{} `json:"agents"`
|
Agents []interface{} `json:"agents"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WSAgentOffline struct {
|
type WSAgentOffline struct {
|
||||||
|
|||||||
73
server/internal/db/pathtrace_sessions.go
Normal file
73
server/internal/db/pathtrace_sessions.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathTraceSessionRow is one persisted Path Tracer session blob.
|
||||||
|
type PathTraceSessionRow struct {
|
||||||
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPathTraceSession stores or replaces a session JSON blob.
|
||||||
|
func (d *Database) UpsertPathTraceSession(id string, createdAt time.Time, payload []byte) error {
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO pathtrace_sessions (id, created_at, payload)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET created_at = excluded.created_at, payload = excluded.payload`,
|
||||||
|
id, createdAt.UTC().Format(time.RFC3339Nano), payload,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPathTraceSessions returns all session rows ordered by age.
|
||||||
|
func (d *Database) ListPathTraceSessions() ([]PathTraceSessionRow, error) {
|
||||||
|
rows, err := d.Query(`SELECT id, created_at, payload FROM pathtrace_sessions ORDER BY created_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PathTraceSessionRow
|
||||||
|
for rows.Next() {
|
||||||
|
var row PathTraceSessionRow
|
||||||
|
var created string
|
||||||
|
if err := rows.Scan(&row.ID, &created, &row.Payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
|
||||||
|
if row.CreatedAt.IsZero() {
|
||||||
|
row.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePathTraceSession removes one session row.
|
||||||
|
func (d *Database) DeletePathTraceSession(id string) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM pathtrace_sessions WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExpiredPathTraceSessions removes rows older than cutoff.
|
||||||
|
func (d *Database) DeleteExpiredPathTraceSessions(cutoff time.Time) (int64, error) {
|
||||||
|
res, err := d.Exec(`DELETE FROM pathtrace_sessions WHERE created_at < ?`, cutoff.UTC().Format(time.RFC3339Nano))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathTraceSessionCount returns how many session rows exist.
|
||||||
|
func (d *Database) PathTraceSessionCount() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := d.QueryRow(`SELECT COUNT(*) FROM pathtrace_sessions`).Scan(&n)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
36
server/internal/db/pathtrace_sessions_test.go
Normal file
36
server/internal/db/pathtrace_sessions_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathTraceSessionPersistenceAndSweep(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
|
||||||
|
fresh := []byte(`{"id":"sess-fresh","ready":true}`)
|
||||||
|
stale := []byte(`{"id":"sess-stale","ready":false}`)
|
||||||
|
|
||||||
|
if err := d.UpsertPathTraceSession("sess-fresh", time.Now().Add(-30*time.Minute), fresh); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := d.UpsertPathTraceSession("sess-stale", time.Now().Add(-3*time.Hour), stale); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := d.DeleteExpiredPathTraceSessions(time.Now().Add(-2 * time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Fatalf("expected 1 stale row deleted, got %d", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := d.ListPathTraceSessions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].ID != "sess-fresh" {
|
||||||
|
t.Fatalf("expected fresh session only, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,12 @@ func (d *Database) migrate() error {
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`,
|
`CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS pathtrace_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_pathtrace_sessions_created ON pathtrace_sessions(created_at)`,
|
||||||
}
|
}
|
||||||
for _, m := range extraMigrations {
|
for _, m := range extraMigrations {
|
||||||
if _, err := d.Exec(m); err != nil {
|
if _, err := d.Exec(m); err != nil {
|
||||||
@@ -252,6 +258,7 @@ func (d *Database) migrate() error {
|
|||||||
scaleIndexes := []string{
|
scaleIndexes := []string{
|
||||||
`CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`,
|
`CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`,
|
`CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen ON agents(status, last_seen)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`,
|
||||||
}
|
}
|
||||||
for _, m := range scaleIndexes {
|
for _, m := range scaleIndexes {
|
||||||
|
|||||||
@@ -64,6 +64,36 @@ func TestGetAgentNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertAgentPreservesOperatorNameWhenDiffersFromHostname(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
a := &models.Agent{
|
||||||
|
ID: "renamed-worker",
|
||||||
|
Name: "Living Room PC",
|
||||||
|
Hostname: "DESKTOP-ABC123",
|
||||||
|
Status: "offline",
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
if err := d.UpsertAgent(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect reports hostname only — UpsertAgent must keep operator label.
|
||||||
|
a.Name = "DESKTOP-ABC123"
|
||||||
|
a.Hostname = "DESKTOP-ABC123"
|
||||||
|
a.Status = "online"
|
||||||
|
if err := d.UpsertAgent(a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := d.GetAgent(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Name != "Living Room PC" {
|
||||||
|
t.Fatalf("operator name should be preserved; got %q hostname=%q", got.Name, got.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
||||||
d := openTestDB(t)
|
d := openTestDB(t)
|
||||||
a := seedAgent(t, d, "persist-created")
|
a := seedAgent(t, d, "persist-created")
|
||||||
|
|||||||
@@ -77,4 +77,14 @@ describe('auth session helpers', () => {
|
|||||||
expect(consumeAuthExpiredFlag()).toBe(true);
|
expect(consumeAuthExpiredFlag()).toBe(true);
|
||||||
expect(consumeAuthExpiredFlag()).toBe(false);
|
expect(consumeAuthExpiredFlag()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches aetherforge-auth on login and logout', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener('aetherforge-auth', handler);
|
||||||
|
setStoredAuth('user', 'pass');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
clearStoredAuth();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
window.removeEventListener('aetherforge-auth', handler);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs';
|
|||||||
import SetupBanner from '../SetupBanner';
|
import SetupBanner from '../SetupBanner';
|
||||||
import { getSetupStatus } from '../../help/setupStatus';
|
import { getSetupStatus } from '../../help/setupStatus';
|
||||||
import { resolvePageWeather } from '../../help/pageWeather';
|
import { resolvePageWeather } from '../../help/pageWeather';
|
||||||
|
import { isDashboardRoute } from '../../help/routeEffects';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { usePresence } from '../../context/PresenceContext';
|
import { usePresence } from '../../context/PresenceContext';
|
||||||
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||||
|
import { formatAppVersion } from '../../help/appVersion';
|
||||||
import type { ServerConfig, ServerInfo } from '../../types';
|
import type { ServerConfig, ServerInfo } from '../../types';
|
||||||
import '../Presence/Presence.css';
|
import '../Presence/Presence.css';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
@@ -280,6 +282,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
const setupStatus = getSetupStatus(serverConfig, serverInfo);
|
const setupStatus = getSetupStatus(serverConfig, serverInfo);
|
||||||
const pageWeather = resolvePageWeather(location.pathname);
|
const pageWeather = resolvePageWeather(location.pathname);
|
||||||
|
const showDeckEffects = isDashboardRoute(location.pathname);
|
||||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||||
const mobileShortLabel: Record<string, string> = {
|
const mobileShortLabel: Record<string, string> = {
|
||||||
'/dashboard': 'Deck',
|
'/dashboard': 'Deck',
|
||||||
@@ -296,7 +299,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||||
data-operator-deck={operatorDeckId(location.pathname)}
|
data-operator-deck={operatorDeckId(location.pathname)}
|
||||||
>
|
>
|
||||||
{!isMobile && glowParticles && <CursorFire />}
|
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
|
||||||
<AmbientBackground weather={pageWeather} />
|
<AmbientBackground weather={pageWeather} />
|
||||||
{glowParticles && <SacredGeometryLayer />}
|
{glowParticles && <SacredGeometryLayer />}
|
||||||
<nav className="sidebar sidebar--desktop desktop-only">
|
<nav className="sidebar sidebar--desktop desktop-only">
|
||||||
@@ -348,8 +351,8 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<SacredMotif name="flower" opacity={0.6} />
|
<SacredMotif name="flower" opacity={0.6} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
|
{/* Matrix rain log — Command Deck only; fills lower sidebar between nav and footer */}
|
||||||
<MatrixRain />
|
{showDeckEffects && <MatrixRain />}
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<FleetReadout />
|
<FleetReadout />
|
||||||
@@ -367,7 +370,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="sidebar-sig font-tech">
|
<div className="sidebar-sig font-tech">
|
||||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||||
<span className="sig-ver">{serverInfo?.version ?? 'v1.0.0'}</span>
|
<span className="sig-ver">{formatAppVersion(serverInfo?.version)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -837,6 +837,19 @@ describe('SystemStatusBar', () => {
|
|||||||
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives fleet counts from WebSocket agents, not REST listAgents', async () => {
|
||||||
|
const listAgentsSpy = vi.spyOn(api, 'listAgents');
|
||||||
|
render(
|
||||||
|
<MemoryRouter future={routerFuture}>
|
||||||
|
<SystemStatusBar />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(listAgentsSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FleetTopologyMap', () => {
|
describe('FleetTopologyMap', () => {
|
||||||
@@ -925,4 +938,32 @@ describe('Layout', () => {
|
|||||||
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mounts MatrixRain and CursorFire on Command Deck only', async () => {
|
||||||
|
const { container: deck } = render(
|
||||||
|
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
||||||
|
<Layout>
|
||||||
|
<div>deck</div>
|
||||||
|
</Layout>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
|
||||||
|
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const { container: crucible } = render(
|
||||||
|
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
|
||||||
|
<Layout>
|
||||||
|
<div>crucible</div>
|
||||||
|
</Layout>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('crucible')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
|
||||||
|
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
20
server/web/src/help/architectureDeferred.test.ts
Normal file
20
server/web/src/help/architectureDeferred.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const crucibleSrc = readFileSync(resolve(__dirname, '../pages/CruciblePage.tsx'), 'utf8');
|
||||||
|
const wsProviderSrc = readFileSync(resolve(__dirname, '../context/WebSocketProvider.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('architecture deferred (frontend)', () => {
|
||||||
|
it('CruciblePage remains monolithic until section split lands', () => {
|
||||||
|
expect(crucibleSrc.split('\n').length).toBeGreaterThan(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WebSocketProvider is still a single shared context', () => {
|
||||||
|
expect(wsProviderSrc).toContain('WebSocketContext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminal render cap documents virtualization ceiling', () => {
|
||||||
|
expect(crucibleSrc).toContain('visibleTerminalLines');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
server/web/src/help/authStoragePolicy.test.ts
Normal file
58
server/web/src/help/authStoragePolicy.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
AUTH_CROSS_TAB_STORAGE_SYNC,
|
||||||
|
AUTH_STORAGE_KEY,
|
||||||
|
AUTH_SYNC_EVENT,
|
||||||
|
describeAuthStoragePolicy,
|
||||||
|
} from './authStoragePolicy';
|
||||||
|
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||||
|
|
||||||
|
describe('authStoragePolicy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents dual storage with same-tab event sync only', () => {
|
||||||
|
expect(describeAuthStoragePolicy()).toMatch(/dual-write/);
|
||||||
|
expect(describeAuthStoragePolicy()).toMatch(/aetherforge-auth/);
|
||||||
|
expect(AUTH_CROSS_TAB_STORAGE_SYNC).toBe(false);
|
||||||
|
expect(AUTH_STORAGE_KEY).toBe('aetherforge_auth');
|
||||||
|
expect(AUTH_SYNC_EVENT).toBe('aetherforge-auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches aetherforge-auth on login for same-tab listeners', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener(AUTH_SYNC_EVENT, handler);
|
||||||
|
setStoredAuth('ops', 'secret');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
window.removeEventListener(AUTH_SYNC_EVENT, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches aetherforge-auth on logout for same-tab listeners', () => {
|
||||||
|
setStoredAuth('ops', 'secret');
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener(AUTH_SYNC_EVENT, handler);
|
||||||
|
clearStoredAuth();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
window.removeEventListener(AUTH_SYNC_EVENT, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sessionStorage auth when localStorage cleared externally (no cross-tab logout)', () => {
|
||||||
|
setStoredAuth('ops', 'secret');
|
||||||
|
const token = getStoredAuth();
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent('storage', {
|
||||||
|
key: AUTH_STORAGE_KEY,
|
||||||
|
oldValue: token,
|
||||||
|
newValue: null,
|
||||||
|
storageArea: localStorage,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(getStoredAuth()).toBe(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
server/web/src/help/authStoragePolicy.ts
Normal file
24
server/web/src/help/authStoragePolicy.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Cross-tab auth storage policy (document-only).
|
||||||
|
*
|
||||||
|
* Credentials mirror in sessionStorage and localStorage under `aetherforge_auth`.
|
||||||
|
* Reads prefer sessionStorage, then fall back to localStorage (survives tab close).
|
||||||
|
*
|
||||||
|
* Same-tab sync: login/logout dispatch the `aetherforge-auth` CustomEvent so SessionGate,
|
||||||
|
* WebSocketProvider, and PresenceContext re-read credentials without a full reload.
|
||||||
|
*
|
||||||
|
* Cross-tab: intentionally NOT synced via the `storage` event. Logout in tab A clears
|
||||||
|
* localStorage but tab B keeps in-memory session until refresh or a 401. Each tab owns
|
||||||
|
* its WS lifecycle after auth changes in that tab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AUTH_STORAGE_KEY = 'aetherforge_auth';
|
||||||
|
export const AUTH_EXPIRED_FLAG_KEY = 'aetherforge_auth_expired';
|
||||||
|
export const AUTH_SYNC_EVENT = 'aetherforge-auth';
|
||||||
|
|
||||||
|
/** By policy we never listen to cross-tab `storage` events for auth. */
|
||||||
|
export const AUTH_CROSS_TAB_STORAGE_SYNC = false;
|
||||||
|
|
||||||
|
export function describeAuthStoragePolicy(): string {
|
||||||
|
return 'dual-write session+local; same-tab aetherforge-auth; no storage-event cross-tab sync';
|
||||||
|
}
|
||||||
@@ -373,7 +373,10 @@ describe('getForgeFieldMeta', () => {
|
|||||||
|
|
||||||
describe('getForgeLiveNotices', () => {
|
describe('getForgeLiveNotices', () => {
|
||||||
it('returns empty array for a plain windows form', () => {
|
it('returns empty array for a plain windows form', () => {
|
||||||
const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false);
|
const notices = getForgeLiveNotices(
|
||||||
|
baseForm({ target_os: 'windows', spread_kit: false, miner_execution: 'inprocess' }),
|
||||||
|
false
|
||||||
|
);
|
||||||
expect(notices).toEqual([]);
|
expect(notices).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,4 +453,15 @@ describe('getForgeLiveNotices', () => {
|
|||||||
);
|
);
|
||||||
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
|
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns when container/auto execution needs worker image', () => {
|
||||||
|
const auto = getForgeLiveNotices(baseForm({ miner_execution: 'auto' }), false);
|
||||||
|
expect(auto.some((n) => n.includes('agent-worker'))).toBe(true);
|
||||||
|
|
||||||
|
const container = getForgeLiveNotices(baseForm({ miner_execution: 'container' }), false);
|
||||||
|
expect(container.some((n) => n.includes('Dockerfile.agent'))).toBe(true);
|
||||||
|
|
||||||
|
const inprocess = getForgeLiveNotices(baseForm({ miner_execution: 'inprocess' }), false);
|
||||||
|
expect(inprocess.some((n) => n.includes('agent-worker'))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -315,6 +315,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
max_memory_percent: { disabled: false, badge: 'baked' },
|
max_memory_percent: { disabled: false, badge: 'baked' },
|
||||||
min_free_ram_mb: { disabled: false, badge: 'baked' },
|
min_free_ram_mb: { disabled: false, badge: 'baked' },
|
||||||
mining_mode: { disabled: false, badge: 'baked' },
|
mining_mode: { disabled: false, badge: 'baked' },
|
||||||
|
miner_execution: {
|
||||||
|
disabled: false,
|
||||||
|
badge: 'baked',
|
||||||
|
hint:
|
||||||
|
'Auto/container tiers need a worker image on the host: docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest . Override tag with AETHERFORGE_MINER_IMAGE.',
|
||||||
|
},
|
||||||
idle_threshold_pct: {
|
idle_threshold_pct: {
|
||||||
disabled: !isIdle,
|
disabled: !isIdle,
|
||||||
badge: 'requires',
|
badge: 'requires',
|
||||||
@@ -627,6 +633,12 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
|
|||||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||||
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
|
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
|
||||||
}
|
}
|
||||||
|
const execMode = form.miner_execution ?? 'auto';
|
||||||
|
if (execMode === 'auto' || execMode === 'container') {
|
||||||
|
notices.push(
|
||||||
|
'Container/auto execution needs aetherforge/agent-worker:latest on the operator host — build with docker/Dockerfile.agent (set AETHERFORGE_MINER_IMAGE to override).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return notices;
|
return notices;
|
||||||
}
|
}
|
||||||
|
|||||||
17
server/web/src/help/routeEffects.test.ts
Normal file
17
server/web/src/help/routeEffects.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isDashboardRoute } from './routeEffects';
|
||||||
|
|
||||||
|
describe('isDashboardRoute', () => {
|
||||||
|
it('matches Command Deck paths', () => {
|
||||||
|
expect(isDashboardRoute('/dashboard')).toBe(true);
|
||||||
|
expect(isDashboardRoute('/dashboard/')).toBe(true);
|
||||||
|
expect(isDashboardRoute('/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects other operator deck routes', () => {
|
||||||
|
expect(isDashboardRoute('/crucible')).toBe(false);
|
||||||
|
expect(isDashboardRoute('/forge')).toBe(false);
|
||||||
|
expect(isDashboardRoute('/settings')).toBe(false);
|
||||||
|
expect(isDashboardRoute('/pathtracer')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
server/web/src/help/routeEffects.ts
Normal file
5
server/web/src/help/routeEffects.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** True when pathname is Command Deck (`/dashboard` or `/`). */
|
||||||
|
export function isDashboardRoute(pathname: string): boolean {
|
||||||
|
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||||
|
return path === '/dashboard' || path === '/';
|
||||||
|
}
|
||||||
13
server/web/src/help/terminalRenderCap.test.ts
Normal file
13
server/web/src/help/terminalRenderCap.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TERM_RENDER_CAP, visibleTerminalLines } from './terminalRenderCap';
|
||||||
|
|
||||||
|
describe('terminalRenderCap', () => {
|
||||||
|
it('renders only the newest window at cap', () => {
|
||||||
|
const lines = Array.from({ length: TERM_RENDER_CAP + 25 }, (_, i) => `line-${i}`);
|
||||||
|
const { visible, truncated, hidden } = visibleTerminalLines(lines);
|
||||||
|
expect(visible).toHaveLength(TERM_RENDER_CAP);
|
||||||
|
expect(visible[0]).toBe('line-25');
|
||||||
|
expect(truncated).toBe(true);
|
||||||
|
expect(hidden).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
server/web/src/help/terminalRenderCap.ts
Normal file
17
server/web/src/help/terminalRenderCap.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** Max terminal lines rendered in the DOM; full history stays in state for export. */
|
||||||
|
export const TERM_RENDER_CAP = 400;
|
||||||
|
|
||||||
|
export function visibleTerminalLines<T>(lines: T[]): {
|
||||||
|
visible: T[];
|
||||||
|
truncated: boolean;
|
||||||
|
hidden: number;
|
||||||
|
} {
|
||||||
|
if (lines.length <= TERM_RENDER_CAP) {
|
||||||
|
return { visible: lines, truncated: false, hidden: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
visible: lines.slice(-TERM_RENDER_CAP),
|
||||||
|
truncated: true,
|
||||||
|
hidden: lines.length - TERM_RENDER_CAP,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import AlsoHere from '../components/Presence/AlsoHere';
|
|||||||
import { HelpTip } from '../components/HelpTip';
|
import { HelpTip } from '../components/HelpTip';
|
||||||
import '../components/Fleet/FullSysCheckPanel.css';
|
import '../components/Fleet/FullSysCheckPanel.css';
|
||||||
import '../components/Fleet/FleetToolbar.css';
|
import '../components/Fleet/FleetToolbar.css';
|
||||||
|
import { TERM_RENDER_CAP, visibleTerminalLines } from '../help/terminalRenderCap';
|
||||||
import './CruciblePage.css';
|
import './CruciblePage.css';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -326,9 +327,6 @@ if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
|
|||||||
|
|
||||||
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
|
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
|
||||||
|
|
||||||
/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */
|
|
||||||
const TERM_RENDER_CAP = 400;
|
|
||||||
|
|
||||||
/** Roster page size — avoids rendering 500+ node cards at once. */
|
/** Roster page size — avoids rendering 500+ node cards at once. */
|
||||||
const ROSTER_PAGE_SIZE = 80;
|
const ROSTER_PAGE_SIZE = 80;
|
||||||
|
|
||||||
@@ -427,11 +425,10 @@ export default function CruciblePage() {
|
|||||||
|
|
||||||
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
|
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
|
||||||
|
|
||||||
const visibleTermLines = useMemo(
|
const { visible: visibleTermLines, truncated: termTruncated, hidden: termHidden } = useMemo(
|
||||||
() => (termLines.length > TERM_RENDER_CAP ? termLines.slice(-TERM_RENDER_CAP) : termLines),
|
() => visibleTerminalLines(termLines),
|
||||||
[termLines],
|
[termLines],
|
||||||
);
|
);
|
||||||
const termTruncated = termLines.length > TERM_RENDER_CAP;
|
|
||||||
|
|
||||||
const dispatchTunnelCommand = useCallback(
|
const dispatchTunnelCommand = useCallback(
|
||||||
async (action: string, args?: Record<string, unknown>) => {
|
async (action: string, args?: Record<string, unknown>) => {
|
||||||
@@ -1548,7 +1545,7 @@ export default function CruciblePage() {
|
|||||||
)}
|
)}
|
||||||
{termTruncated && (
|
{termTruncated && (
|
||||||
<div className="crucible-term-truncated" style={{ opacity: 0.55, fontSize: '0.72rem', padding: '0.2rem 0' }}>
|
<div className="crucible-term-truncated" style={{ opacity: 0.55, fontSize: '0.72rem', padding: '0.2rem 0' }}>
|
||||||
… {termLines.length - TERM_RENDER_CAP} older lines hidden (CLEAR to reset)
|
… {termHidden} older lines hidden (CLEAR to reset)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleTermLines.map((line) => {
|
{visibleTermLines.map((line) => {
|
||||||
|
|||||||
@@ -127,7 +127,19 @@ describe('EmberwakePage', () => {
|
|||||||
});
|
});
|
||||||
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
|
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
|
||||||
expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
||||||
// War room loads once on mount; hashrate telemetry comes from WS stats_batch (no poll loop).
|
// War room loads once on mount; live funnel uses WS emberwake_war_room (no poll loop).
|
||||||
expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not poll war room on an interval', async () => {
|
||||||
|
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
|
||||||
|
renderEmberwake();
|
||||||
|
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(warRoomSpy.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
const initialCalls = warRoomSpy.mock.calls.length;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(warRoomSpy.mock.calls.length).toBe(initialCalls);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type { Agent, AgentService } from '../types';
|
|||||||
*/
|
*/
|
||||||
export interface WSDashboardInit {
|
export interface WSDashboardInit {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSAgentOffline {
|
export interface WSAgentOffline {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ One command runs everything:
|
|||||||
test.bat
|
test.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
From repo root with GNU Make (same as `test.bat`):
|
||||||
|
|
||||||
|
```make
|
||||||
|
make test
|
||||||
|
make test-quick # -SkipE2E
|
||||||
|
make test-fast # -SkipE2E -SkipBuild
|
||||||
|
```
|
||||||
|
|
||||||
Or with PowerShell directly:
|
Or with PowerShell directly:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
Reference in New Issue
Block a user