Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.
Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
This commit is contained in:
736
server/web/package-lock.json
generated
736
server/web/package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "crypto-miner-dashboard",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
@@ -18,7 +20,8 @@
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1273,6 +1276,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1280,6 +1292,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
|
||||
@@ -1322,6 +1343,153 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
||||
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
|
||||
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
||||
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.9",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
||||
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
||||
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^3.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
||||
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
@@ -1369,6 +1537,25 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
@@ -1390,6 +1577,44 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1399,6 +1624,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1551,12 +1794,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -1574,6 +1842,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1623,12 +1904,32 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
@@ -1638,6 +1939,19 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1663,6 +1977,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -1672,6 +1995,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1704,6 +2036,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -1722,6 +2066,13 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1732,6 +2083,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1777,6 +2138,68 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1784,6 +2207,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -1830,6 +2262,23 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1967,6 +2416,21 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
@@ -2031,6 +2495,19 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2041,12 +2518,96 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
|
||||
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
||||
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2061,6 +2622,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -2174,12 +2741,179 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.3.7",
|
||||
"es-module-lexer": "^1.5.4",
|
||||
"pathe": "^1.1.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.8.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
@@ -19,6 +22,7 @@
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import DashboardPage from './pages/DashboardPage';
|
||||
import AgentsPage from './pages/AgentsPage';
|
||||
import BuilderPage from './pages/BuilderPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import GuidePage from './pages/GuidePage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -12,7 +13,9 @@ function App() {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/builder" element={<BuilderPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse, ServerInfo } from '../types';
|
||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
@@ -15,12 +15,8 @@ async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Dashboard
|
||||
getStats: () => fetchJSON<FleetStats>('/dashboard/stats'),
|
||||
|
||||
// Agents
|
||||
listAgents: () => fetchJSON<Agent[]>('/agents'),
|
||||
getAgent: (id: string) => fetchJSON<Agent>(`/agents/${id}`),
|
||||
getAgentStats: (id: string, limit?: number) =>
|
||||
fetchJSON<HashrateSample[]>(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`),
|
||||
|
||||
@@ -62,9 +58,37 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
|
||||
|
||||
// Blueprints (config presets)
|
||||
listBlueprints: () => fetchJSON<BlueprintInfo[]>('/blueprints'),
|
||||
getBlueprint: (name: string) => fetchJSON<any>(`/blueprints/${encodeURIComponent(name)}`),
|
||||
saveBlueprint: (name: string, data: any) =>
|
||||
fetchJSON<{ success: boolean; name: string; file_path: string; created_at: string }>('/blueprints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, data }),
|
||||
}),
|
||||
deleteBlueprint: (name: string) =>
|
||||
fetchJSON<{ success: string; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Health / server
|
||||
healthCheck: () => fetchJSON<{ status: string }>('/health'),
|
||||
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
|
||||
|
||||
// Fleet ops
|
||||
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
|
||||
getPoolStatus: () => fetchJSON<PoolStatus[]>('/pools/status'),
|
||||
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
|
||||
getEarningsEstimate: (hashrate: number) =>
|
||||
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
|
||||
sendAgentCommand: (id: string, action: string, tailLines?: number) =>
|
||||
fetchJSON<{ success: boolean }>(`/agents/${id}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, tail_lines: tailLines }),
|
||||
}),
|
||||
getAgentLog: (id: string, refresh = false) =>
|
||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
||||
};
|
||||
|
||||
49
server/web/src/components/Fleet/AgentRemoteActions.css
Normal file
49
server/web/src/components/Fleet/AgentRemoteActions.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.agent-remote.compact {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(0, 245, 255, 0.12);
|
||||
}
|
||||
|
||||
.agent-remote-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agent-action-btn.warn {
|
||||
border-color: rgba(255, 176, 32, 0.5);
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
}
|
||||
|
||||
.agent-action-btn.warn:hover {
|
||||
background: rgba(255, 176, 32, 0.22);
|
||||
}
|
||||
|
||||
.agent-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-remote-feedback {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.agent-remote-feedback.ok {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.agent-remote-feedback.bad {
|
||||
color: #ff3c50;
|
||||
}
|
||||
|
||||
.agent-list-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-list-actions .agent-action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
119
server/web/src/components/Fleet/AgentRemoteActions.tsx
Normal file
119
server/web/src/components/Fleet/AgentRemoteActions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent } from '../../types';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
type AgentCommandAction = 'pause' | 'resume' | 'restart' | 'stop' | 'uninstall' | 'get_log';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
compact?: boolean;
|
||||
onCommandSent?: (action: string, message: string) => void;
|
||||
}
|
||||
|
||||
function useAgentCommand() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ agentId: string; message: string; ok: boolean } | null>(null);
|
||||
|
||||
const runCommand = useCallback(async (agent: Agent, action: AgentCommandAction) => {
|
||||
if (agent.status !== 'online') {
|
||||
setFeedback({ agentId: agent.id, message: 'Agent is offline', ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
if (!confirm(`Stop miner on "${agent.name}"?\n\nMining halts and the process exits. It will restart if persistence is enabled.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action === 'uninstall') {
|
||||
if (!confirm(`Uninstall miner from "${agent.name}"?\n\nRemoves the process, persistence, scheduled task, and install folder from that PC.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(`${agent.id}:${action}`);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await api.sendAgentCommand(agent.id, action);
|
||||
const msg =
|
||||
action === 'stop' ? 'Stop command sent — miner shutting down…' :
|
||||
action === 'uninstall' ? 'Uninstall sent — removing miner from machine…' :
|
||||
`${action} command sent`;
|
||||
setFeedback({ agentId: agent.id, message: msg, ok: true });
|
||||
return msg;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Command failed';
|
||||
setFeedback({ agentId: agent.id, message, ok: false });
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { runCommand, busy, feedback, setFeedback };
|
||||
}
|
||||
|
||||
export default function AgentRemoteActions({ agent, compact = false, onCommandSent }: Props) {
|
||||
const { runCommand, busy, feedback } = useAgentCommand();
|
||||
const online = agent.status === 'online';
|
||||
const isBusy = busy?.startsWith(`${agent.id}:`);
|
||||
|
||||
const send = async (action: AgentCommandAction) => {
|
||||
try {
|
||||
const msg = await runCommand(agent, action);
|
||||
if (msg && onCommandSent) onCommandSent(action, msg);
|
||||
} catch {
|
||||
/* feedback set in hook */
|
||||
}
|
||||
};
|
||||
|
||||
const localFeedback = feedback?.agentId === agent.id ? feedback : null;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="agent-remote compact">
|
||||
<div className="agent-remote-row">
|
||||
<button
|
||||
type="button"
|
||||
className="agent-action-btn warn"
|
||||
disabled={!online || isBusy}
|
||||
onClick={() => send('stop')}
|
||||
title="Stop mining process on this PC"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-action-btn danger"
|
||||
disabled={!online || isBusy}
|
||||
onClick={() => send('uninstall')}
|
||||
title="Remove miner completely from this PC"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
{localFeedback && (
|
||||
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-remote">
|
||||
<p className="form-hint">Control this node from the dashboard — no RDP needed. Agent must be online.</p>
|
||||
<div className="agent-actions">
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('pause')}>Pause mining</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!online || isBusy} onClick={() => send('stop')}>Stop miner</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('restart')}>Restart</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('get_log')}>Fetch log</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!online || isBusy} onClick={() => send('uninstall')}>Uninstall from PC</button>
|
||||
</div>
|
||||
{localFeedback && (
|
||||
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
server/web/src/components/Fleet/FleetPanels.css
Normal file
200
server/web/src/components/Fleet/FleetPanels.css
Normal file
@@ -0,0 +1,200 @@
|
||||
.alert-banner-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 176, 32, 0.35);
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-banner.alert-error {
|
||||
border-color: rgba(255, 60, 80, 0.45);
|
||||
background: rgba(255, 60, 80, 0.1);
|
||||
}
|
||||
|
||||
.alert-type {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.85;
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.alert-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pool-status-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pool-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid rgba(0, 245, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stratum-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stratum-dot.green {
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.stratum-dot.yellow {
|
||||
background: var(--neon-amber);
|
||||
box-shadow: 0 0 8px var(--neon-amber);
|
||||
}
|
||||
|
||||
.stratum-dot.red {
|
||||
background: #ff3c50;
|
||||
box-shadow: 0 0 8px #ff3c50;
|
||||
}
|
||||
|
||||
.pool-status-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pool-status-label.green { color: var(--neon-green); }
|
||||
.pool-status-label.yellow { color: var(--neon-amber); }
|
||||
.pool-status-label.red { color: #ff3c50; }
|
||||
|
||||
.ai-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.ai-activity-row {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid rgba(180, 100, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ai-activity-detail {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ai-activity-detail .ok { color: var(--neon-green); }
|
||||
.ai-activity-detail .bad { color: #ff3c50; }
|
||||
|
||||
.ai-reasoning {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.75;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.earnings-estimator {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-action-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid rgba(0, 245, 255, 0.35);
|
||||
background: rgba(0, 245, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-action-btn:hover {
|
||||
background: rgba(0, 245, 255, 0.18);
|
||||
}
|
||||
|
||||
.agent-action-btn.danger {
|
||||
border-color: rgba(255, 60, 80, 0.45);
|
||||
background: rgba(255, 60, 80, 0.1);
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
margin-top: 1rem;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(0, 245, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.build-manager-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.build-manager-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid rgba(255, 176, 32, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.blueprint-diff {
|
||||
font-size: 0.8rem;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.blueprint-diff li.added { color: var(--neon-green); }
|
||||
.blueprint-diff li.removed { color: #ff3c50; }
|
||||
.blueprint-diff li.changed { color: var(--neon-amber); }
|
||||
|
||||
.qr-wrap {
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
110
server/web/src/components/Fleet/FleetPanels.tsx
Normal file
110
server/web/src/components/Fleet/FleetPanels.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import type { FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
|
||||
import './FleetPanels.css';
|
||||
|
||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||
if (alerts.length === 0) return null;
|
||||
return (
|
||||
<div className="alert-banner-stack">
|
||||
{alerts.slice(0, 5).map((a) => (
|
||||
<div key={a.id} className={`alert-banner alert-${a.level}`}>
|
||||
<span className="alert-type font-tech">{a.type.replace(/_/g, ' ').toUpperCase()}</span>
|
||||
<span className="alert-msg">{a.message}</span>
|
||||
<span className="alert-time font-tech">
|
||||
{a.timestamp ? new Date(a.timestamp).toLocaleTimeString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
|
||||
return (
|
||||
<NeonCard accent="green" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Pool Stratum Status
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{pools.length === 0 ? (
|
||||
<p className="form-hint">No forged pool connections yet — agents connect upstream on auth.</p>
|
||||
) : (
|
||||
<div className="pool-status-grid">
|
||||
{pools.map((p) => (
|
||||
<div key={p.key} className={`pool-status-item status-${p.status}`}>
|
||||
<span className={`stratum-dot ${p.status}`} title={p.connected ? 'Connected' : 'Disconnected'} />
|
||||
<div className="pool-status-meta">
|
||||
<strong>{p.host}:{p.port}</strong>
|
||||
<span className="mono-sm">{p.use_tls ? 'TLS' : 'TCP'} · {p.wallet}…</span>
|
||||
</div>
|
||||
<span className={`pool-status-label font-tech ${p.status}`}>
|
||||
{p.status === 'green' ? 'LIVE' : p.status === 'yellow' ? 'DEGRADED' : 'DOWN'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEntry[]; agentNames: Record<string, string> }) {
|
||||
return (
|
||||
<NeonCard accent="purple" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> AI Activity Monitor
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{entries.length === 0 ? (
|
||||
<p className="form-hint">No Ollama decide cycles yet — enable AI on a forged miner with Ollama running on this PC.</p>
|
||||
) : (
|
||||
<div className="ai-activity-list">
|
||||
{entries.map((e) => (
|
||||
<div key={e.agent_id} className="ai-activity-row">
|
||||
<div>
|
||||
<strong>{agentNames[e.agent_id] || e.agent_id.slice(0, 8)}</strong>
|
||||
<span className="mono-sm"> · {e.last_tool || e.last_action || 'idle'}</span>
|
||||
</div>
|
||||
<div className="ai-activity-detail">
|
||||
<span>Decide: {e.last_decide_at ? new Date(e.last_decide_at).toLocaleTimeString() : '—'}</span>
|
||||
<span>Tools: {e.tool_call_count ?? 0}</span>
|
||||
<span className={e.last_success ? 'ok' : 'bad'}>
|
||||
{e.last_report_at ? new Date(e.last_report_at).toLocaleTimeString() : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{e.last_reasoning && <p className="ai-reasoning">{e.last_reasoning}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
const [xmrPerDay, setXmrPerDay] = useState<number | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (hashrate <= 0) {
|
||||
setXmrPerDay(null);
|
||||
return;
|
||||
}
|
||||
api.getEarningsEstimate(hashrate).then((r) => {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}).catch(console.error);
|
||||
}, [hashrate]);
|
||||
|
||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
||||
|
||||
return (
|
||||
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
|
||||
<div className="stat-label font-tech">Earnings Estimate</div>
|
||||
<div className="stat-value neon-glow-amber">~{xmrPerDay.toFixed(6)} XMR/day</div>
|
||||
<div className="stat-sub">{note}</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
19
server/web/src/components/Fleet/LanDownloadQR.tsx
Normal file
19
server/web/src/components/Fleet/LanDownloadQR.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export function LanDownloadQR({ url }: { url: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !url) return;
|
||||
QRCode.toCanvas(canvasRef.current, url, { width: 120, margin: 1 }).catch(console.error);
|
||||
}, [url]);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<div className="qr-wrap" title={url}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
server/web/src/components/Forge/ForgeFieldHints.tsx
Normal file
46
server/web/src/components/Forge/ForgeFieldHints.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ForgeFieldMeta } from '../../help/forgeRules';
|
||||
import { forgeBadgeLabel } from '../../help/forgeRules';
|
||||
|
||||
interface ForgeLockedHintProps {
|
||||
meta?: ForgeFieldMeta;
|
||||
}
|
||||
|
||||
export function ForgeLockedHint({ meta }: ForgeLockedHintProps) {
|
||||
if (!meta?.lockedReason && !meta?.hint) return null;
|
||||
return (
|
||||
<p className="forge-locked-hint" title={meta.lockedReason || meta.hint}>
|
||||
🔒 {meta.lockedReason || meta.hint}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForgeFieldBadgeProps {
|
||||
meta?: ForgeFieldMeta;
|
||||
}
|
||||
|
||||
export function ForgeFieldBadge({ meta }: ForgeFieldBadgeProps) {
|
||||
if (!meta?.badge) return null;
|
||||
return (
|
||||
<span className={`forge-field-badge forge-badge-${meta.badge}`} title={forgeBadgeLabel(meta.badge)}>
|
||||
{meta.badge === 'baked' ? '⛏ baked' : meta.badge === 'server-only' ? '🖥 server' : '↳ if enabled'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForgeSectionHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
badge: 'baked' | 'server-only';
|
||||
}
|
||||
|
||||
export function ForgeSectionHeader({ title, description, badge }: ForgeSectionHeaderProps) {
|
||||
return (
|
||||
<div className="forge-section-header">
|
||||
<div className="forge-section-title-row">
|
||||
<h3>{title}</h3>
|
||||
<span className={`forge-field-badge forge-badge-${badge}`}>{forgeBadgeLabel(badge)}</span>
|
||||
</div>
|
||||
<p className="form-hint forge-section-desc">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +190,19 @@
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.main-with-status {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-with-status .main-content {
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
@@ -225,6 +238,10 @@
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.main-with-status {
|
||||
margin-left: 72px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 72px;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -10,7 +11,8 @@ interface LayoutProps {
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/builder', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
] as const;
|
||||
|
||||
@@ -37,6 +39,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 16l-2 4 4-2" />
|
||||
</svg>
|
||||
);
|
||||
case 'guide':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -94,7 +104,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
<div className="main-with-status">
|
||||
<SystemStatusBar />
|
||||
<main className="main-content">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
server/web/src/components/Visual/SystemStatusBar.tsx
Normal file
59
server/web/src/components/Visual/SystemStatusBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import './VisualComponents.css';
|
||||
|
||||
export default function SystemStatusBar() {
|
||||
const [serverOk, setServerOk] = useState(true);
|
||||
const [agentTotal, setAgentTotal] = useState(0);
|
||||
const [agentOnline, setAgentOnline] = useState(0);
|
||||
const [buildCount, setBuildCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
await api.healthCheck();
|
||||
setServerOk(true);
|
||||
} catch {
|
||||
setServerOk(false);
|
||||
}
|
||||
try {
|
||||
const agents = await api.listAgents();
|
||||
setAgentTotal(agents.length);
|
||||
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
||||
} catch {
|
||||
setAgentTotal(0);
|
||||
setAgentOnline(0);
|
||||
}
|
||||
try {
|
||||
const builds = await api.listBuilds();
|
||||
setBuildCount(builds.length);
|
||||
} catch {
|
||||
setBuildCount(0);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="system-status-bar">
|
||||
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
||||
<span className="status-pill-dot" />
|
||||
SERVER {serverOk ? 'UP' : 'DOWN'}
|
||||
</span>
|
||||
<span className={`status-pill ${agentOnline > 0 ? 'ok' : agentTotal > 0 ? 'warn' : ''}`}>
|
||||
<span className="status-pill-dot" />
|
||||
FLEET {agentOnline}/{agentTotal} ONLINE
|
||||
</span>
|
||||
<span className={`status-pill ${buildCount > 0 ? 'ok' : 'warn'}`}>
|
||||
<span className="status-pill-dot" />
|
||||
{buildCount} BUILD{buildCount === 1 ? '' : 'S'}
|
||||
</span>
|
||||
<Link to="/guide" className="status-pill" style={{ marginLeft: 'auto', textDecoration: 'none', color: 'var(--neon-cyan)' }}>
|
||||
📖 GUIDE
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
server/web/src/components/Visual/VisualComponents.css
Normal file
371
server/web/src/components/Visual/VisualComponents.css
Normal file
@@ -0,0 +1,371 @@
|
||||
/* Visual pipeline, activity, guide components */
|
||||
|
||||
.pipeline-flow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.pipeline-step-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.pipeline-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pipeline-step.pipeline-active {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15);
|
||||
}
|
||||
|
||||
.pipeline-icon {
|
||||
font-size: 1.5rem;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(201, 162, 39, 0.15);
|
||||
border: 1px solid rgba(201, 162, 39, 0.35);
|
||||
}
|
||||
|
||||
.pipeline-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.pipeline-text strong {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pipeline-text span {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-tech);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.pipeline-connector {
|
||||
width: 1.5rem;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--brass), var(--neon-cyan));
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pipeline-compact .pipeline-step {
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.fleet-pipeline-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.fleet-pipeline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fleet-pipeline-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.fleet-pipeline-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fleet-pipeline-node.ok .fleet-pipeline-dot {
|
||||
background: var(--neon-green);
|
||||
border-color: var(--neon-green);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.6);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fleet-pipeline-node.pending .fleet-pipeline-dot {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
|
||||
.fleet-pipeline-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fleet-pipeline-node.ok .fleet-pipeline-label {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.fleet-pipeline-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin: 0 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.fleet-pipeline-line.lit {
|
||||
background: linear-gradient(90deg, var(--neon-green), rgba(74, 222, 128, 0.3));
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.15); opacity: 0.85; }
|
||||
}
|
||||
|
||||
.activity-pulse {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.activity-blip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.activity-blip-core {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.activity-blip.ok .activity-blip-core {
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.activity-blip.bad .activity-blip-core {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 8px #f87171;
|
||||
}
|
||||
|
||||
.activity-blip-label {
|
||||
font-size: 0.55rem;
|
||||
font-family: var(--font-tech);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.activity-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.compare-card h4 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.compare-card ul {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.roadmap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.roadmap-card {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.roadmap-card h4 {
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.roadmap-card p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.roadmap-priority {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.roadmap-high .roadmap-priority {
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
.roadmap-medium .roadmap-priority {
|
||||
color: var(--neon-amber);
|
||||
border: 1px solid rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
|
||||
.roadmap-low .roadmap-priority {
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid rgba(0, 245, 255, 0.35);
|
||||
}
|
||||
|
||||
.guide-step-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.guide-step-num {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid var(--brass);
|
||||
color: var(--neon-amber);
|
||||
background: rgba(201, 162, 39, 0.1);
|
||||
}
|
||||
|
||||
.guide-step-body h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.guide-step-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.guide-tips {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.trouble-card {
|
||||
padding: 0.85rem 1rem;
|
||||
border-left: 3px solid var(--neon-amber);
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.trouble-card strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
.trouble-card span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.system-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(8, 6, 4, 0.85);
|
||||
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-pill.ok .status-pill-dot {
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 6px var(--neon-green);
|
||||
}
|
||||
|
||||
.status-pill.warn .status-pill-dot {
|
||||
background: var(--neon-amber);
|
||||
}
|
||||
|
||||
.status-pill.bad .status-pill-dot {
|
||||
background: #f87171;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
137
server/web/src/components/Visual/VisualComponents.tsx
Normal file
137
server/web/src/components/Visual/VisualComponents.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import {
|
||||
FORGE_VS_CALIBRATE,
|
||||
PIPELINE_STEPS,
|
||||
ROADMAP_FEATURES,
|
||||
} from '../../help/cheatSheetContent';
|
||||
import './VisualComponents.css';
|
||||
|
||||
interface PipelineFlowProps {
|
||||
activeStep?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PipelineFlow({ activeStep, compact }: PipelineFlowProps) {
|
||||
return (
|
||||
<div className={`pipeline-flow ${compact ? 'pipeline-compact' : ''}`}>
|
||||
{PIPELINE_STEPS.map((step, i) => (
|
||||
<div key={step.id} className="pipeline-step-wrap">
|
||||
<div className={`pipeline-step ${activeStep === step.id ? 'pipeline-active' : ''}`}>
|
||||
<div className="pipeline-icon">{step.icon}</div>
|
||||
<div className="pipeline-text">
|
||||
<strong>{step.title}</strong>
|
||||
<span>{step.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i < PIPELINE_STEPS.length - 1 && <div className="pipeline-connector" aria-hidden />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FleetPipelineStatusProps {
|
||||
hasBuilds: boolean;
|
||||
agentCount: number;
|
||||
onlineCount: number;
|
||||
hasHashrate: boolean;
|
||||
hasShares: boolean;
|
||||
}
|
||||
|
||||
export function FleetPipelineStatus({
|
||||
hasBuilds,
|
||||
agentCount,
|
||||
onlineCount,
|
||||
hasHashrate,
|
||||
hasShares,
|
||||
}: FleetPipelineStatusProps) {
|
||||
const steps = [
|
||||
{ id: 'forge', label: 'Forged', ok: hasBuilds, hint: 'At least one build exists' },
|
||||
{ id: 'deploy', label: 'Deployed', ok: agentCount > 0, hint: 'Agent registered on dashboard' },
|
||||
{ id: 'connect', label: 'Online', ok: onlineCount > 0, hint: `${onlineCount} node(s) live` },
|
||||
{ id: 'mine', label: 'Hashing', ok: hasHashrate, hint: 'Fleet hashrate > 0' },
|
||||
{ id: 'shares', label: 'Shares', ok: hasShares, hint: 'Shares submitted to pool' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fleet-pipeline-status">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.id} className="fleet-pipeline-item">
|
||||
<div className={`fleet-pipeline-node ${s.ok ? 'ok' : 'pending'}`} title={s.hint}>
|
||||
<span className="fleet-pipeline-dot" />
|
||||
<span className="fleet-pipeline-label font-tech">{s.label}</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && <div className={`fleet-pipeline-line ${s.ok ? 'lit' : ''}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActivityPulseProps {
|
||||
items: { id: string; label: string; ok: boolean; time?: string }[];
|
||||
}
|
||||
|
||||
export function ActivityPulse({ items }: ActivityPulseProps) {
|
||||
if (items.length === 0) {
|
||||
return <p className="activity-empty font-tech">Awaiting fleet activity…</p>;
|
||||
}
|
||||
return (
|
||||
<div className="activity-pulse">
|
||||
{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" />
|
||||
<span className="activity-blip-label">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForgeCalibrateCompareProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ForgeCalibrateCompare({ compact }: ForgeCalibrateCompareProps) {
|
||||
return (
|
||||
<div className={`compare-grid ${compact ? 'compare-compact' : ''}`}>
|
||||
<NeonCard accent="cyan" className="compare-card">
|
||||
<h4>{FORGE_VS_CALIBRATE.forge.title}</h4>
|
||||
<ul>
|
||||
{FORGE_VS_CALIBRATE.forge.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
{!compact && (
|
||||
<Link to="/forge" className="btn btn-outline btn-sm">Go to Forge</Link>
|
||||
)}
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="compare-card">
|
||||
<h4>{FORGE_VS_CALIBRATE.calibrate.title}</h4>
|
||||
<ul>
|
||||
{FORGE_VS_CALIBRATE.calibrate.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
{!compact && (
|
||||
<Link to="/settings" className="btn btn-outline btn-sm">Go to Calibrate</Link>
|
||||
)}
|
||||
</NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoadmapGrid() {
|
||||
return (
|
||||
<div className="roadmap-grid">
|
||||
{ROADMAP_FEATURES.map((f) => (
|
||||
<div key={f.title} className={`roadmap-card roadmap-${f.priority}`}>
|
||||
<span className={`roadmap-priority font-tech`}>{f.priority}</span>
|
||||
<h4>{f.title}</h4>
|
||||
<p>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
server/web/src/help/buildManager.ts
Normal file
49
server/web/src/help/buildManager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** Shallow diff between two plain objects for blueprint comparison. */
|
||||
export function blueprintDiff(
|
||||
base: Record<string, unknown>,
|
||||
current: Record<string, unknown>
|
||||
): { key: string; kind: 'added' | 'removed' | 'changed'; from?: unknown; to?: unknown }[] {
|
||||
const keys = new Set([...Object.keys(base), ...Object.keys(current)]);
|
||||
const out: { key: string; kind: 'added' | 'removed' | 'changed'; from?: unknown; to?: unknown }[] = [];
|
||||
for (const key of keys) {
|
||||
const a = base[key];
|
||||
const b = current[key];
|
||||
const hasA = key in base;
|
||||
const hasB = key in current;
|
||||
if (!hasA && hasB) {
|
||||
out.push({ key, kind: 'added', to: b });
|
||||
} else if (hasA && !hasB) {
|
||||
out.push({ key, kind: 'removed', from: a });
|
||||
} else if (JSON.stringify(a) !== JSON.stringify(b)) {
|
||||
out.push({ key, kind: 'changed', from: a, to: b });
|
||||
}
|
||||
}
|
||||
return out.sort((x, y) => x.key.localeCompare(y.key));
|
||||
}
|
||||
|
||||
/** Build partial forge request from a stored build record. */
|
||||
export function buildRequestFromRecord(
|
||||
record: {
|
||||
worker_name: string;
|
||||
server_url: string;
|
||||
wallet: string;
|
||||
threads: number;
|
||||
pool_host: string;
|
||||
pool_port: number;
|
||||
pool_tls: boolean;
|
||||
pool_pass: string;
|
||||
},
|
||||
defaults: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...defaults,
|
||||
worker_name: record.worker_name,
|
||||
server_url: record.server_url,
|
||||
wallet: record.wallet,
|
||||
threads: record.threads,
|
||||
pool_host: record.pool_host,
|
||||
pool_port: record.pool_port,
|
||||
pool_tls: record.pool_tls,
|
||||
pool_pass: record.pool_pass,
|
||||
};
|
||||
}
|
||||
193
server/web/src/help/cheatSheetContent.ts
Normal file
193
server/web/src/help/cheatSheetContent.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/** Structured content for the visual Guide / Cheat Sheet page. */
|
||||
|
||||
export interface CheatStep {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
body: string;
|
||||
route?: string;
|
||||
routeLabel?: string;
|
||||
tips?: string[];
|
||||
}
|
||||
|
||||
export interface CheatSection {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps?: CheatStep[];
|
||||
cards?: { title: string; body: string; accent?: string }[];
|
||||
}
|
||||
|
||||
export const PIPELINE_STEPS: CheatStep[] = [
|
||||
{
|
||||
id: 'calibrate',
|
||||
title: 'Calibrate',
|
||||
subtitle: 'Server hub',
|
||||
icon: '⚙',
|
||||
body: 'Set listen port, data folder, fleet alerts, pool/wallet defaults for new Forge forms, and server limits. Does not change already-forged miners.',
|
||||
route: '/settings',
|
||||
routeLabel: 'Open Calibrate',
|
||||
tips: ['One-time server setup', 'Public LAN URL helps Forge quick-pick chips'],
|
||||
},
|
||||
{
|
||||
id: 'forge',
|
||||
title: 'Forge',
|
||||
subtitle: 'Per-miner config',
|
||||
icon: '⚒',
|
||||
body: 'Calibrate every worker here — wallet, pool, threads, install path, stealth, Fusion, AI. All baked into the .exe + uninstall script.',
|
||||
route: '/forge',
|
||||
routeLabel: 'Open Forge',
|
||||
tips: ['Green badge = baked into installer', 'Preflight must pass before forge'],
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
title: 'Deploy',
|
||||
subtitle: 'Copy & run once',
|
||||
icon: '📦',
|
||||
body: 'Copy install-*.exe (or fused prep.exe) to each Windows machine. Double-click once — it embeds, persists, and connects back.',
|
||||
tips: ['Keep uninstall-*.ps1 next to the exe', 'Use LAN IP in server URL, not localhost'],
|
||||
},
|
||||
{
|
||||
id: 'connect',
|
||||
title: 'Connect',
|
||||
subtitle: 'WebSocket auth',
|
||||
icon: '📡',
|
||||
body: 'Worker reaches your control server, sends forged wallet/pool/AI config, appears on Command Deck and Fleet Roster.',
|
||||
route: '/agents',
|
||||
routeLabel: 'Fleet Roster',
|
||||
tips: ['Signal Locked = dashboard live', 'Worker name from Forge shows in roster'],
|
||||
},
|
||||
{
|
||||
id: 'mine',
|
||||
title: 'Mine',
|
||||
subtitle: 'RandomX + pool',
|
||||
icon: '⛏',
|
||||
body: 'Server opens Stratum to your forged pool. Jobs broadcast to agents. Shares validated against pool before accept rate updates.',
|
||||
route: '/dashboard',
|
||||
routeLabel: 'Command Deck',
|
||||
tips: ['Hashrate wave chart = fleet total', 'Share log updates live'],
|
||||
},
|
||||
];
|
||||
|
||||
export const FORGE_VS_CALIBRATE = {
|
||||
forge: {
|
||||
title: 'Forge — per miner',
|
||||
items: [
|
||||
'Worker name & server URL',
|
||||
'Wallet & pool (host, port, TLS)',
|
||||
'Threads, CPU/RAM limits, schedule',
|
||||
'Install path, stealth, persistence',
|
||||
'Fusion prep bundling',
|
||||
'AI Autonomy toggle + Ollama model',
|
||||
],
|
||||
},
|
||||
calibrate: {
|
||||
title: 'Calibrate — control server',
|
||||
items: [
|
||||
'Listen port & data directory',
|
||||
'Dashboard subtitle',
|
||||
'Fleet alert thresholds + notifications',
|
||||
'Default pool/wallet for new Forge forms',
|
||||
'Stats & build retention, max agents/build size',
|
||||
'WebSocket ping, pool reconnect, logging toggles',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FUSION_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Build worker config',
|
||||
subtitle: 'Forge tab',
|
||||
icon: '1',
|
||||
body: 'Set all miner options first — Fusion wraps the same smart agent inside your prep app.',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Enable Fusion',
|
||||
subtitle: 'Upload prep.exe',
|
||||
icon: '2',
|
||||
body: 'Toggle Fusion, upload your prep.exe, pick run order (parallel / prep first / worker first).',
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
title: 'Forge fused output',
|
||||
subtitle: 'One file',
|
||||
icon: '3',
|
||||
body: 'Output is prep.exe (or custom name) containing your app + hidden worker. Uninstall script generated alongside.',
|
||||
},
|
||||
];
|
||||
|
||||
export const AI_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
title: 'Install Ollama',
|
||||
subtitle: 'Control PC',
|
||||
icon: '🤖',
|
||||
body: 'Ollama runs on the machine hosting miner-server — not on workers. Default: http://localhost:11434',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
title: 'Enable on Forge',
|
||||
subtitle: 'AI Autonomy',
|
||||
icon: '⚡',
|
||||
body: 'Toggle AI Autonomy, set model (e.g. llama3.2). Re-forge to change after deploy.',
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
title: 'Agent loop',
|
||||
subtitle: 'Every ~60s',
|
||||
icon: '🔄',
|
||||
body: 'Forged worker asks server /decide → Ollama → tool calls (self-heal, persistence check). Best with Self-healing on.',
|
||||
},
|
||||
];
|
||||
|
||||
export const TROUBLESHOOTING = [
|
||||
{ problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Check Windows firewall on port 8989.' },
|
||||
{ problem: '0 hashrate', fix: 'Pool must be reachable from control server. Check pool host/TLS/port in Forge match your pool docs.' },
|
||||
{ problem: 'Forge blocked', fix: 'Read preflight ✕ errors. Common: missing wallet, localhost URL, Fusion without prep.exe, AI without Ollama URL.' },
|
||||
{ problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' },
|
||||
{ problem: 'Can\'t remove miner', fix: 'Run uninstall-*.ps1 from the same forge output folder as the installer — as the same Windows user.' },
|
||||
{ problem: 'AI not doing anything', fix: 'Re-forge with AI on. Ollama must run on control PC. Check server logs for /agent/decide.' },
|
||||
];
|
||||
|
||||
/** Shipped vs planned — shown on Guide page. */
|
||||
export const ROADMAP_FEATURES = [
|
||||
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + optional Telegram/email.' },
|
||||
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health on Command Deck.' },
|
||||
{ priority: 'high', title: 'AI activity monitor', desc: 'Ollama decide cycles and tool calls per agent.' },
|
||||
{ priority: 'medium', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
|
||||
{ priority: 'medium', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day.' },
|
||||
{ priority: 'medium', title: 'Build manager', desc: 'Blueprint diff, re-forge, LAN QR downloads on Forge.' },
|
||||
{ priority: 'low', title: 'Dashboard auth', desc: 'Password or API token for LAN-wide command deck access.' },
|
||||
{ priority: 'low', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
|
||||
{ priority: 'low', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
|
||||
];
|
||||
|
||||
export const CHEAT_SECTIONS: CheatSection[] = [
|
||||
{
|
||||
id: 'pipeline',
|
||||
title: 'End-to-end pipeline',
|
||||
description: 'How data flows from your control PC to the pool.',
|
||||
steps: PIPELINE_STEPS,
|
||||
},
|
||||
{
|
||||
id: 'fusion',
|
||||
title: 'Fusion workflow',
|
||||
description: 'Bundle your prep app with the smart miner agent.',
|
||||
steps: FUSION_GUIDE,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Autonomy workflow',
|
||||
description: 'Self-healing via Ollama on the control server.',
|
||||
steps: AI_GUIDE,
|
||||
},
|
||||
{
|
||||
id: 'troubleshoot',
|
||||
title: 'Troubleshooting',
|
||||
description: 'Common fixes when something looks wrong.',
|
||||
cards: TROUBLESHOOTING.map((t) => ({ title: t.problem, body: t.fix, accent: 'amber' })),
|
||||
},
|
||||
];
|
||||
28
server/web/src/help/endpointHelpers.ts
Normal file
28
server/web/src/help/endpointHelpers.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ServerInfo } from '../types';
|
||||
|
||||
export function formatLanEndpoint(host: string, port: number): string {
|
||||
const h = host.trim().replace(/^https?:\/\//i, '').split('/')[0].split(':')[0];
|
||||
return `http://${h}:${port}`;
|
||||
}
|
||||
|
||||
/** Distinct LAN URLs workers can use to reach this control server. */
|
||||
export function lanEndpointCandidates(info: ServerInfo, portOverride?: number): string[] {
|
||||
const port = portOverride ?? info.port ?? 8989;
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
const push = (raw: string) => {
|
||||
const u = raw.trim();
|
||||
if (!u || seen.has(u)) return;
|
||||
seen.add(u);
|
||||
out.push(u);
|
||||
};
|
||||
|
||||
if (info.suggested_url?.trim()) {
|
||||
push(info.suggested_url.trim());
|
||||
}
|
||||
for (const ip of info.local_ips ?? []) {
|
||||
push(formatLanEndpoint(ip, port));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
177
server/web/src/help/forgeCompatibility.ts
Normal file
177
server/web/src/help/forgeCompatibility.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import type { PreflightCheck } from './forgeValidation';
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
/** Extra incompatibility checks beyond basic validation. */
|
||||
export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
const checks: PreflightCheck[] = [];
|
||||
|
||||
if (form.stealth_mode && form.display_mode === 'visible') {
|
||||
checks.push({
|
||||
id: 'stealth_display',
|
||||
level: 'error',
|
||||
message: 'Stealth mode cannot use Visible display — switch display to Silent/Background or turn off Stealth.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.stealth_mode && form.file_logging) {
|
||||
checks.push({
|
||||
id: 'stealth_logs',
|
||||
level: 'error',
|
||||
message: 'Stealth mode disables log files — turn off "Write miner.log" or disable Stealth.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.fusion_enabled && form.display_mode === 'visible') {
|
||||
checks.push({
|
||||
id: 'fusion_display',
|
||||
level: 'error',
|
||||
message: 'Fusion builds require silent/background display — Visible mode is not allowed with Fusion.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.mining_mode === 'idle') {
|
||||
if (form.idle_threshold_pct < 1 || form.idle_threshold_pct > 100) {
|
||||
checks.push({
|
||||
id: 'idle_threshold',
|
||||
level: 'error',
|
||||
message: 'Idle CPU threshold must be between 1 and 100 when using Idle mining mode.',
|
||||
});
|
||||
}
|
||||
if (form.idle_duration_minutes < 1) {
|
||||
checks.push({
|
||||
id: 'idle_duration',
|
||||
level: 'error',
|
||||
message: 'Idle duration must be at least 1 minute when using Idle mining mode.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (form.mining_mode === 'scheduled') {
|
||||
if (!form.schedule_start || !form.schedule_end) {
|
||||
checks.push({
|
||||
id: 'schedule',
|
||||
level: 'error',
|
||||
message: 'Scheduled mode requires both Start Time and End Time.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (form.thread_mode === 'fixed' && form.adapt_to_hardware) {
|
||||
checks.push({
|
||||
id: 'adapt_fixed',
|
||||
level: 'warn',
|
||||
message: 'Adapt to hardware is ignored when Thread Mode is Fixed — consider turning it off.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.thread_mode === 'percent' && (form.thread_percent < 1 || form.thread_percent > 100)) {
|
||||
checks.push({
|
||||
id: 'thread_percent_range',
|
||||
level: 'error',
|
||||
message: 'Thread Percent must be between 1 and 100.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.max_cpu_usage_pct < 1 || form.max_cpu_usage_pct > 100) {
|
||||
checks.push({
|
||||
id: 'max_cpu',
|
||||
level: 'error',
|
||||
message: 'Max CPU Usage must be between 1 and 100.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.max_memory_percent < 10 || form.max_memory_percent > 95) {
|
||||
checks.push({
|
||||
id: 'max_mem',
|
||||
level: 'error',
|
||||
message: 'Max Memory must be between 10 and 95.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.pool_port < 1 || form.pool_port > 65535) {
|
||||
checks.push({
|
||||
id: 'pool_port',
|
||||
level: 'error',
|
||||
message: 'Pool port must be between 1 and 65535.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.pool_port === 443 && !form.pool_tls) {
|
||||
checks.push({
|
||||
id: 'pool_tls_443',
|
||||
level: 'warn',
|
||||
message: 'Port 443 typically requires TLS — enable Use TLS/SSL unless your pool says otherwise.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.pool_tls && form.pool_port === 3333) {
|
||||
checks.push({
|
||||
id: 'pool_tls_3333',
|
||||
level: 'warn',
|
||||
message: 'TLS on port 3333 is unusual — confirm with your pool (many use 443 for SSL).',
|
||||
});
|
||||
}
|
||||
|
||||
if (!form.pool_pass.trim()) {
|
||||
checks.push({
|
||||
id: 'pool_pass',
|
||||
level: 'warn',
|
||||
message: 'Pool password empty — will default to "x" (standard for Monero).',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.run_as === 'service') {
|
||||
checks.push({
|
||||
id: 'run_as_service',
|
||||
level: 'warn',
|
||||
message: '"Windows Service" uses a scheduled task under the hood — not a true SCM service.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.ai_enabled && form.ai_ollama_endpoint.includes('127.0.0.1')) {
|
||||
checks.push({
|
||||
id: 'ai_localhost',
|
||||
level: 'warn',
|
||||
message: 'Ollama URL uses 127.0.0.1 — that means the control server PC, not the worker machine.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.ai_enabled && !form.self_healing) {
|
||||
checks.push({
|
||||
id: 'ai_no_heal',
|
||||
level: 'warn',
|
||||
message: 'AI Autonomy works best with Self-healing enabled — consider turning it on.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.process_name.trim() && !/^[a-zA-Z0-9._-]+$/.test(form.process_name.trim())) {
|
||||
checks.push({
|
||||
id: 'process_name',
|
||||
level: 'warn',
|
||||
message: 'Process name has unusual characters — use letters, numbers, dash, underscore only.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.wallet.trim() && looksLikeXMRWallet(form.wallet) && form.pool_host.trim()) {
|
||||
checks.push({
|
||||
id: 'forge_ready',
|
||||
level: 'ok',
|
||||
message: 'Core miner config looks coherent — wallet, pool, and identity are set.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.fusion_enabled && fusionPrepSelected) {
|
||||
checks.push({
|
||||
id: 'fusion_ready',
|
||||
level: 'ok',
|
||||
message: 'Fusion prep.exe attached — ready to bundle.',
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
54
server/web/src/help/forgeDefaults.ts
Normal file
54
server/web/src/help/forgeDefaults.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
|
||||
|
||||
/** Defaults for a new forge build — not stored in Calibrate. */
|
||||
export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
BuildRequest,
|
||||
'worker_name' | 'server_url' | 'wallet' | 'pool_host' | 'pool_port' | 'pool_tls' | 'pool_pass'
|
||||
> = {
|
||||
output_dir: 'exports',
|
||||
threads: 4,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
mining_mode: 'always',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
max_cpu_usage_pct: 80,
|
||||
max_memory_percent: 70,
|
||||
min_free_ram_mb: 1024,
|
||||
idle_threshold_pct: 20,
|
||||
idle_duration_minutes: 5,
|
||||
schedule_start: '21:00',
|
||||
schedule_end: '06:00',
|
||||
install_base: 'localappdata',
|
||||
install_custom_base: '',
|
||||
install_relative_path: 'CryptoMiner/{worker}-{build_short}',
|
||||
adapt_to_hardware: true,
|
||||
self_healing: true,
|
||||
file_logging: false,
|
||||
stealth_mode: true,
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
ai_enabled: false,
|
||||
ai_ollama_endpoint: 'http://localhost:11434',
|
||||
ai_model: 'llama3.2',
|
||||
};
|
||||
|
||||
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||
const publicUrl = config.server?.public_url?.trim();
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url,
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
pool_tls: config.pool.use_tls,
|
||||
pool_pass: config.pool.password || 'x',
|
||||
};
|
||||
}
|
||||
326
server/web/src/help/forgeRules.ts
Normal file
326
server/web/src/help/forgeRules.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
|
||||
|
||||
export interface ForgeFieldMeta {
|
||||
disabled: boolean;
|
||||
lockedReason?: string;
|
||||
badge?: ForgeFieldBadge;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ForgeSectionMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: ForgeFieldBadge;
|
||||
}
|
||||
|
||||
/** What each Forge section controls — shown in the UI header. */
|
||||
export const FORGE_SECTIONS: ForgeSectionMeta[] = [
|
||||
{
|
||||
id: 'identity',
|
||||
title: 'Identity',
|
||||
description: 'Worker label, control server URL, payout wallet — all baked into the installer.',
|
||||
badge: 'baked',
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
title: 'Pool Configuration',
|
||||
description: 'Where this miner submits work. Baked per installer; each forged worker can use its own pool.',
|
||||
badge: 'baked',
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
title: 'Performance & Resources',
|
||||
description: 'Threads, CPU/RAM limits, and when mining runs. Baked into the worker binary.',
|
||||
badge: 'baked',
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
title: 'Install & Process',
|
||||
description: 'Install path, persistence, stealth, and Task Manager name. Baked on first run.',
|
||||
badge: 'baked',
|
||||
},
|
||||
{
|
||||
id: 'fusion',
|
||||
title: 'Fusion',
|
||||
description: 'Optional prep.exe bundling. Baked into the fused output file.',
|
||||
badge: 'baked',
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Autonomy',
|
||||
description: 'Ollama decisions via the control server. Baked toggle; Ollama must run on the server PC.',
|
||||
badge: 'baked',
|
||||
},
|
||||
];
|
||||
|
||||
const BADGE_LABELS: Record<ForgeFieldBadge, string> = {
|
||||
baked: 'Baked into installer',
|
||||
'server-only': 'Server folder only — not in .exe',
|
||||
requires: 'Required when parent option is on',
|
||||
};
|
||||
|
||||
export function forgeBadgeLabel(badge: ForgeFieldBadge): string {
|
||||
return BADGE_LABELS[badge];
|
||||
}
|
||||
|
||||
/** Smart field update — auto-fixes coupled settings so incompatible mixes are hard to create. */
|
||||
export function applyForgeFieldUpdate(
|
||||
form: BuildRequest,
|
||||
field: keyof BuildRequest,
|
||||
value: unknown
|
||||
): BuildRequest {
|
||||
const next: BuildRequest = { ...form, [field]: value } as BuildRequest;
|
||||
|
||||
switch (field) {
|
||||
case 'stealth_mode':
|
||||
if (value === true) {
|
||||
next.file_logging = false;
|
||||
if (next.display_mode === 'visible') {
|
||||
next.display_mode = 'background';
|
||||
}
|
||||
next.silent_mode = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'display_mode':
|
||||
if (value === 'visible') {
|
||||
next.stealth_mode = false;
|
||||
next.silent_mode = false;
|
||||
} else if (value === 'silent' || value === 'background') {
|
||||
next.silent_mode = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'persistence':
|
||||
next.auto_start = value === true;
|
||||
break;
|
||||
|
||||
case 'auto_start':
|
||||
next.persistence = value === true;
|
||||
break;
|
||||
|
||||
case 'fusion_enabled':
|
||||
if (value === true) {
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thread_mode':
|
||||
if (value === 'fixed' && next.threads < 1) {
|
||||
next.threads = 4;
|
||||
}
|
||||
if (value === 'percent' && (next.thread_percent < 1 || next.thread_percent > 100)) {
|
||||
next.thread_percent = 75;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mining_mode':
|
||||
if (value === 'always') {
|
||||
// keep idle/schedule values for if user switches back
|
||||
}
|
||||
break;
|
||||
|
||||
case 'install_base':
|
||||
if (value !== 'custom') {
|
||||
next.install_custom_base = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'run_as':
|
||||
if (value === 'scheduled' || value === 'service') {
|
||||
// Scheduled/service always creates a task — sync persistence flags so UI matches reality
|
||||
next.persistence = true;
|
||||
next.auto_start = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ai_enabled':
|
||||
if (value === true) {
|
||||
if (!next.ai_ollama_endpoint?.trim()) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
}
|
||||
if (!next.ai_model?.trim()) {
|
||||
next.ai_model = 'llama3.2';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pool_port':
|
||||
if (typeof value === 'number') {
|
||||
if (value === 443 && !next.pool_tls) {
|
||||
next.pool_tls = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pool_tls':
|
||||
if (value === true && next.pool_port === 3333) {
|
||||
// common pools use 443 for TLS — warn in preflight, don't auto-change port
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Per-field UI state: disabled fields + why. */
|
||||
export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeFieldMeta> {
|
||||
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';
|
||||
|
||||
return {
|
||||
worker_name: { disabled: false, badge: 'baked' },
|
||||
server_url: { disabled: false, badge: 'baked' },
|
||||
wallet: { disabled: false, badge: 'baked' },
|
||||
output_dir: {
|
||||
disabled: false,
|
||||
badge: 'server-only',
|
||||
hint: 'Only copies the built files on this PC — not embedded in the worker.',
|
||||
},
|
||||
pool_host: { disabled: false, badge: 'baked' },
|
||||
pool_port: { disabled: false, badge: 'baked' },
|
||||
pool_tls: { disabled: false, badge: 'baked' },
|
||||
pool_pass: { disabled: false, badge: 'baked' },
|
||||
thread_mode: { disabled: false, badge: 'baked' },
|
||||
thread_percent: {
|
||||
disabled: isFixedThreads,
|
||||
badge: 'baked',
|
||||
lockedReason: isFixedThreads ? 'Disabled while Thread Mode is Fixed — use Fixed Threads instead.' : undefined,
|
||||
},
|
||||
threads: {
|
||||
disabled: !isFixedThreads,
|
||||
badge: 'baked',
|
||||
lockedReason: !isFixedThreads ? 'Disabled while Thread Mode is Auto (%) — use Thread Percent instead.' : undefined,
|
||||
},
|
||||
cpu_priority: { disabled: false, badge: 'baked' },
|
||||
max_cpu_usage_pct: { disabled: false, badge: 'baked' },
|
||||
max_memory_percent: { disabled: false, badge: 'baked' },
|
||||
min_free_ram_mb: { disabled: false, badge: 'baked' },
|
||||
mining_mode: { disabled: false, badge: 'baked' },
|
||||
idle_threshold_pct: {
|
||||
disabled: !isIdle,
|
||||
badge: 'requires',
|
||||
lockedReason: !isIdle ? 'Only applies when Mining Mode is "Only When Idle".' : undefined,
|
||||
},
|
||||
idle_duration_minutes: {
|
||||
disabled: !isIdle,
|
||||
badge: 'requires',
|
||||
lockedReason: !isIdle ? 'Only applies when Mining Mode is "Only When Idle".' : undefined,
|
||||
},
|
||||
schedule_start: {
|
||||
disabled: !isScheduled,
|
||||
badge: 'requires',
|
||||
lockedReason: !isScheduled ? 'Only applies when Mining Mode is "Scheduled Hours".' : undefined,
|
||||
},
|
||||
schedule_end: {
|
||||
disabled: !isScheduled,
|
||||
badge: 'requires',
|
||||
lockedReason: !isScheduled ? 'Only applies when Mining Mode is "Scheduled Hours".' : undefined,
|
||||
},
|
||||
install_base: { disabled: false, badge: 'baked' },
|
||||
install_custom_base: {
|
||||
disabled: form.install_base !== 'custom',
|
||||
badge: 'requires',
|
||||
lockedReason: form.install_base !== 'custom' ? 'Select Install Base → Custom Path first.' : undefined,
|
||||
},
|
||||
install_relative_path: { disabled: false, badge: 'baked' },
|
||||
adapt_to_hardware: {
|
||||
disabled: isFixedThreads,
|
||||
badge: 'baked',
|
||||
lockedReason: isFixedThreads
|
||||
? 'Adapt to hardware is ignored when using Fixed thread count — switch to Auto (%) or turn off fixed mode.'
|
||||
: undefined,
|
||||
},
|
||||
self_healing: { disabled: false, badge: 'baked' },
|
||||
stealth_mode: { disabled: false, badge: 'baked' },
|
||||
file_logging: {
|
||||
disabled: form.stealth_mode,
|
||||
badge: 'baked',
|
||||
lockedReason: form.stealth_mode ? 'Stealth mode disables log files — turn off Stealth to enable logging.' : undefined,
|
||||
},
|
||||
process_name: { disabled: false, badge: 'baked' },
|
||||
display_mode: { disabled: false, badge: 'baked' },
|
||||
persistence: {
|
||||
disabled: runAsForcedPersistence,
|
||||
badge: 'baked',
|
||||
lockedReason: runAsForcedPersistence
|
||||
? 'Run As Scheduled/Service always installs a logon task — persistence cannot be turned off for this mode.'
|
||||
: undefined,
|
||||
},
|
||||
auto_start: {
|
||||
disabled: runAsForcedPersistence,
|
||||
badge: 'baked',
|
||||
lockedReason: runAsForcedPersistence
|
||||
? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
|
||||
: undefined,
|
||||
},
|
||||
run_as: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: { disabled: false, badge: 'baked' },
|
||||
fusion_prep: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
|
||||
},
|
||||
fusion_run_order: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
|
||||
},
|
||||
fusion_output_name: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
|
||||
},
|
||||
ai_enabled: { disabled: false, badge: 'baked' },
|
||||
ai_ollama_endpoint: {
|
||||
disabled: !form.ai_enabled,
|
||||
badge: 'requires',
|
||||
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
|
||||
},
|
||||
ai_model: {
|
||||
disabled: !form.ai_enabled,
|
||||
badge: 'requires',
|
||||
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Live incompatibility notices shown above the form. */
|
||||
export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: boolean): string[] {
|
||||
const notices: string[] = [];
|
||||
|
||||
if (form.run_as === 'service') {
|
||||
notices.push(
|
||||
'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.fusion_enabled && !fusionPrepSelected) {
|
||||
notices.push('Fusion is enabled — upload prep.exe before you can forge.');
|
||||
}
|
||||
if (form.ai_enabled) {
|
||||
notices.push('AI calls Ollama on the control server PC (not the worker). Use http://localhost:11434 if Ollama runs on this machine.');
|
||||
}
|
||||
if (form.thread_mode === 'fixed' && form.adapt_to_hardware) {
|
||||
notices.push('Fixed thread count ignores "Adapt to hardware" at runtime.');
|
||||
}
|
||||
if (form.pool_port === 443 && !form.pool_tls) {
|
||||
notices.push('Port 443 usually requires TLS — enable Use TLS/SSL or verify your pool docs.');
|
||||
}
|
||||
if (form.pool_tls && form.pool_port === 3333) {
|
||||
notices.push('TLS on port 3333 is uncommon — many pools use 443 for SSL. Double-check pool docs.');
|
||||
}
|
||||
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
|
||||
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.');
|
||||
}
|
||||
|
||||
return notices;
|
||||
}
|
||||
71
server/web/src/help/forgeValidation.test.ts
Normal file
71
server/web/src/help/forgeValidation.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runForgePreflight, preflightHasErrors } from './forgeValidation';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest => ({
|
||||
worker_name: 'test-worker',
|
||||
server_url: 'http://192.168.1.50:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
mining_mode: 'always',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: '',
|
||||
max_cpu_usage_pct: 80,
|
||||
max_memory_percent: 70,
|
||||
min_free_ram_mb: 1024,
|
||||
idle_threshold_pct: 20,
|
||||
idle_duration_minutes: 5,
|
||||
schedule_start: '21:00',
|
||||
schedule_end: '06:00',
|
||||
install_base: 'localappdata',
|
||||
install_custom_base: '',
|
||||
install_relative_path: 'CryptoMiner/{worker}-{build_short}',
|
||||
adapt_to_hardware: true,
|
||||
self_healing: true,
|
||||
file_logging: true,
|
||||
stealth_mode: false,
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
ai_enabled: false,
|
||||
ai_ollama_endpoint: 'http://localhost:11434',
|
||||
ai_model: 'llama3.2',
|
||||
});
|
||||
|
||||
describe('runForgePreflight', () => {
|
||||
it('blocks empty wallet (F-01)', () => {
|
||||
const form = { ...baseForm(), wallet: '' };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(preflightHasErrors(checks)).toBe(true);
|
||||
expect(checks.some((c) => c.id === 'wallet' && c.level === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks localhost server URL (F-02)', () => {
|
||||
const form = { ...baseForm(), server_url: 'http://localhost:8989' };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(preflightHasErrors(checks)).toBe(true);
|
||||
expect(checks.some((c) => c.id === 'server' && c.level === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when AI enabled without endpoint (AI-06)', () => {
|
||||
const form = { ...baseForm(), ai_enabled: true, ai_ollama_endpoint: '' };
|
||||
const checks = runForgePreflight(form, false);
|
||||
expect(checks.some((c) => c.id === 'ai' && c.level === 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes valid LAN forge form', () => {
|
||||
const checks = runForgePreflight(baseForm(), false);
|
||||
expect(preflightHasErrors(checks)).toBe(false);
|
||||
});
|
||||
});
|
||||
132
server/web/src/help/forgeValidation.ts
Normal file
132
server/web/src/help/forgeValidation.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { runForgeCompatibilityChecks } from './forgeCompatibility';
|
||||
|
||||
export type PreflightLevel = 'ok' | 'warn' | 'error';
|
||||
|
||||
export interface PreflightCheck {
|
||||
id: string;
|
||||
level: PreflightLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isLanReachableUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url.trim());
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return false;
|
||||
if (host.startsWith('192.168.') || host.startsWith('10.')) return true;
|
||||
if (host.startsWith('172.')) {
|
||||
const parts = host.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const second = parseInt(parts[1], 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
}
|
||||
return host.includes('.');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
const checks: PreflightCheck[] = [];
|
||||
|
||||
if (!form.worker_name.trim()) {
|
||||
checks.push({ id: 'worker', level: 'error', message: 'Worker name is required (unique per machine).' });
|
||||
} else if (!/^[a-zA-Z0-9._-]+$/.test(form.worker_name.trim())) {
|
||||
checks.push({ id: 'worker', level: 'warn', message: 'Worker name has unusual characters; stick to letters, numbers, dash, underscore.' });
|
||||
} else {
|
||||
checks.push({ id: 'worker', level: 'ok', message: `Worker "${form.worker_name.trim()}" is valid.` });
|
||||
}
|
||||
|
||||
if (!form.server_url.trim()) {
|
||||
checks.push({ id: 'server', level: 'error', message: 'Server URL is required — miners must reach your control server.' });
|
||||
} else if (!isLanReachableUrl(form.server_url)) {
|
||||
checks.push({
|
||||
id: 'server',
|
||||
level: 'error',
|
||||
message: 'Server URL should be your LAN IP (e.g. http://192.168.1.10:8989), not localhost.',
|
||||
});
|
||||
} else {
|
||||
checks.push({ id: 'server', level: 'ok', message: 'Server URL looks reachable from other PCs on your network.' });
|
||||
}
|
||||
|
||||
if (!form.wallet.trim()) {
|
||||
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
|
||||
} else if (!looksLikeXMRWallet(form.wallet)) {
|
||||
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4).' });
|
||||
} else {
|
||||
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
|
||||
}
|
||||
|
||||
if (!form.pool_host.trim()) {
|
||||
checks.push({ id: 'pool', level: 'error', message: 'Pool host is required.' });
|
||||
} else {
|
||||
checks.push({ id: 'pool', level: 'ok', message: `Pool ${form.pool_host}:${form.pool_port} configured.` });
|
||||
}
|
||||
|
||||
if (form.install_base === 'custom' && !form.install_custom_base.trim()) {
|
||||
checks.push({ id: 'install', level: 'error', message: 'Custom install base path is required.' });
|
||||
} else {
|
||||
checks.push({ id: 'install', level: 'ok', message: 'Install path configuration OK.' });
|
||||
}
|
||||
|
||||
const out = (form.output_dir || '').trim();
|
||||
if (!out) {
|
||||
checks.push({ id: 'output', level: 'warn', message: 'No output folder set — build will only be stored under data/builds (still downloadable).' });
|
||||
} else if (out.includes('..') || out.includes(':') || out.startsWith('\\') || out.startsWith('/')) {
|
||||
checks.push({ id: 'output', level: 'error', message: 'Output folder must be a relative path under server data_dir (example: exports).' });
|
||||
} else {
|
||||
checks.push({ id: 'output', level: 'ok', message: `Output folder: data/${out}` });
|
||||
}
|
||||
|
||||
if (form.fusion_enabled) {
|
||||
if (!fusionPrepSelected) {
|
||||
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — upload your prep.exe.' });
|
||||
} else {
|
||||
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → output ${form.fusion_output_name || 'prep.exe'}.` });
|
||||
}
|
||||
}
|
||||
|
||||
if (form.thread_mode === 'fixed' && form.threads < 1) {
|
||||
checks.push({ id: 'threads', level: 'error', message: 'Fixed thread count must be at least 1.' });
|
||||
}
|
||||
|
||||
if (form.ai_enabled) {
|
||||
const endpoint = (form.ai_ollama_endpoint || '').trim();
|
||||
const model = (form.ai_model || '').trim();
|
||||
if (!endpoint) {
|
||||
checks.push({ id: 'ai', level: 'error', message: 'AI Autonomy is on — set the Ollama endpoint URL.' });
|
||||
} else {
|
||||
try {
|
||||
const u = new URL(endpoint);
|
||||
if (!u.protocol.startsWith('http')) {
|
||||
checks.push({ id: 'ai', level: 'warn', message: 'Ollama endpoint should use http:// or https://.' });
|
||||
} else {
|
||||
checks.push({ id: 'ai', level: 'ok', message: `Ollama endpoint ${endpoint} configured.` });
|
||||
}
|
||||
} catch {
|
||||
checks.push({ id: 'ai', level: 'error', message: 'Ollama endpoint URL is not valid.' });
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
checks.push({ id: 'ai', level: 'warn', message: 'AI model name is empty — will default to llama3.2 on the server.' });
|
||||
} else {
|
||||
checks.push({ id: 'ai', level: 'ok', message: `AI model: ${model}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Incompatibility + coupling rules
|
||||
checks.push(...runForgeCompatibilityChecks(form, fusionPrepSelected));
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function preflightHasErrors(checks: PreflightCheck[]): boolean {
|
||||
return checks.some((c) => c.level === 'error');
|
||||
}
|
||||
17
server/web/src/help/installPreview.test.ts
Normal file
17
server/web/src/help/installPreview.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { previewInstallPath } from './installPreview';
|
||||
|
||||
describe('previewInstallPath', () => {
|
||||
it('expands worker and build tokens (F-14)', () => {
|
||||
const path = previewInstallPath({
|
||||
install_base: 'localappdata',
|
||||
install_relative_path: 'CryptoMiner/{worker}-{build_short}',
|
||||
worker_name: 'office-pc',
|
||||
process_name: 'RuntimeHelper',
|
||||
});
|
||||
expect(path).toContain('office-pc-abc12345');
|
||||
expect(path).toContain('CryptoMiner\\office-pc-abc12345');
|
||||
expect(path).toContain('RuntimeHelper.exe');
|
||||
expect(path.startsWith('%LOCALAPPDATA%')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,28 @@
|
||||
export const SETUP_CHEATSHEET = [
|
||||
{
|
||||
title: '1. Launch the server',
|
||||
body: 'Run run.bat on your control PC. Open the dashboard at http://YOUR-LAN-IP:8989 from any device on your network.',
|
||||
title: '1. Calibrate the server',
|
||||
body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.',
|
||||
},
|
||||
{
|
||||
title: '2. Settings first',
|
||||
body: 'Set your Monero wallet and pool in Settings. These become defaults for every installer you build.',
|
||||
title: '2. Forge your installer',
|
||||
body: 'All miner options live here — threads, install path, stealth, persistence, Fusion, AI. Incompatible mixes are blocked; grayed fields do not apply to your current picks. Green badges = baked into the .exe.',
|
||||
},
|
||||
{
|
||||
title: '3. Build an installer',
|
||||
body: 'Give each machine a unique Worker Name. Build install-{name}.exe. Server URL must be your LAN IP, not localhost, so other PCs can reach you.',
|
||||
title: '3. Deploy',
|
||||
body: 'Copy the built .exe to a worker machine (or USB). Run once — it embeds and connects back to your LAN dashboard.',
|
||||
},
|
||||
{
|
||||
title: '4. Deploy once per PC',
|
||||
body: 'Double-click the .exe on any Windows machine. It copies itself to your configured install folder, registers persistence if enabled, connects to your dashboard, and starts mining — no extra steps.',
|
||||
},
|
||||
{
|
||||
title: '5. Monitor',
|
||||
body: 'Watch Dashboard and Agents for hashrate graphs, CPU/RAM, shares, and online status.',
|
||||
title: '4. Command Deck',
|
||||
body: 'Watch live hashrate, CPU, and shares from every machine on your network.',
|
||||
},
|
||||
];
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3',
|
||||
server_url: 'Your control server address on the LAN. Workers connect here for jobs and stats. Use http://192.168.x.x:8989 not localhost.',
|
||||
server_url:
|
||||
'Control server URL baked into the installer (http://LAN-IP:port). Editable in Forge when your host IP changes; use a LAN address, not localhost.',
|
||||
output_dir:
|
||||
'Optional: also copy the finished .exe into a folder under the server data_dir (example: exports). This is just for convenience; builds are always kept under data/builds/<id>/ and downloadable.',
|
||||
wallet: 'Monero wallet address where pool payouts go. Must be a valid 95-character mainnet address starting with 4.',
|
||||
pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.',
|
||||
pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.',
|
||||
@@ -44,7 +43,7 @@ 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 = startup entry. Scheduled/Service uses a logon scheduled task for persistence.',
|
||||
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
|
||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||
fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.',
|
||||
@@ -53,8 +52,14 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||
public_url: 'Override the LAN URL shown in Forge and given to new miners. Use http://192.168.x.x:8989 — not localhost — so other PCs can reach this server.',
|
||||
websocket_ping_seconds: 'How often the server pings dashboard and agent WebSockets (seconds). Keeps NAT/firewall sessions alive.',
|
||||
log_pool_traffic: 'Verbose Stratum wire logging to the server console — for debugging pool connectivity only.',
|
||||
adapt_to_hardware: 'Auto-tune thread count and RAM limits based on each machine\'s CPU cores and memory at runtime.',
|
||||
self_healing: 'Watchdog re-applies persistence and restores the binary from backup if deleted. Scheduled tasks restart on failure.',
|
||||
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.',
|
||||
ai_ollama_endpoint: 'Ollama API URL on the control server machine (example: http://localhost:11434). The hub calls Ollama — not the worker directly.',
|
||||
ai_model: 'Ollama model name to use for AI decisions (example: llama3.2). Must be pulled locally on the control server.',
|
||||
};
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type { WSMessage, Agent, FleetStats, Share } from '../types';
|
||||
import type { WSMessage, Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types';
|
||||
|
||||
interface DashboardData {
|
||||
interface DashboardInit {
|
||||
agents: Agent[];
|
||||
stats: FleetStats;
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
stats: FleetStats | null;
|
||||
recentShares: Share[];
|
||||
fleetAlerts: FleetAlert[];
|
||||
poolStatus: PoolStatus[];
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useWebSocket(): UseWebSocketReturn {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [stats, setStats] = useState<FleetStats | null>(null);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
|
||||
|
||||
@@ -28,13 +37,13 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
if (!unmounted.current) setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
// Reconnect after 3 seconds
|
||||
setTimeout(connect, 3000);
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -47,9 +56,8 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as DashboardData;
|
||||
const data = msg.payload as DashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
if (data.stats) setStats(data.stats);
|
||||
break;
|
||||
}
|
||||
case 'agent_online': {
|
||||
@@ -102,6 +110,36 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = msg.payload as FleetAlert;
|
||||
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
||||
break;
|
||||
}
|
||||
case 'pool_status': {
|
||||
const pools = msg.payload as PoolStatus[];
|
||||
if (Array.isArray(pools)) setPoolStatus(pools);
|
||||
break;
|
||||
}
|
||||
case 'ai_activity': {
|
||||
const entry = msg.payload as AIActivityEntry;
|
||||
setAiActivity((prev) => {
|
||||
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as { agent_id: string; content: string };
|
||||
if (agent_id) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
@@ -110,13 +148,20 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { isConnected, agents, stats, recentShares };
|
||||
return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs };
|
||||
}
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listAgents()
|
||||
.then(setAgents)
|
||||
.catch(console.error)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
setAgents(liveAgents);
|
||||
if (selectedAgent) {
|
||||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||||
if (updated) setSelectedAgent(updated);
|
||||
}
|
||||
}
|
||||
}, [liveAgents, isConnected, selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgent && agentLogs[selectedAgent.id]) {
|
||||
setLogContent(agentLogs[selectedAgent.id]);
|
||||
}
|
||||
}, [selectedAgent?.id, agentLogs]);
|
||||
|
||||
const refreshLog = async (refresh = false) => {
|
||||
if (!selectedAgent) return;
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const res = await api.getAgentLog(selectedAgent.id, refresh);
|
||||
setLogContent(res.content || '');
|
||||
} catch (err) {
|
||||
setLogContent(err instanceof Error ? err.message : 'Failed to load log');
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAgent = async (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setLogContent('');
|
||||
try {
|
||||
const history = await api.getAgentStats(agent.id, 60);
|
||||
setHashrateHistory(history);
|
||||
@@ -39,6 +77,12 @@ export default function AgentsPage() {
|
||||
<span className="header-count font-tech">{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
{loadError && (
|
||||
<NeonCard accent="amber" className="empty-state">
|
||||
<p>{loadError}</p>
|
||||
</NeonCard>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<p>Scanning network...</p>
|
||||
@@ -76,6 +120,7 @@ export default function AgentsPage() {
|
||||
<span>v{agent.version || '?'}</span>
|
||||
<span>{agent.cpu_cores} cores</span>
|
||||
</div>
|
||||
<AgentRemoteActions agent={agent} compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -171,6 +216,22 @@ export default function AgentsPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Remote Control</h3>
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
onCommandSent={(action) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading}>{logLoading ? '…' : 'Refresh'}</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>
|
||||
</NeonCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types';
|
||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { SETUP_CHEATSHEET } from '../help/settingHelp';
|
||||
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
|
||||
import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||
const d = config.default_agent_config;
|
||||
return {
|
||||
worker_name: '',
|
||||
server_url: serverInfo.suggested_url,
|
||||
wallet: config.wallet.address,
|
||||
threads: d.threads,
|
||||
thread_mode: d.thread_mode || 'percent',
|
||||
thread_percent: d.thread_percent || 75,
|
||||
cpu_priority: d.cpu_priority,
|
||||
mining_mode: d.mining_mode,
|
||||
display_mode: d.display_mode || (config.background.silent_mode ? 'silent' : 'background'),
|
||||
silent_mode: config.background.silent_mode,
|
||||
run_as: config.background.run_as,
|
||||
auto_start: config.background.auto_start,
|
||||
persistence: config.background.auto_start,
|
||||
process_name: d.process_name || '',
|
||||
max_cpu_usage_pct: d.max_cpu_usage_pct,
|
||||
max_memory_percent: d.max_memory_percent || 70,
|
||||
min_free_ram_mb: d.min_free_ram_mb,
|
||||
idle_threshold_pct: d.idle_threshold_pct,
|
||||
idle_duration_minutes: d.idle_duration_minutes,
|
||||
schedule_start: d.schedule_start,
|
||||
schedule_end: d.schedule_end,
|
||||
install_base: d.install_base || 'localappdata',
|
||||
install_custom_base: d.install_custom_base || '',
|
||||
install_relative_path: d.install_relative_path || 'CryptoMiner/{worker}-{build_short}',
|
||||
adapt_to_hardware: d.adapt_to_hardware ?? true,
|
||||
self_healing: d.self_healing ?? true,
|
||||
file_logging: d.file_logging ?? true,
|
||||
stealth_mode: d.stealth_mode ?? false,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
pool_tls: config.pool.use_tls,
|
||||
pool_pass: config.pool.password,
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
};
|
||||
return forgeDefaultsFromServer(config, serverInfo);
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
@@ -56,13 +29,41 @@ export default function BuilderPage() {
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [listenPort, setListenPort] = useState(8989);
|
||||
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
|
||||
|
||||
const refreshEndpointInfo = async () => {
|
||||
setRefreshingEndpoints(true);
|
||||
try {
|
||||
const [config, info] = await Promise.all([api.getConfig(), api.getServerInfo()]);
|
||||
setServerInfo(info);
|
||||
setListenPort(config.port || info.port || 8989);
|
||||
return info;
|
||||
} finally {
|
||||
setRefreshingEndpoints(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint state
|
||||
const [blueprints, setBlueprints] = useState<BlueprintInfo[]>([]);
|
||||
const [showBlueprints, setShowBlueprints] = useState(false);
|
||||
const [blueprintName, setBlueprintName] = useState('');
|
||||
const [blueprintMsg, setBlueprintMsg] = useState('');
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [compareBlueprint, setCompareBlueprint] = useState<Record<string, unknown> | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||
.then(([config, serverInfo]) => setForm(defaultsFromConfig(config, serverInfo)))
|
||||
.then(([config, info]) => {
|
||||
setServerInfo(info);
|
||||
setListenPort(config.port || info.port || 8989);
|
||||
setForm(defaultsFromConfig(config, info));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError('Failed to load server defaults from Settings');
|
||||
setError('Failed to load server info — is the control server running?');
|
||||
})
|
||||
.finally(() => setLoadingDefaults(false));
|
||||
}, []);
|
||||
@@ -77,30 +78,136 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: save current form as a named blueprint
|
||||
const handleSaveBlueprint = async () => {
|
||||
if (!form) return;
|
||||
const name = prompt('Enter a name for this blueprint:', form.worker_name || 'my-miner-config');
|
||||
if (!name || !name.trim()) return;
|
||||
setBlueprintMsg('');
|
||||
try {
|
||||
const result = await api.saveBlueprint(name.trim(), form);
|
||||
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to save: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: load blueprints list and show picker
|
||||
const handleLoadBlueprint = async () => {
|
||||
try {
|
||||
setLoadingBlueprints(true);
|
||||
const list = await api.listBlueprints();
|
||||
setBlueprints(list);
|
||||
setShowBlueprints(true);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to load blueprints: ${err.message}`);
|
||||
} finally {
|
||||
setLoadingBlueprints(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: apply a selected blueprint to the form
|
||||
const handleApplyBlueprint = async (name: string) => {
|
||||
try {
|
||||
const data = await api.getBlueprint(name);
|
||||
setCompareBlueprint(data as Record<string, unknown>);
|
||||
// Merge loaded data into form, preserving any fields not in the blueprint
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setShowBlueprints(false);
|
||||
setBlueprintMsg(`✅ Blueprint "${name}" loaded`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to load blueprint: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const reForgeFromBuild = async (build: BuildRecord) => {
|
||||
if (!form) return;
|
||||
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
|
||||
setForm(merged);
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
const checks = runForgePreflight(merged, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Re-forge preflight failed — adjust settings and forge manually.');
|
||||
return;
|
||||
}
|
||||
setBuilding(true);
|
||||
try {
|
||||
const result = await api.buildAgent(merged, fusionPrepFile);
|
||||
if (!result.success) throw new Error(result.error || 'Build failed');
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Re-forge failed');
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const blueprintDiffRows = useMemo(() => {
|
||||
if (!form || !compareBlueprint) return [];
|
||||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||||
}, [form, compareBlueprint]);
|
||||
|
||||
// Blueprint: delete a blueprint
|
||||
const handleDeleteBlueprint = async (name: string) => {
|
||||
if (!confirm(`Delete blueprint "${name}"?`)) return;
|
||||
try {
|
||||
await api.deleteBlueprint(name);
|
||||
setBlueprints((prev) => prev.filter((b) => b.name !== name));
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to delete: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: import from a local .json file
|
||||
const handleImportBlueprintFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch {
|
||||
setBlueprintMsg('❌ Invalid JSON file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input so same file can be re-selected
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// Blueprint: export current form as a downloadable .json file
|
||||
const handleExportBlueprintFile = () => {
|
||||
if (!form) return;
|
||||
const blob = new Blob([JSON.stringify(form, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${form.worker_name || 'miner-config'}-blueprint.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
if (!form.worker_name.trim()) {
|
||||
setError('Worker name is required');
|
||||
return;
|
||||
}
|
||||
if (!form.server_url.trim()) {
|
||||
setError('Server URL is required');
|
||||
return;
|
||||
}
|
||||
if (!form.wallet.trim()) {
|
||||
setError('Wallet address is required');
|
||||
return;
|
||||
}
|
||||
if (form.install_base === 'custom' && !form.install_custom_base.trim()) {
|
||||
setError('Custom install base path is required when Install Base is Custom');
|
||||
return;
|
||||
}
|
||||
if (form.fusion_enabled && !fusionPrepFile) {
|
||||
setError('Fusion requires your prep.exe file');
|
||||
const checks = runForgePreflight(form, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Preflight failed — fix errors in the checklist below before forging.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,15 +226,32 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof BuildRequest, value: any) => {
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
const updateField = (field: keyof BuildRequest, value: unknown) => {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
|
||||
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
|
||||
const liveNotices = useMemo(
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const preflightChecks = useMemo(
|
||||
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
|
||||
if (loadingDefaults || !form) {
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header"><h1>Miner Builder</h1></div>
|
||||
<div className="card"><p>Loading defaults from Settings...</p></div>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass"><p>Loading forge defaults from server...</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,22 +264,95 @@ export default function BuilderPage() {
|
||||
process_name: form.process_name,
|
||||
});
|
||||
|
||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
{/* Hidden file input for importing blueprint .json files */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<p className="page-subtitle">Craft fused or standalone miners for your LAN fleet.</p>
|
||||
<p className="page-subtitle">
|
||||
Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||||
Recent Builds
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleSaveBlueprint} title="Save current form as a named blueprint on the server">
|
||||
💾 Save Blueprint
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleLoadBlueprint} title="Load a saved blueprint from the server">
|
||||
📂 Load Blueprint
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleExportBlueprintFile} title="Download current form as a .json file">
|
||||
⬇️ Export .json
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleImportBlueprintFile} title="Import a .json blueprint file from your computer">
|
||||
📥 Import .json
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||||
Recent Builds
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Blueprint status message */}
|
||||
{blueprintMsg && (
|
||||
<div className={`save-message ${blueprintMsg.includes('✅') ? 'success' : 'error'}`} style={{ marginBottom: '12px' }}>
|
||||
{blueprintMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprint picker panel */}
|
||||
{showBlueprints && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0 }}>Saved Blueprints</h3>
|
||||
<button className="btn btn-outline" onClick={() => setShowBlueprints(false)}>Close</button>
|
||||
</div>
|
||||
{loadingBlueprints ? (
|
||||
<p>Loading blueprints...</p>
|
||||
) : blueprints.length === 0 ? (
|
||||
<p className="empty-text">No saved blueprints yet. Configure the form and click "Save Blueprint".</p>
|
||||
) : (
|
||||
<div className="builds-list">
|
||||
{blueprints.map((bp) => (
|
||||
<div key={bp.name} className="build-item">
|
||||
<div className="build-item-name">{bp.name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{(bp.size / 1024).toFixed(1)} KB</span>
|
||||
<span>{new Date(bp.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleApplyBlueprint(bp.name)}>
|
||||
Load
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => handleDeleteBlueprint(bp.name)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="builder-layout builder-layout-wide">
|
||||
<div className="card cheat-sheet-panel">
|
||||
<h2>Setup Cheat Sheet</h2>
|
||||
<h2>Quick links</h2>
|
||||
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
|
||||
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
|
||||
Open Field Guide
|
||||
</Link>
|
||||
<div className="cheat-sheet">
|
||||
{SETUP_CHEATSHEET.map((item) => (
|
||||
<div key={item.title} className="cheat-sheet-item">
|
||||
@@ -167,6 +364,43 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="card builder-form">
|
||||
<div className="forge-rules-banner">
|
||||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
|
||||
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
|
||||
</p>
|
||||
<div className="forge-rules-grid">
|
||||
<div className="forge-rule-card">
|
||||
<strong>⛏ Baked into installer</strong>
|
||||
Wallet, pool, threads, install path, stealth, AI toggle — frozen when you forge. Re-forge to change.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>🖥 Server folder only</strong>
|
||||
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>🔒 Auto-coupled</strong>
|
||||
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>✕ Cannot forge until fixed</strong>
|
||||
Preflight errors below must be resolved — warnings let you forge but double-check first.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveNotices.length > 0 && (
|
||||
<div className="forge-live-notices">
|
||||
<strong className="font-tech">ACTIVE RULES</strong>
|
||||
<ul>
|
||||
{liveNotices.map((n) => (
|
||||
<li key={n}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Build Miner Installer</h2>
|
||||
<p className="form-description">
|
||||
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
|
||||
@@ -175,9 +409,16 @@ export default function BuilderPage() {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<h3>Identity</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Identity"
|
||||
badge="baked"
|
||||
description="Worker name, control server URL, and payout wallet are embedded in every forged installer."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
|
||||
<div className="label-row">
|
||||
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.worker_name} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
@@ -187,16 +428,50 @@ export default function BuilderPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Server URL <HelpTip field="server_url" /></label>
|
||||
<div className="form-group endpoint-group">
|
||||
<div className="endpoint-header">
|
||||
<label className="label">Control Endpoint <HelpTip field="server_url" /></label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={refreshingEndpoints}
|
||||
onClick={() => void refreshEndpointInfo()}
|
||||
>
|
||||
{refreshingEndpoints ? 'Scanning...' : 'Refresh LAN IPs'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
type="url"
|
||||
className="input mono endpoint-input"
|
||||
placeholder={`http://192.168.1.10:${listenPort}`}
|
||||
value={form.server_url}
|
||||
onChange={(e) => updateField('server_url', e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FieldHint field="server_url" />
|
||||
<p className="form-hint endpoint-hint">
|
||||
Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first.
|
||||
</p>
|
||||
{endpointCandidates.length > 0 && (
|
||||
<div className="endpoint-picks">
|
||||
<span className="endpoint-picks-label font-tech">Quick pick</span>
|
||||
<div className="endpoint-chips">
|
||||
{endpointCandidates.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
className={`endpoint-chip ${form.server_url.trim() === url ? 'active' : ''}`}
|
||||
onClick={() => updateField('server_url', url)}
|
||||
title="Use this address in the installer"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
@@ -208,10 +483,33 @@ export default function BuilderPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.output_dir} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="exports"
|
||||
value={(form.output_dir || '') as any}
|
||||
onChange={(e) => updateField('output_dir' as any, e.target.value)}
|
||||
/>
|
||||
<FieldHint field="output_dir" />
|
||||
<ForgeLockedHint meta={fieldMeta.output_dir} />
|
||||
<p className="form-hint">
|
||||
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Pool Configuration</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Pool Configuration"
|
||||
badge="baked"
|
||||
description="This miner's pool connection — host, port, TLS, and password are baked into the worker."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host</label>
|
||||
<input
|
||||
@@ -258,7 +556,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Performance & Resources</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Performance & Resources"
|
||||
badge="baked"
|
||||
description="Thread count, CPU/RAM limits, and mining schedule. Irrelevant fields lock based on your mode picks."
|
||||
/>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
|
||||
@@ -268,19 +570,21 @@ export default function BuilderPage() {
|
||||
</select>
|
||||
<FieldHint field="thread_mode" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.thread_percent?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
|
||||
disabled={form.thread_mode === 'fixed'}
|
||||
disabled={fieldMeta.thread_percent?.disabled}
|
||||
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
|
||||
<ForgeLockedHint meta={fieldMeta.thread_percent} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.threads?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
|
||||
<input type="number" className="input" min={1} max={128} value={form.threads}
|
||||
disabled={form.thread_mode !== 'fixed'}
|
||||
disabled={fieldMeta.threads?.disabled}
|
||||
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
|
||||
<ForgeLockedHint meta={fieldMeta.threads} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
|
||||
@@ -325,20 +629,25 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
{form.mining_mode === 'idle' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Idle CPU Threshold (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||||
value={form.idle_threshold_pct}
|
||||
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Idle Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||||
value={form.idle_duration_minutes}
|
||||
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
/>
|
||||
@@ -347,20 +656,22 @@ export default function BuilderPage() {
|
||||
)}
|
||||
{form.mining_mode === 'scheduled' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
disabled={fieldMeta.schedule_start?.disabled}
|
||||
value={form.schedule_start}
|
||||
onChange={(e) => updateField('schedule_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
disabled={fieldMeta.schedule_end?.disabled}
|
||||
value={form.schedule_end}
|
||||
onChange={(e) => updateField('schedule_end', e.target.value)}
|
||||
/>
|
||||
@@ -370,11 +681,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Install & Process</h3>
|
||||
<p className="form-description">
|
||||
Double-clicking the built `.exe` embeds the miner on first run: copies itself to the path below,
|
||||
optionally persists, then starts mining in the background.
|
||||
</p>
|
||||
<ForgeSectionHeader
|
||||
title="Install & Process"
|
||||
badge="baked"
|
||||
description="Where the miner installs, how it persists, and how it appears in Task Manager."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select className="select" value={form.install_base}
|
||||
@@ -389,12 +700,14 @@ export default function BuilderPage() {
|
||||
<FieldHint field="install_base" />
|
||||
</div>
|
||||
{form.install_base === 'custom' && (
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
|
||||
disabled={fieldMeta.install_custom_base?.disabled}
|
||||
value={form.install_custom_base}
|
||||
onChange={(e) => updateField('install_custom_base', e.target.value)} />
|
||||
<FieldHint field="install_custom_base" />
|
||||
<ForgeLockedHint meta={fieldMeta.install_custom_base} />
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
@@ -409,13 +722,15 @@ export default function BuilderPage() {
|
||||
<label className="label">Install Preview</label>
|
||||
<code className="path-display">{installPreview}</code>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.adapt_to_hardware?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.adapt_to_hardware}
|
||||
disabled={fieldMeta.adapt_to_hardware?.disabled}
|
||||
onChange={(e) => updateField('adapt_to_hardware', e.target.checked)} />
|
||||
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
|
||||
</label>
|
||||
<FieldHint field="adapt_to_hardware" />
|
||||
<ForgeLockedHint meta={fieldMeta.adapt_to_hardware} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
@@ -428,21 +743,19 @@ export default function BuilderPage() {
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.stealth_mode}
|
||||
onChange={(e) => {
|
||||
updateField('stealth_mode', e.target.checked);
|
||||
if (e.target.checked) updateField('file_logging', false);
|
||||
}} />
|
||||
onChange={(e) => updateField('stealth_mode', e.target.checked)} />
|
||||
<span>Stealth mode (no window, no logs, discreet persistence) <HelpTip field="stealth_mode" /></span>
|
||||
</label>
|
||||
<FieldHint field="stealth_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.file_logging}
|
||||
disabled={form.stealth_mode}
|
||||
disabled={fieldMeta.file_logging?.disabled}
|
||||
onChange={(e) => updateField('file_logging', e.target.checked)} />
|
||||
<span>Write miner.log on host <HelpTip field="file_logging" /></span>
|
||||
</label>
|
||||
<ForgeLockedHint meta={fieldMeta.file_logging} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Process Name <HelpTip field="process_name" /></label>
|
||||
@@ -460,13 +773,15 @@ export default function BuilderPage() {
|
||||
</select>
|
||||
<FieldHint field="display_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.persistence?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.persistence}
|
||||
onChange={(e) => { updateField('persistence', e.target.checked); updateField('auto_start', e.target.checked); }} />
|
||||
disabled={fieldMeta.persistence?.disabled}
|
||||
onChange={(e) => updateField('persistence', e.target.checked)} />
|
||||
<span>Persist after reboot <HelpTip field="persistence" /></span>
|
||||
</label>
|
||||
<FieldHint field="persistence" />
|
||||
<ForgeLockedHint meta={fieldMeta.persistence} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Run As <HelpTip field="run_as" /></label>
|
||||
@@ -475,25 +790,29 @@ export default function BuilderPage() {
|
||||
value={form.run_as}
|
||||
onChange={(e) => updateField('run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User</option>
|
||||
<option value="service">Windows Service</option>
|
||||
<option value="scheduled">Scheduled Task</option>
|
||||
<option value="user">Current User (Run key when persistence on)</option>
|
||||
<option value="service">Scheduled Task — forced persistence</option>
|
||||
<option value="scheduled">Scheduled Task — forced persistence</option>
|
||||
</select>
|
||||
<FieldHint field="run_as" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<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}
|
||||
onChange={(e) => { updateField('auto_start', e.target.checked); updateField('persistence', e.target.checked); }} />
|
||||
<span>Also register startup entry (same as persistence)</span>
|
||||
disabled={fieldMeta.auto_start?.disabled}
|
||||
onChange={(e) => updateField('auto_start', e.target.checked)} />
|
||||
<span>Also register startup entry (linked to persistence)</span>
|
||||
</label>
|
||||
<ForgeLockedHint meta={fieldMeta.auto_start} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Fusion (prep + worker)</h3>
|
||||
<p className="form-description">
|
||||
Bundle your machine prep tool with the miner into one file. The output runs your prep.exe and embeds the worker in the background.
|
||||
</p>
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
badge="baked"
|
||||
description="Optional — bundles prep.exe with the miner. Forces background display when enabled."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
@@ -504,8 +823,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
{form.fusion_enabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
@@ -541,15 +863,83 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="AI Autonomy (AI自治)"
|
||||
badge="baked"
|
||||
description="Optional — Ollama on the control server decides actions. Worker must reach this dashboard."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
|
||||
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
|
||||
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="ai_enabled" />
|
||||
</div>
|
||||
{form.ai_enabled && (
|
||||
<>
|
||||
<div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Ollama Endpoint URL</label>
|
||||
<ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} />
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
className="input mono"
|
||||
placeholder="http://localhost:11434"
|
||||
disabled={fieldMeta.ai_ollama_endpoint?.disabled}
|
||||
value={form.ai_ollama_endpoint}
|
||||
onChange={(e) => updateField('ai_ollama_endpoint', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Control server machine — not the worker. Default: <code>http://localhost:11434</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Ollama Model</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="llama3.2"
|
||||
disabled={fieldMeta.ai_model?.disabled}
|
||||
value={form.ai_model}
|
||||
onChange={(e) => updateField('ai_model', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Model to use for decisions. Default: <code>llama3.2</code>. Must support tool-calling / JSON output.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="preflight-panel card">
|
||||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||||
<ul className="preflight-list">
|
||||
{preflightChecks.map((c) => (
|
||||
<li key={c.id} className={`preflight-item preflight-${c.level}`}>
|
||||
<span className="preflight-icon">{c.level === 'ok' ? '✓' : c.level === 'warn' ? '!' : '✕'}</span>
|
||||
{c.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
<span>⚠️</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn btn-success build-btn" disabled={building}>
|
||||
{building ? 'Building...' : 'Build Installer .exe'}
|
||||
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
|
||||
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
|
||||
</button>
|
||||
{!canForge && errorCount > 0 && (
|
||||
<p className="forge-forge-blocked">
|
||||
Forge is blocked until all preflight errors (✕) are resolved. Warnings (!) still allow forging.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -572,6 +962,15 @@ export default function BuilderPage() {
|
||||
Download .exe
|
||||
</a>
|
||||
)}
|
||||
{lastBuild.uninstall_download_url && (
|
||||
<>
|
||||
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
|
||||
<code className="path-display">{lastBuild.uninstall_path}</code>
|
||||
<a className="btn btn-outline" href={lastBuild.uninstall_download_url} download>
|
||||
Download uninstall script
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -579,29 +978,48 @@ export default function BuilderPage() {
|
||||
{showRecent && (
|
||||
<div className="card recent-builds">
|
||||
<div className="recent-header">
|
||||
<h2>Recent Builds</h2>
|
||||
<h2>Build Manager</h2>
|
||||
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
|
||||
</div>
|
||||
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
|
||||
{blueprintDiffRows.length > 0 && (
|
||||
<NeonCard accent="purple" className="section">
|
||||
<h3>Blueprint Diff vs current form</h3>
|
||||
<ul className="blueprint-diff">
|
||||
{blueprintDiffRows.map((d) => (
|
||||
<li key={d.key} className={d.kind}>
|
||||
<strong>{d.key}</strong>: {d.kind}
|
||||
{d.kind === 'changed' && ` (${JSON.stringify(d.from)} → ${JSON.stringify(d.to)})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NeonCard>
|
||||
)}
|
||||
{recentBuilds.length === 0 ? (
|
||||
<p className="empty-text">No builds yet</p>
|
||||
) : (
|
||||
<div className="builds-list">
|
||||
{recentBuilds.map((build) => (
|
||||
<div key={build.id} className="build-item">
|
||||
<div className="build-item-name">{build.worker_name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{build.threads} threads</span>
|
||||
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>{new Date(build.created_at).toLocaleString()}</span>
|
||||
<div className="build-manager-grid">
|
||||
{recentBuilds.map((build) => {
|
||||
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
||||
return (
|
||||
<div key={build.id} className="build-manager-row">
|
||||
<div>
|
||||
<div className="build-item-name">{build.worker_name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{build.threads} threads</span>
|
||||
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>{new Date(build.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LanDownloadQR url={downloadUrl} />
|
||||
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)}>Download</a>
|
||||
<a className="btn btn-outline" href={api.buildUninstallUrl(build.id)}>Uninstall script</a>
|
||||
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
|
||||
Re-forge
|
||||
</button>
|
||||
</div>
|
||||
{build.file_path && (
|
||||
<code className="path-display small">{build.file_path}</code>
|
||||
)}
|
||||
<a className="btn btn-outline" href={`/api/v1/builds/${build.id}/download`}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Share } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import GaugeRing from '../components/Charts/GaugeRing';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
|
||||
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents } = useWebSocket();
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
|
||||
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
|
||||
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
|
||||
const [subtitle, setSubtitle] = useState('security is just an emotion');
|
||||
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [hasBuilds, setHasBuilds] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getRecentShares(20).then(setShares).catch(console.error);
|
||||
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
|
||||
api.getConfig()
|
||||
.then((cfg) => {
|
||||
const s = cfg.server?.dashboard_subtitle?.trim();
|
||||
if (s) setSubtitle(s);
|
||||
})
|
||||
.catch(console.error);
|
||||
api.getAlerts().then(setRestAlerts).catch(console.error);
|
||||
api.getPoolStatus().then(setRestPools).catch(console.error);
|
||||
api.getAIActivity().then(setRestAI).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (recentShares.length > 0) {
|
||||
setShares((prev) => {
|
||||
const merged = [...recentShares, ...prev];
|
||||
const seen = new Set<string>();
|
||||
return merged.filter((s) => {
|
||||
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}-${s.timestamp}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, 20);
|
||||
});
|
||||
}
|
||||
}, [recentShares]);
|
||||
|
||||
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
|
||||
const onlineCount = agents.filter((a) => a.status === 'online').length;
|
||||
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
|
||||
@@ -42,14 +77,35 @@ 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 totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
|
||||
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
|
||||
const pools = poolStatus.length > 0 ? poolStatus : restPools;
|
||||
const aiEntries = aiActivity.length > 0 ? aiActivity : restAI;
|
||||
const agentNameMap = useMemo(
|
||||
() => Object.fromEntries(agents.map((a) => [a.id, a.name])),
|
||||
[agents]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<AlertBanner alerts={alerts} />
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
|
||||
<h1>Command Deck</h1>
|
||||
<p className="page-subtitle">
|
||||
Your private mining fleet across the LAN — brass gauges, neon pulse, real-time hashrate.
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="deck-hero-status">
|
||||
@@ -64,6 +120,23 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NeonCard accent="green" className="section" hud>
|
||||
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
|
||||
<span className="section-ornament">◆</span> Fleet Pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Visual progress — lit nodes mean that stage is active. <Link to="/guide">Open Field Guide →</Link>
|
||||
</p>
|
||||
<FleetPipelineStatus
|
||||
hasBuilds={hasBuilds}
|
||||
agentCount={agents.length}
|
||||
onlineCount={onlineCount}
|
||||
hasHashrate={totalHashrate > 0}
|
||||
hasShares={totalShareCount > 0}
|
||||
/>
|
||||
</NeonCard>
|
||||
|
||||
<section className="gauge-row">
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<GaugeRing
|
||||
@@ -92,6 +165,7 @@ export default function DashboardPage() {
|
||||
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
|
||||
<div className="stat-sub">{onlineCount} engines firing</div>
|
||||
</NeonCard>
|
||||
<EarningsEstimator hashrate={totalHashrate} />
|
||||
<NeonCard accent="green" className="stat-card-wrap">
|
||||
<div className="stat-label font-tech">Fleet Online</div>
|
||||
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
|
||||
@@ -109,6 +183,10 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
<PoolStatusPanel pools={pools} />
|
||||
|
||||
<AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />
|
||||
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
@@ -122,6 +200,14 @@ export default function DashboardPage() {
|
||||
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||
</NeonCard>
|
||||
|
||||
<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} />
|
||||
</NeonCard>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Machine Roster
|
||||
@@ -164,6 +250,7 @@ export default function DashboardPage() {
|
||||
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
|
||||
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
|
||||
</div>
|
||||
<AgentRemoteActions agent={agent} compact />
|
||||
</NeonCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
130
server/web/src/pages/GuidePage.tsx
Normal file
130
server/web/src/pages/GuidePage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import {
|
||||
CHEAT_SECTIONS,
|
||||
PIPELINE_STEPS,
|
||||
TROUBLESHOOTING,
|
||||
} from '../help/cheatSheetContent';
|
||||
import {
|
||||
ForgeCalibrateCompare,
|
||||
PipelineFlow,
|
||||
RoadmapGrid,
|
||||
} from '../components/Visual/VisualComponents';
|
||||
import './Pages.css';
|
||||
|
||||
export default function GuidePage() {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
|
||||
<h1>Field Guide</h1>
|
||||
<p className="page-subtitle">
|
||||
Visual cheat sheet — what each page does, how the pipeline works, and what to fix when things break.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NeonCard accent="cyan" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Live pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<PipelineFlow />
|
||||
</NeonCard>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Forge vs Calibrate
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<ForgeCalibrateCompare />
|
||||
</section>
|
||||
|
||||
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
|
||||
<section key={section.id} className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> {section.title}
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint">{section.description}</p>
|
||||
{section.steps?.map((step) => (
|
||||
<div key={step.id} className="guide-step-card">
|
||||
<div className="guide-step-num">{step.icon}</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>{step.title} — {step.subtitle}</h4>
|
||||
<p>{step.body}</p>
|
||||
{step.tips && (
|
||||
<ul className="guide-tips">
|
||||
{step.tips.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{step.route && (
|
||||
<Link to={step.route} className="btn btn-outline btn-sm">
|
||||
{step.routeLabel || 'Open'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Step-by-step (detailed)
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{PIPELINE_STEPS.map((step) => (
|
||||
<div key={step.id} className="guide-step-card">
|
||||
<div className="guide-step-num">{step.icon}</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>{step.title} — {step.subtitle}</h4>
|
||||
<p>{step.body}</p>
|
||||
{step.tips && (
|
||||
<ul className="guide-tips">
|
||||
{step.tips.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{step.route && (
|
||||
<Link to={step.route} className="btn btn-primary btn-sm">
|
||||
{step.routeLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Troubleshooting
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<NeonCard accent="amber">
|
||||
{TROUBLESHOOTING.map((t) => (
|
||||
<div key={t.problem} className="trouble-card">
|
||||
<strong>{t.problem}</strong>
|
||||
<span>{t.fix}</span>
|
||||
</div>
|
||||
))}
|
||||
</NeonCard>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Product roadmap
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '1rem' }}>
|
||||
Shipped capabilities (high/medium) and remaining low-priority ideas.
|
||||
</p>
|
||||
<RoadmapGrid />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -659,6 +659,121 @@
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.preflight-panel {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.preflight-panel h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
.preflight-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.preflight-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.preflight-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preflight-ok { color: var(--neon-green); }
|
||||
.preflight-warn { color: var(--neon-amber); }
|
||||
.preflight-error { color: var(--accent-red, #f87171); }
|
||||
|
||||
.forge-submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.endpoint-group {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.endpoint-input {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.endpoint-hint {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoint-picks {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.endpoint-picks-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.endpoint-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.endpoint-chip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--neon-cyan);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.endpoint-chip:hover {
|
||||
border-color: var(--neon-cyan);
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.endpoint-chip.active {
|
||||
border-color: var(--neon-amber);
|
||||
color: var(--neon-amber);
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.builder-layout-wide {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -973,3 +1088,136 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Forge guardrails ── */
|
||||
.forge-rules-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(212, 168, 75, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(212, 168, 75, 0.06);
|
||||
}
|
||||
|
||||
.forge-rules-banner h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.forge-rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.forge-rule-card {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.forge-rule-card strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
.forge-live-notices {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid var(--neon-amber);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.forge-live-notices ul {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.forge-section-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.forge-section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forge-section-title-row h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forge-section-desc {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.forge-field-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forge-badge-baked {
|
||||
color: #86efac;
|
||||
border: 1px solid rgba(134, 239, 172, 0.35);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.forge-badge-server-only {
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(147, 197, 253, 0.35);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.forge-badge-requires {
|
||||
color: #fcd34d;
|
||||
border: 1px solid rgba(252, 211, 77, 0.35);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.forge-locked-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--neon-amber);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.form-group.field-disabled label {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.form-group.field-disabled .input,
|
||||
.form-group.field-disabled .select {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forge-forge-blocked {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-red, #f87171);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
border-radius: 6px;
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ServerConfig } from '../types';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import './Pages.css';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig()
|
||||
.then(setConfig)
|
||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||
.then(([cfg, info]) => {
|
||||
setConfig({
|
||||
...cfg,
|
||||
server: {
|
||||
public_url: cfg.server?.public_url ?? '',
|
||||
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
|
||||
build_retention_days: cfg.server?.build_retention_days ?? 30,
|
||||
pool_reconnect_seconds: cfg.server?.pool_reconnect_seconds ?? 30,
|
||||
websocket_ping_seconds: cfg.server?.websocket_ping_seconds ?? 30,
|
||||
max_agents: cfg.server?.max_agents ?? 256,
|
||||
max_build_size_mb: cfg.server?.max_build_size_mb ?? 150,
|
||||
log_agent_connections: cfg.server?.log_agent_connections ?? true,
|
||||
log_share_submissions: cfg.server?.log_share_submissions ?? false,
|
||||
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
|
||||
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
|
||||
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
|
||||
},
|
||||
});
|
||||
setServerInfo(info);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const updateField = (path: string, value: any) => {
|
||||
const updateField = (path: string, value: unknown) => {
|
||||
if (!config) return;
|
||||
const newConfig = { ...config };
|
||||
const keys = path.split('.');
|
||||
let obj: any = newConfig;
|
||||
let obj: Record<string, unknown> = newConfig as unknown as Record<string, unknown>;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
obj = obj[keys[i]];
|
||||
const key = keys[i];
|
||||
if (!obj[key] || typeof obj[key] !== 'object') {
|
||||
obj[key] = {};
|
||||
}
|
||||
obj = obj[key] as Record<string, unknown>;
|
||||
}
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
setConfig(newConfig);
|
||||
@@ -37,463 +62,350 @@ export default function SettingsPage() {
|
||||
try {
|
||||
const updated = await api.updateConfig(config);
|
||||
setConfig(updated);
|
||||
setSaveMessage('✅ Settings saved successfully');
|
||||
setTimeout(() => setSaveMessage(''), 3000);
|
||||
} catch (err: any) {
|
||||
setSaveMessage(`❌ Failed to save: ${err.message}`);
|
||||
setSaveMessage('Calibration saved — control server updated.');
|
||||
setTimeout(() => setSaveMessage(''), 4000);
|
||||
} catch (err: unknown) {
|
||||
setSaveMessage(`Save failed: ${err instanceof Error ? err.message : 'unknown error'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportConfig = () => {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'aetherforge-server-config.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImportConfig = () => fileInputRef.current?.click();
|
||||
|
||||
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setConfig((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
|
||||
} catch {
|
||||
setSaveMessage('Invalid JSON file.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">CALIBRATION</p>
|
||||
<h1>System Calibrate</h1>
|
||||
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
|
||||
<h1>Calibrate</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass"><p>Loading settings...</p></NeonCard>
|
||||
<NeonCard accent="brass"><p>Loading server calibration...</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header"><h1>Settings</h1></div>
|
||||
<div className="card"><p>Failed to load settings</p></div>
|
||||
<div className="page fade-in command-deck">
|
||||
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = config.server || {
|
||||
public_url: '',
|
||||
stats_retention_hours: 168,
|
||||
build_retention_days: 30,
|
||||
pool_reconnect_seconds: 30,
|
||||
websocket_ping_seconds: 30,
|
||||
max_agents: 256,
|
||||
max_build_size_mb: 150,
|
||||
log_agent_connections: true,
|
||||
log_share_submissions: false,
|
||||
log_pool_traffic: false,
|
||||
strict_wallet_validation: false,
|
||||
dashboard_subtitle: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
|
||||
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">CALIBRATION</p>
|
||||
<h1>System Calibrate</h1>
|
||||
<p className="page-subtitle">Pool, wallet, defaults, and install paths for the forge.</p>
|
||||
<p className="deck-eyebrow font-tech">CONTROL SERVER · LOCAL HOST</p>
|
||||
<h1>Calibrate</h1>
|
||||
<p className="page-subtitle">
|
||||
Settings for this machine only — the dashboard, pool relay, and LAN address. Miner installers are built in the Forge tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="deck-hero-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Calibration'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
|
||||
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{saveMessage && (
|
||||
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
|
||||
{saveMessage}
|
||||
</div>
|
||||
<div className={`save-message ${saveMessage.includes('failed') ? 'error' : 'success'}`}>{saveMessage}</div>
|
||||
)}
|
||||
|
||||
{serverInfo && (
|
||||
<NeonCard accent="cyan" className="calibrate-banner" hud>
|
||||
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
|
||||
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
|
||||
{serverInfo.local_ips?.length > 0 && (
|
||||
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
|
||||
)}
|
||||
<p className="form-hint">Set Public URL below if you want the Forge to default to a specific address.</p>
|
||||
</NeonCard>
|
||||
)}
|
||||
|
||||
<div className="settings-grid">
|
||||
{/* Pool Configuration */}
|
||||
<div className="card settings-section">
|
||||
<h2>Pool Connection</h2>
|
||||
<p className="section-desc">Configure which Monero pool your miners connect to.</p>
|
||||
<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>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Listen Port</label>
|
||||
<input type="number" className="input" min={1024} max={65535} value={config.port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
|
||||
<span className="form-hint">Restart server after changing port.</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Data Directory</label>
|
||||
<input type="text" className="input mono" value={config.data_dir}
|
||||
onChange={(e) => updateField('data_dir', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
|
||||
<input type="text" className="input mono" placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
|
||||
value={s.public_url}
|
||||
onChange={(e) => updateField('server.public_url', e.target.value)} />
|
||||
<FieldHint field="public_url" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Dashboard Subtitle</label>
|
||||
<input type="text" className="input" value={s.dashboard_subtitle}
|
||||
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="cyan" className="settings-section">
|
||||
<h2 className="font-display">Upstream Pool</h2>
|
||||
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host <HelpTip field="pool_host" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.pool.host}
|
||||
onChange={(e) => updateField('pool.host', e.target.value)}
|
||||
placeholder="pool.supportxmr.com"
|
||||
/>
|
||||
<input type="text" className="input" value={config.pool.host}
|
||||
onChange={(e) => updateField('pool.host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Port <HelpTip field="pool_port" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={config.pool.port}
|
||||
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)}
|
||||
/>
|
||||
<label className="label">Port</label>
|
||||
<input type="number" className="input" value={config.pool.port}
|
||||
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={config.pool.use_tls}
|
||||
onChange={(e) => updateField('pool.use_tls', e.target.checked)}
|
||||
/>
|
||||
<span>Use TLS/SSL</span>
|
||||
<input type="checkbox" className="checkbox" checked={config.pool.use_tls}
|
||||
onChange={(e) => updateField('pool.use_tls', e.target.checked)} />
|
||||
<span>Use TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Password (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.pool.password}
|
||||
onChange={(e) => updateField('pool.password', e.target.value)}
|
||||
placeholder="x"
|
||||
/>
|
||||
<label className="label">Pool Password</label>
|
||||
<input type="text" className="input" value={config.pool.password}
|
||||
onChange={(e) => updateField('pool.password', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Configuration */}
|
||||
<div className="card settings-section">
|
||||
<h2>Wallet</h2>
|
||||
<p className="section-desc">Default wallet address for new miners.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)}
|
||||
placeholder="4..."
|
||||
/>
|
||||
<label className="label">Pool Reconnect Interval (sec)</label>
|
||||
<input type="number" className="input" min={5} value={s.pool_reconnect_seconds}
|
||||
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="purple" className="settings-section">
|
||||
<h2 className="font-display">Fleet Payout Wallet</h2>
|
||||
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Address <HelpTip field="wallet" /></label>
|
||||
<input type="text" className="input mono" value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Payment ID (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={config.wallet.payment_id}
|
||||
onChange={(e) => updateField('wallet.payment_id', e.target.value)}
|
||||
/>
|
||||
<input type="text" className="input mono" value={config.wallet.payment_id}
|
||||
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={s.strict_wallet_validation}
|
||||
onChange={(e) => updateField('server.strict_wallet_validation', e.target.checked)} />
|
||||
<span>Strict wallet validation on build API</span>
|
||||
</label>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
{/* Default Agent Config */}
|
||||
<div className="card settings-section">
|
||||
<h2>Default Agent Configuration</h2>
|
||||
<p className="section-desc">Default settings applied to newly built miners.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.default_agent_config.thread_mode || 'percent'}
|
||||
onChange={(e) => updateField('default_agent_config.thread_mode', e.target.value)}
|
||||
>
|
||||
<option value="percent">Auto (% of cores)</option>
|
||||
<option value="fixed">Fixed count</option>
|
||||
</select>
|
||||
</div>
|
||||
{config.default_agent_config.thread_mode === 'fixed' ? (
|
||||
<div className="form-group">
|
||||
<label className="label">Threads <HelpTip field="threads" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={128}
|
||||
value={config.default_agent_config.threads}
|
||||
onChange={(e) => updateField('default_agent_config.threads', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={10}
|
||||
max={100}
|
||||
value={config.default_agent_config.thread_percent ?? 75}
|
||||
onChange={(e) => updateField('default_agent_config.thread_percent', parseInt(e.target.value) || 75)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.default_agent_config.cpu_priority}
|
||||
onChange={(e) => updateField('default_agent_config.cpu_priority', e.target.value)}
|
||||
>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="below_normal">Below Normal</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="above_normal">Above Normal</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.default_agent_config.max_cpu_usage_pct}
|
||||
onChange={(e) => updateField('default_agent_config.max_cpu_usage_pct', parseInt(e.target.value) || 80)}
|
||||
/>
|
||||
</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={config.default_agent_config.max_memory_percent ?? 70}
|
||||
onChange={(e) => updateField('default_agent_config.max_memory_percent', parseInt(e.target.value) || 70)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={256}
|
||||
value={config.default_agent_config.min_free_ram_mb}
|
||||
onChange={(e) => updateField('default_agent_config.min_free_ram_mb', parseInt(e.target.value) || 1024)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<h3>Install Location Defaults</h3>
|
||||
<p className="section-desc">Where built installers embed the miner on first run.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.default_agent_config.install_base || 'localappdata'}
|
||||
onChange={(e) => updateField('default_agent_config.install_base', e.target.value)}
|
||||
>
|
||||
<option value="localappdata">Local App Data (%LOCALAPPDATA%)</option>
|
||||
<option value="appdata">Roaming App Data (%APPDATA%)</option>
|
||||
<option value="programdata">Program Data (%ProgramData%)</option>
|
||||
<option value="userprofile">User Profile (%USERPROFILE%)</option>
|
||||
<option value="temp">Temp Folder (%TEMP%)</option>
|
||||
<option value="custom">Custom Path</option>
|
||||
</select>
|
||||
</div>
|
||||
{config.default_agent_config.install_base === 'custom' && (
|
||||
<div className="form-group">
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={config.default_agent_config.install_custom_base || ''}
|
||||
onChange={(e) => updateField('default_agent_config.install_custom_base', e.target.value)}
|
||||
placeholder="%ProgramData%\\HiddenApps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="label">Install Subfolder <HelpTip field="install_relative_path" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={config.default_agent_config.install_relative_path || 'CryptoMiner/{worker}-{build_short}'}
|
||||
onChange={(e) => updateField('default_agent_config.install_relative_path', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={config.default_agent_config.adapt_to_hardware ?? true}
|
||||
onChange={(e) => updateField('default_agent_config.adapt_to_hardware', e.target.checked)} />
|
||||
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={config.default_agent_config.self_healing ?? true}
|
||||
onChange={(e) => updateField('default_agent_config.self_healing', e.target.checked)} />
|
||||
<span>Self-healing <HelpTip field="self_healing" /></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={config.default_agent_config.stealth_mode ?? false}
|
||||
onChange={(e) => {
|
||||
updateField('default_agent_config.stealth_mode', e.target.checked);
|
||||
if (e.target.checked) updateField('default_agent_config.file_logging', false);
|
||||
}} />
|
||||
<span>Stealth mode <HelpTip field="stealth_mode" /></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Display Mode <HelpTip field="display_mode" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.default_agent_config.display_mode || 'background'}
|
||||
onChange={(e) => updateField('default_agent_config.display_mode', e.target.value)}
|
||||
>
|
||||
<option value="visible">Visible (console)</option>
|
||||
<option value="silent">Silent (hidden window)</option>
|
||||
<option value="background">Background (hidden + low priority)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Process Name <HelpTip field="process_name" /></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.default_agent_config.process_name || 'RuntimeBrokerHelper'}
|
||||
onChange={(e) => updateField('default_agent_config.process_name', e.target.value)}
|
||||
placeholder="RuntimeBrokerHelper"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.default_agent_config.mining_mode}
|
||||
onChange={(e) => updateField('default_agent_config.mining_mode', e.target.value)}
|
||||
>
|
||||
<option value="always">Always Mine</option>
|
||||
<option value="idle">Only When Idle</option>
|
||||
<option value="scheduled">Scheduled Hours</option>
|
||||
</select>
|
||||
<label className="label">Offline After (minutes)</label>
|
||||
<input type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
|
||||
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
{config.default_agent_config.mining_mode === 'idle' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Idle CPU Threshold (%) <HelpTip field="idle_threshold_pct" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.default_agent_config.idle_threshold_pct}
|
||||
onChange={(e) => updateField('default_agent_config.idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Idle Duration (min) <HelpTip field="idle_duration_minutes" /></label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
value={config.default_agent_config.idle_duration_minutes}
|
||||
onChange={(e) => updateField('default_agent_config.idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Hashrate Drop (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Rejection Rate (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<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>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Bot Token</label>
|
||||
<input type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Chat ID</label>
|
||||
<input type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}
|
||||
onChange={(e) => updateField('alerts.email_enabled', e.target.checked)} />
|
||||
<span>Email alerts via SMTP</span>
|
||||
</label>
|
||||
</div>
|
||||
{config.alerts.email_enabled && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Host</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_host || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Port</label>
|
||||
<input type="number" className="input" value={config.alerts.smtp_port || 587}
|
||||
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP User</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_user || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Password</label>
|
||||
<input type="password" className="input" value={config.alerts.smtp_password || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Email To</label>
|
||||
<input type="email" className="input" value={config.alerts.email_to || ''}
|
||||
onChange={(e) => updateField('alerts.email_to', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Email From</label>
|
||||
<input type="email" className="input" value={config.alerts.email_from || ''}
|
||||
onChange={(e) => updateField('alerts.email_from', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{config.default_agent_config.mining_mode === 'scheduled' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Start Time <HelpTip field="schedule_start" /></label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
value={config.default_agent_config.schedule_start}
|
||||
onChange={(e) => updateField('default_agent_config.schedule_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">End Time <HelpTip field="schedule_end" /></label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
value={config.default_agent_config.schedule_end}
|
||||
onChange={(e) => updateField('default_agent_config.schedule_end', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<h2 className="font-display">Data & Limits</h2>
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Stats Retention (hours)</label>
|
||||
<input type="number" className="input" min={24} value={s.stats_retention_hours}
|
||||
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Keep Builds (days)</label>
|
||||
<input type="number" className="input" min={1} value={s.build_retention_days}
|
||||
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Max Agents</label>
|
||||
<input type="number" className="input" min={1} value={s.max_agents}
|
||||
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Build Size (MB)</label>
|
||||
<input type="number" className="input" min={10} value={s.max_build_size_mb}
|
||||
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">WebSocket Ping (sec)</label>
|
||||
<input type="number" className="input" min={10} value={s.websocket_ping_seconds}
|
||||
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
{/* Background / Silent Mode */}
|
||||
<div className="card settings-section">
|
||||
<h2>Background & Deployment</h2>
|
||||
<p className="section-desc">How miners behave on target machines.</p>
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<h2 className="font-display">Server Logging</h2>
|
||||
<p className="section-desc">What this control server writes to its log.</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={config.background.silent_mode}
|
||||
onChange={(e) => updateField('background.silent_mode', e.target.checked)}
|
||||
/>
|
||||
<span>Silent Mode (no console window) <HelpTip field="silent_mode" /></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Run As <HelpTip field="run_as" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={config.background.run_as}
|
||||
onChange={(e) => updateField('background.run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User</option>
|
||||
<option value="service">Windows Service</option>
|
||||
<option value="scheduled">Scheduled Task</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={config.background.auto_start}
|
||||
onChange={(e) => updateField('background.auto_start', e.target.checked)}
|
||||
/>
|
||||
<span>Auto-start with Windows <HelpTip field="auto_start" /></span>
|
||||
<input type="checkbox" className="checkbox" checked={s.log_agent_connections}
|
||||
onChange={(e) => updateField('server.log_agent_connections', e.target.checked)} />
|
||||
<span>Log agent connect / disconnect</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={config.background.minimize_to_tray}
|
||||
onChange={(e) => updateField('background.minimize_to_tray', e.target.checked)}
|
||||
/>
|
||||
<span>Minimize to System Tray</span>
|
||||
<input type="checkbox" className="checkbox" checked={s.log_share_submissions}
|
||||
onChange={(e) => updateField('server.log_share_submissions', e.target.checked)} />
|
||||
<span>Log every share submission</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div className="card settings-section">
|
||||
<h2>Alerts</h2>
|
||||
<p className="section-desc">Configure thresholds for fleet health alerts.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Offline Threshold (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
value={config.alerts.offline_threshold_minutes}
|
||||
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)}
|
||||
/>
|
||||
<span className="form-hint">Alert if agent hasn't reported in this many minutes</span>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={s.log_pool_traffic}
|
||||
onChange={(e) => updateField('server.log_pool_traffic', e.target.checked)} />
|
||||
<span>Verbose pool traffic (debug)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Hashrate Drop Threshold (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.alerts.hashrate_drop_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)}
|
||||
/>
|
||||
<span className="form-hint">Alert if hashrate drops by this percentage</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Rejection Rate Threshold (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.alerts.rejection_rate_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)}
|
||||
/>
|
||||
<span className="form-hint">Alert if share rejection rate exceeds this percentage</span>
|
||||
</div>
|
||||
</div>
|
||||
</NeonCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,9 +78,27 @@ export interface ServerConfig {
|
||||
data_dir: string;
|
||||
pool: PoolConfig;
|
||||
wallet: WalletConfig;
|
||||
default_agent_config: AgentDefaults;
|
||||
background: BackgroundConfig;
|
||||
server: ServerSettings;
|
||||
alerts: AlertsConfig;
|
||||
/** @deprecated Legacy JSON only — Forge bakes per-miner settings; not used by Calibrate UI. */
|
||||
default_agent_config?: AgentDefaults;
|
||||
/** @deprecated Legacy JSON only — not used at runtime. */
|
||||
background?: BackgroundConfig;
|
||||
}
|
||||
|
||||
export interface ServerSettings {
|
||||
public_url: string;
|
||||
stats_retention_hours: number;
|
||||
build_retention_days: number;
|
||||
pool_reconnect_seconds: number;
|
||||
websocket_ping_seconds: number;
|
||||
max_agents: number;
|
||||
max_build_size_mb: number;
|
||||
log_agent_connections: boolean;
|
||||
log_share_submissions: boolean;
|
||||
log_pool_traffic: boolean;
|
||||
strict_wallet_validation: boolean;
|
||||
dashboard_subtitle: string;
|
||||
}
|
||||
|
||||
export interface PoolConfig {
|
||||
@@ -130,12 +148,60 @@ export interface AlertsConfig {
|
||||
offline_threshold_minutes: number;
|
||||
hashrate_drop_threshold_pct: number;
|
||||
rejection_rate_threshold_pct: number;
|
||||
telegram_bot_token?: string;
|
||||
telegram_chat_id?: string;
|
||||
email_enabled?: boolean;
|
||||
smtp_host?: string;
|
||||
smtp_port?: number;
|
||||
smtp_user?: string;
|
||||
smtp_password?: string;
|
||||
email_to?: string;
|
||||
email_from?: string;
|
||||
}
|
||||
|
||||
export interface FleetAlert {
|
||||
id: string;
|
||||
level: 'warn' | 'error';
|
||||
type: string;
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PoolStatus {
|
||||
key: string;
|
||||
host: string;
|
||||
port: number;
|
||||
use_tls: boolean;
|
||||
wallet: string;
|
||||
connected: boolean;
|
||||
status: 'green' | 'yellow' | 'red';
|
||||
}
|
||||
|
||||
export interface AIActivityEntry {
|
||||
agent_id: string;
|
||||
last_decide_at?: string;
|
||||
last_action?: string;
|
||||
last_tool?: string;
|
||||
tool_call_count?: number;
|
||||
last_reasoning?: string;
|
||||
last_report_at?: string;
|
||||
last_success?: boolean;
|
||||
}
|
||||
|
||||
export interface EarningsEstimate {
|
||||
hashrate: number;
|
||||
xmr_per_day: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface BuildRequest {
|
||||
worker_name: string;
|
||||
server_url: string;
|
||||
wallet: string;
|
||||
/** Relative folder under server data_dir to also copy the built exe into. */
|
||||
output_dir?: string;
|
||||
threads: number;
|
||||
thread_mode: string;
|
||||
thread_percent: number;
|
||||
@@ -168,6 +234,10 @@ export interface BuildRequest {
|
||||
fusion_enabled: boolean;
|
||||
fusion_run_order: string;
|
||||
fusion_output_name: string;
|
||||
// AI Autonomy (Ollama)
|
||||
ai_enabled: boolean;
|
||||
ai_ollama_endpoint: string;
|
||||
ai_model: string;
|
||||
}
|
||||
|
||||
export interface BuildResponse {
|
||||
@@ -178,11 +248,21 @@ export interface BuildResponse {
|
||||
relative_path?: string;
|
||||
file_size?: number;
|
||||
download_url?: string;
|
||||
uninstall_file_name?: string;
|
||||
uninstall_path?: string;
|
||||
uninstall_download_url?: string;
|
||||
error?: string;
|
||||
fusion_enabled?: boolean;
|
||||
worker_file?: string;
|
||||
}
|
||||
|
||||
export interface BlueprintInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
|
||||
8
server/web/vitest.config.ts
Normal file
8
server/web/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user