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:
AetherForge
2026-06-07 06:33:51 -07:00
parent 85d55df37c
commit 74c006a04b
35 changed files with 1144 additions and 36 deletions

13
Makefile Normal file
View 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 17b 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

View File

@@ -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. |
| 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)
| Item | Action |

View File

@@ -1013,6 +1013,19 @@ func (c *AgentClient) stratumEgress(stratumOverlay bool) string {
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{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
@@ -1034,6 +1047,12 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
case <-ticker.C:
hps := c.pool.HashesPerSecond()
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)
if len(samples) > 90 {
samples = samples[len(samples)-90:]

View File

@@ -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) {
in := StatsPayload{
Hashrate15s: 1, Hashrate1m: 1, Hashrate15m: 1,

View File

@@ -2,6 +2,7 @@ package miner
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
@@ -15,6 +16,17 @@ import (
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.
var containerExecCommand = exec.Command
@@ -170,6 +182,39 @@ func (l *ContainerLauncher) Running() bool {
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) {
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
var buf bytes.Buffer
@@ -234,6 +279,7 @@ func (l *ContainerLauncher) containerEnv() []string {
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
"MINER_LOG_FILE": "/tmp/miner.log",
"MINER_STATS_FILE": ContainerStatsPath,
}
if l.cfg.RVNWallet != "" {
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet

View File

@@ -124,6 +124,7 @@ func TestContainerLauncherStartWithFakeRuntime(t *testing.T) {
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
"MINER_LOG_FILE": "/tmp/miner.log",
"MINER_STATS_FILE": ContainerStatsPath,
}
for k, want := range wantEnv {
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) {
var gotArgs []string
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {

View File

@@ -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) {
attempts := 0
hooks := ChainHooks{

View 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")
}
})
}

View 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
}

View File

@@ -102,10 +102,112 @@ func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
sessions: make(map[string]*TraceSession),
stopCh: make(chan struct{}),
}
h.loadPersistedSessions()
go h.sessionCleanupLoop()
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() {
ticker := time.NewTicker(pathTraceCleanupInterval)
defer ticker.Stop()
@@ -133,6 +235,7 @@ func (h *PathTracerHandler) expireSessions() {
for _, sess := range expired {
log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL)
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.sessions[sess.ID] = sess
h.mu.Unlock()
h.persistSession(sess)
// Orchestrate asynchronously so the HTTP response returns quickly.
go h.orchestrate(sess)
@@ -301,6 +405,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
delete(h.sessions, id)
h.mu.Unlock()
h.deletePersistedSession(id)
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)
h.persistSession(sess)
}
func (h *PathTracerHandler) teardownHops(hops []*HopInfo) {

View 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)
}
}

View File

@@ -1,6 +1,9 @@
{
"WSDashboardInit": [
"agents"
"agents",
"limit",
"offset",
"total"
],
"WSAgentOffline": [
"agent_id"

View File

@@ -241,8 +241,7 @@ func (h *WSHub) runStaleAgentSweep() {
if h.db == nil {
return
}
const staleness = 3 * time.Minute
ticker := time.NewTicker(45 * time.Second)
ticker := time.NewTicker(StaleAgentSweepInterval)
defer ticker.Stop()
for range ticker.C {
// Only sweep agents that are NOT currently connected in memory.
@@ -254,15 +253,12 @@ func (h *WSHub) runStaleAgentSweep() {
}
h.mu.RUnlock()
agents, err := h.db.ListAgents()
agents, err := h.db.ListStaleOnlineAgents(StaleAgentThreshold)
if err != nil {
continue
}
for _, a := range agents {
if a == nil || a.Status != "online" || liveIDs[a.ID] {
continue
}
if time.Since(a.LastSeen) < staleness {
if a == nil || liveIDs[a.ID] {
continue
}
// 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
// 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)
for _, a := range agents {
if a == nil {
@@ -1687,10 +1691,16 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
}
stats, _ := h.db.GetFleetStats()
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
initPayload := map[string]interface{}{
"agents": agents,
"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{}{
"comrades": h.presenceSnapshotLocked(),
})})
@@ -1733,14 +1743,17 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
continue
}
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
// 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 {
var base, patch map[string]interface{}
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.cacheAgentTelemetry(agentID, payload)
if h.statsBatchTimer == nil {
h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch)
h.statsBatchTimer = time.AfterFunc(StatsBatchCoalesceInterval, h.flushStatsBatch)
}
h.statsBatchMu.Unlock()
}

View File

@@ -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) {
t.Helper()
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
// without racing flushStatsBatch clearing the pending map.
func stopStatsBatchTimer(hub *WSHub) {

View File

@@ -6,6 +6,9 @@ package api
type WSDashboardInit struct {
Agents []interface{} `json:"agents"`
Total int `json:"total,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
type WSAgentOffline struct {

View 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
}

View 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)
}
}

View File

@@ -241,6 +241,12 @@ func (d *Database) migrate() error {
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`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 {
if _, err := d.Exec(m); err != nil {
@@ -252,6 +258,7 @@ func (d *Database) migrate() error {
scaleIndexes := []string{
`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_status_last_seen ON agents(status, last_seen)`,
`CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`,
}
for _, m := range scaleIndexes {

View File

@@ -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) {
d := openTestDB(t)
a := seedAgent(t, d, "persist-created")

View File

@@ -77,4 +77,14 @@ describe('auth session helpers', () => {
expect(consumeAuthExpiredFlag()).toBe(true);
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);
});
});

View File

@@ -12,10 +12,12 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { isDashboardRoute } from '../../help/routeEffects';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import { formatAppVersion } from '../../help/appVersion';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
import './Layout.css';
@@ -280,6 +282,7 @@ export default function Layout({ children }: LayoutProps) {
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const pageWeather = resolvePageWeather(location.pathname);
const showDeckEffects = isDashboardRoute(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
@@ -296,7 +299,7 @@ export default function Layout({ children }: LayoutProps) {
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && glowParticles && <CursorFire />}
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
<AmbientBackground weather={pageWeather} />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">
@@ -348,8 +351,8 @@ export default function Layout({ children }: LayoutProps) {
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
{/* Matrix rain log — Command Deck only; fills lower sidebar between nav and footer */}
{showDeckEffects && <MatrixRain />}
<div className="sidebar-footer">
<FleetReadout />
@@ -367,7 +370,7 @@ export default function Layout({ children }: LayoutProps) {
)}
<div className="sidebar-sig font-tech">
<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>
</nav>

View File

@@ -837,6 +837,19 @@ describe('SystemStatusBar', () => {
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', () => {
@@ -925,4 +938,32 @@ describe('Layout', () => {
expect(screen.getByRole('link', { name: /Crucible/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();
});
});

View 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');
});
});

View 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);
});
});

View 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';
}

View File

@@ -373,7 +373,10 @@ describe('getForgeFieldMeta', () => {
describe('getForgeLiveNotices', () => {
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([]);
});
@@ -450,4 +453,15 @@ describe('getForgeLiveNotices', () => {
);
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);
});
});

View File

@@ -315,6 +315,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
max_memory_percent: { disabled: false, badge: 'baked' },
min_free_ram_mb: { 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: {
disabled: !isIdle,
badge: 'requires',
@@ -627,6 +633,12 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
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;
}

View 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);
});
});

View 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 === '/';
}

View 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);
});
});

View 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,
};
}

View File

@@ -35,6 +35,7 @@ import AlsoHere from '../components/Presence/AlsoHere';
import { HelpTip } from '../components/HelpTip';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/FleetToolbar.css';
import { TERM_RENDER_CAP, visibleTerminalLines } from '../help/terminalRenderCap';
import './CruciblePage.css';
// ── 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`;
/** 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. */
const ROSTER_PAGE_SIZE = 80;
@@ -427,11 +425,10 @@ export default function CruciblePage() {
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
const visibleTermLines = useMemo(
() => (termLines.length > TERM_RENDER_CAP ? termLines.slice(-TERM_RENDER_CAP) : termLines),
const { visible: visibleTermLines, truncated: termTruncated, hidden: termHidden } = useMemo(
() => visibleTerminalLines(termLines),
[termLines],
);
const termTruncated = termLines.length > TERM_RENDER_CAP;
const dispatchTunnelCommand = useCallback(
async (action: string, args?: Record<string, unknown>) => {
@@ -1548,7 +1545,7 @@ export default function CruciblePage() {
)}
{termTruncated && (
<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>
)}
{visibleTermLines.map((line) => {

View File

@@ -127,7 +127,19 @@ describe('EmberwakePage', () => {
});
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
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);
});
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);
});
});

View File

@@ -7,6 +7,9 @@ import type { Agent, AgentService } from '../types';
*/
export interface WSDashboardInit {
agents: Agent[];
total?: number;
limit?: number;
offset?: number;
}
export interface WSAgentOffline {

View File

@@ -29,6 +29,14 @@ One command runs everything:
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:
```powershell