feat: add vue-i18n infrastructure and externalize all UI strings (A11Y-03)
Set up vue-i18n with English locale file containing 500+ keys organized by view namespace. All 15 views converted to use t() calls instead of hardcoded strings. Infrastructure ready for community translations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1131251f9
commit
d15e90c26d
@ -358,7 +358,7 @@
|
||||
|
||||
- [x] **A11Y-02** — Add keyboard navigation testing. Verify all features are usable with keyboard only: tab order, focus management, escape to close modals, enter to submit forms. Fix any gaps. **Acceptance**: Complete user journey possible with keyboard only.
|
||||
|
||||
- [ ] **A11Y-03** — Set up i18n infrastructure. Install `vue-i18n`. Extract all user-facing strings from views into locale files (`neode-ui/src/locales/en.json`). Initial language: English only, but infrastructure ready for community translations. **Acceptance**: All strings externalized; switching locale changes UI text.
|
||||
- [x] **A11Y-03** — Set up i18n infrastructure. Install `vue-i18n`. Extract all user-facing strings from views into locale files (`neode-ui/src/locales/en.json`). Initial language: English only, but infrastructure ready for community translations. **Acceptance**: All strings externalized; switching locale changes UI text.
|
||||
|
||||
### Q2 2028 (June -- August): Penetration Testing, Final QA
|
||||
|
||||
|
||||
565
neode-ui/package-lock.json
generated
565
neode-ui/package-lock.json
generated
@ -13,12 +13,15 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.10.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
@ -50,6 +53,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz",
|
||||
@ -487,12 +504,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
|
||||
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -1673,9 +1690,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
|
||||
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@ -1691,6 +1708,16 @@
|
||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@canvas/image-data": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz",
|
||||
@ -2697,6 +2724,67 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
|
||||
"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
"@intlify/message-compiler": "11.3.0",
|
||||
"@intlify/shared": "11.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
|
||||
"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/shared": "11.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
|
||||
"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.3.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
|
||||
"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
@ -2738,6 +2826,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@ -2864,6 +2962,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@ -3495,6 +3609,65 @@
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"ast-v8-to-istanbul": "^0.3.3",
|
||||
"debug": "^4.4.1",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.1.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.9.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "3.2.4",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@ -4034,6 +4207,35 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
|
||||
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/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/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@ -6295,6 +6497,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@ -6973,6 +7182,98 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
@ -7365,6 +7666,47 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -7892,6 +8234,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@ -8435,6 +8824,7 @@
|
||||
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@ -9486,7 +9876,6 @@
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@ -9507,6 +9896,138 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@ -10288,6 +10809,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@ -10429,6 +10951,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
"@intlify/shared": "11.3.0",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@ -28,26 +28,29 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.10.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"vitest": "^3.1.1",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"concurrently": "^9.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.1.1",
|
||||
"vue-tsc": "^3.1.3",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
|
||||
15
neode-ui/src/i18n.ts
Normal file
15
neode-ui/src/i18n.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
|
||||
export type MessageSchema = typeof en
|
||||
|
||||
const i18n = createI18n<[MessageSchema], 'en'>({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
696
neode-ui/src/locales/en.json
Normal file
696
neode-ui/src/locales/en.json
Normal file
@ -0,0 +1,696 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copiedBang": "Copied!",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"uninstall": "Uninstall",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"launch": "Launch",
|
||||
"starting": "Starting...",
|
||||
"stopping": "Stopping...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"manage": "Manage",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnect",
|
||||
"running": "running",
|
||||
"stopped": "stopped",
|
||||
"exited": "exited",
|
||||
"healthy": "Healthy",
|
||||
"elevated": "Elevated",
|
||||
"critical": "Critical",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"synced": "Synced",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"dismiss": "Dismiss",
|
||||
"apply": "Apply",
|
||||
"configure": "Configure",
|
||||
"export": "Export",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"error": "Error",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"category": "Category",
|
||||
"developer": "Developer",
|
||||
"license": "License",
|
||||
"never": "Never",
|
||||
"notAvailable": "Not Available",
|
||||
"goBack": "Go back",
|
||||
"skipToContent": "Skip to main content",
|
||||
"continue": "Continue",
|
||||
"verify": "Verify",
|
||||
"create": "Create",
|
||||
"restore": "Restore",
|
||||
"disabling": "Disabling...",
|
||||
"creating": "Creating...",
|
||||
"restoring": "Restoring...",
|
||||
"manageUpdates": "Manage Updates",
|
||||
"enableAll": "Enable All",
|
||||
"networkDiagnostics": "Network Diagnostics",
|
||||
"network": "Network",
|
||||
"saveConfiguration": "Save Configuration",
|
||||
"sendTest": "Send Test"
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome to Archipelago",
|
||||
"setupTitle": "Set Up Your Node",
|
||||
"twoFactorTitle": "Two-Factor Authentication",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"enterPasswordPlaceholder": "Enter your password",
|
||||
"enterPasswordSetup": "Enter a password (min 8 characters)",
|
||||
"confirmPasswordPlaceholder": "Confirm your password",
|
||||
"setupButton": "Set Up Node",
|
||||
"settingUp": "Setting up...",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"verifyButton": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"useAuthCode": "Use authenticator code",
|
||||
"useBackupCode": "Use a backup code instead",
|
||||
"totpInstruction": "Enter the 6-digit code from your authenticator app",
|
||||
"totpPlaceholder": "000000",
|
||||
"backupCodePlaceholder": "XXXX-XXXX",
|
||||
"serverStarting": "Server starting up...",
|
||||
"replayIntro": "Replay Intro",
|
||||
"onboarding": "Onboarding",
|
||||
"resetting": "Resetting...",
|
||||
"recoveryNote": "Password recovery requires SSH access to the server.",
|
||||
"errorMinLength": "Password must be at least 8 characters",
|
||||
"errorMismatch": "Passwords do not match",
|
||||
"errorServerStarting": "Server is starting up. Please try again in a moment.",
|
||||
"errorSetupFailed": "Setup failed. Please try again.",
|
||||
"errorLoginFailed": "Login failed. Please check your password.",
|
||||
"errorInvalidCode": "Invalid code. Please try again.",
|
||||
"totpLabel": "Two-factor authentication code"
|
||||
},
|
||||
"home": {
|
||||
"title": "Welcome Noderunner",
|
||||
"subtitle": "Here's an overview of your sovereign life",
|
||||
"dashboardTab": "Dashboard",
|
||||
"setupTab": "Setup",
|
||||
"myApps": "My Apps",
|
||||
"myAppsDesc": "Manage your installed applications",
|
||||
"cloud": "Cloud",
|
||||
"cloudDesc": "Cloud services and storage",
|
||||
"network": "Network",
|
||||
"networkDesc": "Network infrastructure and Web3 services",
|
||||
"web5": "Web5",
|
||||
"web5Desc": "Decentralized identity and data protocols",
|
||||
"system": "System",
|
||||
"quickStartGoals": "Quick Start Goals",
|
||||
"quickStartDesc": "Not sure where to start? Try a guided setup.",
|
||||
"installed": "Installed",
|
||||
"runningLabel": "Running",
|
||||
"storageUsed": "Storage Used",
|
||||
"folders": "Folders",
|
||||
"servicesStatus": "Services Status",
|
||||
"connectivity": "Connectivity",
|
||||
"runningApps": "Running Apps",
|
||||
"didStatus": "DID Status",
|
||||
"dwnSync": "DWN Sync",
|
||||
"credentials": "Credentials",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"disk": "Disk",
|
||||
"browseStore": "Browse Store",
|
||||
"manageApps": "Manage Apps",
|
||||
"viewFolders": "View Folders",
|
||||
"uploadFiles": "Upload Files",
|
||||
"manageNetwork": "Manage Network",
|
||||
"manageWeb5": "Manage Web5",
|
||||
"openAI": "Open AI Assistant",
|
||||
"noApps": "No Apps",
|
||||
"allRunning": "All Running",
|
||||
"systemMonitoring": "System monitoring",
|
||||
"updateAvailable": "Update Available: v{version}",
|
||||
"updateNow": "Update Now",
|
||||
"goToApps": "Go to Apps",
|
||||
"goToCloud": "Go to Cloud",
|
||||
"goToNetwork": "Go to Network",
|
||||
"goToWeb5": "Go to Web5",
|
||||
"goToSettings": "Go to Settings"
|
||||
},
|
||||
"apps": {
|
||||
"title": "My Apps",
|
||||
"subtitle": "Manage your installed applications",
|
||||
"searchPlaceholder": "Search installed apps...",
|
||||
"noAppsTitle": "No Apps Installed",
|
||||
"noAppsMessage": "Get started by browsing the app store",
|
||||
"browseAppStore": "Browse App Store",
|
||||
"noResults": "No apps matching \"{query}\"",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"dismissError": "Dismiss error",
|
||||
"searchLabel": "Search installed apps"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Configure your Archipelago experience",
|
||||
"account": "Account",
|
||||
"interfaceMode": "Interface Mode",
|
||||
"claudeAuth": "Claude Authentication",
|
||||
"aiDataAccess": "AI Data Access",
|
||||
"serverName": "Server Name",
|
||||
"sessionStatus": "Session Status",
|
||||
"yourDid": "Your DID",
|
||||
"onionAddress": "Node .onion Address",
|
||||
"loggedIn": "Currently logged in",
|
||||
"didHelper": "Decentralized identifier for passwordless auth",
|
||||
"onionHelper": "Onion address for node interface and peer discovery over Tor",
|
||||
"changePassword": "Change Password",
|
||||
"enable2fa": "Enable 2FA",
|
||||
"disable2fa": "Disable 2FA",
|
||||
"logout": "Logout",
|
||||
"loggingOut": "Logging out...",
|
||||
"twoFactorAuth": "Two-Factor Authentication",
|
||||
"twoFaProtect": "Protect your account with an authenticator app",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"changePasswordDesc": "Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password",
|
||||
"passwordPlaceholder": "12+ chars, upper, lower, digit, special",
|
||||
"updateSshCheckbox": "Also update SSH password (recommended)",
|
||||
"updatePassword": "Update Password",
|
||||
"updatingPassword": "Updating...",
|
||||
"setup2faTitle": "Two-Factor Authentication",
|
||||
"setup2faPasswordPrompt": "Enter your password to begin setup.",
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"scanQrInstruction": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.",
|
||||
"manualEntryKey": "Manual entry key:",
|
||||
"verifyAndEnable": "Verify & Enable",
|
||||
"saveBackupCodes": "Save Your Backup Codes",
|
||||
"backupCodesInstruction": "Store these codes safely. Each can be used once if you lose access to your authenticator app.",
|
||||
"copyAllCodes": "Copy All Codes",
|
||||
"disable2faTitle": "Disable Two-Factor Authentication",
|
||||
"disable2faDesc": "Enter your password and a current TOTP code to disable 2FA.",
|
||||
"authenticatorCode": "Authenticator Code",
|
||||
"webhooks": "Webhooks",
|
||||
"webhooksDesc": "Get notified when important events happen on your node",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "https://example.com/webhook",
|
||||
"webhookSecret": "Secret (for HMAC signing)",
|
||||
"webhookSecretPlaceholder": "Optional shared secret",
|
||||
"webhookEvents": "Events",
|
||||
"containerCrash": "Container Crash",
|
||||
"updateAvailableEvent": "Update Available",
|
||||
"diskWarning": "Disk Warning",
|
||||
"backupComplete": "Backup Complete",
|
||||
"saveWebhook": "Save",
|
||||
"savingWebhook": "Saving...",
|
||||
"testWebhook": "Test",
|
||||
"testingWebhook": "Testing...",
|
||||
"webhookSaved": "Webhook configuration saved",
|
||||
"webhookTestSent": "Test webhook sent successfully",
|
||||
"systemUpdates": "System Updates",
|
||||
"backup": "Backup & Restore",
|
||||
"backupDesc": "Back up your node data to external storage",
|
||||
"createBackup": "Create Backup",
|
||||
"creatingBackup": "Creating...",
|
||||
"restoreBackup": "Restore Backup",
|
||||
"deleteBackup": "Delete backup",
|
||||
"backupCreated": "Backup created successfully",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Broadcast Message",
|
||||
"messagePlaceholder": "Enter your message...",
|
||||
"messageSent": "Message sent",
|
||||
"claudeConnected": "Connected to Claude",
|
||||
"claudeDisconnected": "Not connected",
|
||||
"claudeApiKey": "API Key",
|
||||
"claudeApiKeyPlaceholder": "Enter your Anthropic API key",
|
||||
"claudeSave": "Save Key",
|
||||
"advancedMode": "Advanced Mode",
|
||||
"beginnerMode": "Beginner Mode",
|
||||
"advancedModeDesc": "Show all system controls and developer tools",
|
||||
"beginnerModeDesc": "Simplified interface with guided experience",
|
||||
"networkSettings": "Network Settings",
|
||||
"torEnabled": "Tor Enabled",
|
||||
"torAddress": "Tor Address",
|
||||
"interfaceModeDesc": "Choose how you want to interact with your node.",
|
||||
"claudeAuthDesc": "Connect your Claude Max account to enable AI chat features.",
|
||||
"connectionStatus": "Connection Status",
|
||||
"notConnected": "Not connected",
|
||||
"reAuthenticate": "Re-authenticate",
|
||||
"loginWithClaude": "Login with Claude",
|
||||
"aiDataAccessDesc": "Control what data the AI assistant can see. All categories are off by default.",
|
||||
"enableAllDesc": "Grant access to all data categories at once",
|
||||
"systemUpdatesDesc": "Check for and install software updates",
|
||||
"webhookNotifications": "Webhook Notifications",
|
||||
"webhookNotificationsDesc": "Get push notifications for critical events via webhook",
|
||||
"enableWebhooks": "Enable webhooks",
|
||||
"disableWebhooks": "Disable webhooks",
|
||||
"webhookUrlLabel": "Webhook URL",
|
||||
"webhookSecretLabel": "Secret (optional, for HMAC-SHA256 signing)",
|
||||
"eventsToNotify": "Events to notify",
|
||||
"containerCrashDesc": "A running container stops unexpectedly",
|
||||
"updateAvailableDesc": "A new system or app update is ready",
|
||||
"diskWarningDesc": "Disk usage exceeds warning threshold",
|
||||
"backupCompleteDesc": "A scheduled or manual backup finishes",
|
||||
"backupRestoreDesc": "Encrypted backups of your identity, settings, and data",
|
||||
"loadingBackups": "Loading backups...",
|
||||
"noBackups": "No backups yet. Create one to protect your node data.",
|
||||
"systemBackup": "System Backup",
|
||||
"createEncryptedBackup": "Create Encrypted Backup",
|
||||
"encryptionPassphrase": "Encryption Passphrase",
|
||||
"enterPassphrase": "Enter a strong passphrase",
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"descriptionPlaceholder": "e.g. Before update",
|
||||
"restoreBackupTitle": "Restore Backup",
|
||||
"restoreWarning": "This will overwrite current node data. Make sure you have the correct passphrase.",
|
||||
"enterBackupPassphrase": "Enter backup passphrase",
|
||||
"networkDesc": "Network connectivity, UPnP, and diagnostics",
|
||||
"webhookSecretPlaceholderFull": "Shared secret for payload signing",
|
||||
"backupCreatedSuccess": "Backup created successfully",
|
||||
"backupCreateFailed": "Failed to create backup",
|
||||
"backupVerifiedOk": "Backup verified — integrity OK",
|
||||
"backupVerifyFailed": "Verification failed: {error}",
|
||||
"backupVerifyRequestFailed": "Verification request failed",
|
||||
"backupRestored": "Backup restored. Restart may be needed.",
|
||||
"backupRestoreFailed": "Restore failed — check passphrase",
|
||||
"backupDeleted": "Backup deleted",
|
||||
"backupDeleteFailed": "Failed to delete backup",
|
||||
"noUsbDrives": "No mounted USB drives found. Insert and mount a USB drive first.",
|
||||
"backupCopiedToUsb": "Backup copied to {path}",
|
||||
"backupUsbFailed": "Failed to copy backup to USB",
|
||||
"deleteBackupConfirm": "Delete this backup permanently?",
|
||||
"verifyPassphrasePrompt": "Enter backup passphrase to verify:",
|
||||
"webhookSaveFailed": "Failed to save webhook configuration",
|
||||
"webhookTestFailed": "Test failed: webhook not sent",
|
||||
"webhookSendFailed": "Failed to send test webhook",
|
||||
"passwordAllFieldsRequired": "All fields are required",
|
||||
"passwordMismatch": "New passwords do not match",
|
||||
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
|
||||
"passwordNeedLowercase": "Password must contain at least one lowercase letter",
|
||||
"passwordNeedDigit": "Password must contain at least one digit",
|
||||
"passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)",
|
||||
"setupFailed": "Setup failed",
|
||||
"verificationFailed": "Verification failed",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"copyToUsb": "Copy to USB",
|
||||
"diskSpaceWarning": "Disk Space Warning",
|
||||
"modeEasy": "Easy",
|
||||
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
|
||||
"modePro": "Pro",
|
||||
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
|
||||
"modeChat": "Chat",
|
||||
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "App Store",
|
||||
"subtitle": "Discover and install apps for your new sovereign life",
|
||||
"curatedTab": "Curated",
|
||||
"communityTab": "Community",
|
||||
"filterByCategory": "Filter by Category",
|
||||
"searchPlaceholder": "Search apps...",
|
||||
"downloading": "Downloading...",
|
||||
"alreadyInstalled": "Already Installed",
|
||||
"queryingRelays": "Querying Nostr relays for apps...",
|
||||
"noCommunityApps": "No community apps discovered yet.",
|
||||
"noResults": "No apps found in {category} matching \"{query}\"",
|
||||
"noResultsCategory": "No apps found in {category}",
|
||||
"noResultsSearch": "No apps matching \"{query}\"",
|
||||
"all": "All",
|
||||
"community": "Community",
|
||||
"commerce": "Commerce",
|
||||
"money": "Money",
|
||||
"data": "Data",
|
||||
"homeCategory": "Home",
|
||||
"auto": "Auto",
|
||||
"networking": "Networking",
|
||||
"other": "Other",
|
||||
"searchApps": "Search apps",
|
||||
"percentComplete": "{percent}% complete"
|
||||
},
|
||||
"dashboard": {
|
||||
"mainNav": "Main navigation",
|
||||
"mobileNav": "Mobile navigation"
|
||||
},
|
||||
"chat": {
|
||||
"close": "Close",
|
||||
"aiuiConnected": "AIUI connected",
|
||||
"closeAssistant": "Close AI Assistant",
|
||||
"loadingAssistant": "Loading AI assistant...",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"notConfigured": "AI Assistant is not yet configured on this node.",
|
||||
"deployCta": "Deploy the AIUI app from the App Store to enable this feature."
|
||||
},
|
||||
"web5": {
|
||||
"title": "Web5",
|
||||
"subtitle": "Decentralized identity and data protocols",
|
||||
"profitsHelper": "Earn networking profits by hosting decentralized services",
|
||||
"networkingProfits": "Networking Profits",
|
||||
"didStatus": "DID Status",
|
||||
"walletConnection": "Wallet Connection",
|
||||
"wallet": "Wallet",
|
||||
"walletSubtitle": "On-chain, Lightning & Ecash",
|
||||
"nostrRelays": "Nostr Relays",
|
||||
"connectedNodes": "Connected Nodes",
|
||||
"bitcoinDomains": "Bitcoin Domain Names",
|
||||
"domainsSubtitle": "NIP-05 verified identities",
|
||||
"copyDid": "Copy DID",
|
||||
"viewDidDocument": "View DID Document",
|
||||
"createDid": "Create DID",
|
||||
"creatingDid": "Creating...",
|
||||
"manageDomains": "Manage Domains",
|
||||
"relaysConnected": "{count} connected",
|
||||
"peersKnown": "{count} peer(s) known",
|
||||
"sendMessage": "Send Message",
|
||||
"sendMessageTitle": "Send Message (over Tor)",
|
||||
"to": "To",
|
||||
"selectPeer": "Select a peer...",
|
||||
"message": "Message",
|
||||
"messagePlaceholder": "Type your message...",
|
||||
"didDocument": "DID Document",
|
||||
"addContent": "Add Content",
|
||||
"addContentTitle": "Add Content",
|
||||
"createIdentity": "Create Identity",
|
||||
"createIdentityTitle": "Create Identity",
|
||||
"deleteIdentity": "Delete Identity",
|
||||
"deleteIdentityTitle": "Delete Identity",
|
||||
"sendBitcoin": "Send Bitcoin",
|
||||
"sendBitcoinTitle": "Send Bitcoin",
|
||||
"receiveBitcoin": "Receive Bitcoin",
|
||||
"receiveBitcoinTitle": "Receive Bitcoin",
|
||||
"domains": "Domains",
|
||||
"domainsTitle": "Domains",
|
||||
"relays": "Relays",
|
||||
"relaysTitle": "Relays",
|
||||
"totalEarned": "Total Earned",
|
||||
"monthlyAvg": "Monthly Avg",
|
||||
"ecashBalance": "Ecash Balance",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
"identityName": "Identity Name",
|
||||
"identityNamePlaceholder": "Enter identity name",
|
||||
"contentTitle": "Title",
|
||||
"contentTitlePlaceholder": "Enter content title",
|
||||
"amount": "Amount",
|
||||
"amountPlaceholder": "Enter amount in sats",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "Enter Bitcoin address",
|
||||
"deleteIdentityConfirm": "Are you sure you want to delete this identity? This action cannot be undone.",
|
||||
"confirm": "Confirm",
|
||||
"noRelays": "No relays connected",
|
||||
"noDomains": "No domains configured",
|
||||
"addRelay": "Add Relay",
|
||||
"addDomain": "Add Domain",
|
||||
"relayUrl": "Relay URL",
|
||||
"relayUrlPlaceholder": "wss://relay.example.com",
|
||||
"domainName": "Domain Name",
|
||||
"domainNamePlaceholder": "user{'@'}example.com",
|
||||
"peerNodesDescription": "Peer nodes discovered via Nostr. Messages sent over Tor.",
|
||||
"nodeVisibility": "Node Visibility",
|
||||
"nodeVisibilityDesc": "Control how other nodes can discover you",
|
||||
"yourTorAddress": "Your Tor address",
|
||||
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
|
||||
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
|
||||
"noMessages": "No messages yet. Messages from peers will appear here.",
|
||||
"noRequests": "No pending connection requests.",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"discovering": "Discovering...",
|
||||
"discoverNodes": "Discover Nodes on Nostr",
|
||||
"refreshMessages": "Refresh Messages",
|
||||
"refreshRequests": "Refresh Requests",
|
||||
"torServices": "Tor Services",
|
||||
"torServicesDesc": "Hidden services exposing your apps over Tor",
|
||||
"noTorServices": "No Tor hidden services configured.",
|
||||
"content": "Content",
|
||||
"contentDesc": "Share and browse content with peers over Tor",
|
||||
"noSharedContent": "No shared content",
|
||||
"addFilesToShare": "Add files to share with connected peers.",
|
||||
"browse": "Browse",
|
||||
"connectingToPeer": "Connecting to peer over Tor...",
|
||||
"selectPeerToBrowse": "Select a peer to browse",
|
||||
"choosePeerDesc": "Choose a connected peer to see their shared content.",
|
||||
"peerNoContent": "This peer has no shared content.",
|
||||
"identities": "Identities",
|
||||
"identitiesDesc": "Sovereign digital identities (DID:key)",
|
||||
"noIdentities": "No identities yet",
|
||||
"createFirstIdentity": "Create your first sovereign digital identity.",
|
||||
"deleting": "Deleting...",
|
||||
"decentralizedWebNode": "Decentralized Web Node",
|
||||
"dwnDescription": "Personal data store with DID-based access control",
|
||||
"manageDwn": "Manage DWN",
|
||||
"syncing": "Syncing...",
|
||||
"syncNow": "Sync Now",
|
||||
"verifiableCredentials": "Verifiable Credentials",
|
||||
"verifiableCredentialsDesc": "Issue and manage W3C Verifiable Credentials",
|
||||
"noCredentials": "No credentials issued yet",
|
||||
"messageSent": "Message sent over Tor!",
|
||||
"failedToSend": "Failed to send",
|
||||
"pasteInvoice": "Paste a Lightning invoice (BOLT11)",
|
||||
"enterBitcoinAddress": "Enter a Bitcoin address",
|
||||
"sendFailed": "Send failed",
|
||||
"broadcastViaHwWallet": "Broadcast via hardware wallet",
|
||||
"broadcastFailed": "Broadcast failed",
|
||||
"psbtCopied": "PSBT copied!",
|
||||
"enterAmount": "Enter an amount",
|
||||
"pasteEcashToken": "Paste an ecash token",
|
||||
"receiveFailed": "Receive failed",
|
||||
"ecashTokenCopied": "Ecash token copied",
|
||||
"contentAdded": "Content added",
|
||||
"failedToAddContent": "Failed to add content",
|
||||
"contentRemoved": "Content removed",
|
||||
"failedToRemoveContent": "Failed to remove content",
|
||||
"failedToUpdatePricing": "Failed to update pricing",
|
||||
"failedToUpdatePrice": "Failed to update price",
|
||||
"failedToConnectPeer": "Failed to connect to peer",
|
||||
"onionAddressCopied": "Onion address copied",
|
||||
"streamUrlCopied": "Stream URL copied",
|
||||
"playerError": "Unable to load media. The content may only be accessible over Tor.",
|
||||
"connectionAccepted": "Connection accepted",
|
||||
"failedToAcceptRequest": "Failed to accept request",
|
||||
"requestRejected": "Request rejected",
|
||||
"failedToRejectRequest": "Failed to reject request",
|
||||
"visibilitySetTo": "Visibility set to {level}",
|
||||
"failedToUpdateVisibility": "Failed to update visibility",
|
||||
"didCopied": "DID copied to clipboard",
|
||||
"defaultIdentityUpdated": "Default identity updated",
|
||||
"failedToSetDefault": "Failed to set default",
|
||||
"identityCreated": "Identity created",
|
||||
"failedToCreateIdentity": "Failed to create identity",
|
||||
"identityDeleted": "Identity deleted",
|
||||
"failedToDeleteIdentity": "Failed to delete identity",
|
||||
"registrationFailed": "Registration failed",
|
||||
"removeFailed": "Remove failed",
|
||||
"failedToAddRelay": "Failed to add relay",
|
||||
"failedToRemoveRelay": "Failed to remove relay",
|
||||
"failedToToggleRelay": "Failed to toggle relay",
|
||||
"downloadUrlCopied": "Download URL copied",
|
||||
"hardwareWalletDetected": "Hardware Wallet Detected",
|
||||
"namesRegistered": "Names Registered",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
"nostrRelaysDesc": "Decentralized social networking relays",
|
||||
"relaysConnectedLabel": "Relays Connected",
|
||||
"totalRelays": "Total Relays",
|
||||
"freeAccessDesc": "Available to all peers for free",
|
||||
"peersOnlyAccessDesc": "Available only to connected peers",
|
||||
"signWithHwWallet": "Sign with Hardware Wallet",
|
||||
"createsPsbt": "Creates a PSBT for external signing",
|
||||
"generateFreshAddress": "Generate a fresh Bitcoin address",
|
||||
"registerNewName": "Register New Name",
|
||||
"verifyNip05": "Verify NIP-05",
|
||||
"peers": "Peers",
|
||||
"messages": "Messages",
|
||||
"requests": "Requests",
|
||||
"myContent": "My Content",
|
||||
"browsePeers": "Browse Peers",
|
||||
"verified": "Verified",
|
||||
"invalid": "Invalid",
|
||||
"stream": "Stream",
|
||||
"download": "Download"
|
||||
},
|
||||
"appDetails": {
|
||||
"backToApps": "Back to My Apps",
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"ram": "RAM",
|
||||
"ramDesc": "Minimum 512MB",
|
||||
"storage": "Storage",
|
||||
"storageDesc": "~100MB",
|
||||
"links": "Links",
|
||||
"website": "Website",
|
||||
"sourceCode": "Source Code",
|
||||
"documentation": "Documentation",
|
||||
"services": "Services",
|
||||
"guardian": "Guardian",
|
||||
"gateway": "Gateway",
|
||||
"access": "Access",
|
||||
"lan": "LAN",
|
||||
"tor": "Tor",
|
||||
"requiresTor": "Requires Tor Browser",
|
||||
"channels": "Channels",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found",
|
||||
"installed": "Installed",
|
||||
"channels": "Channels"
|
||||
},
|
||||
"containerDetails": {
|
||||
"back": "Back",
|
||||
"subtitle": "Container details and management",
|
||||
"containerInfo": "Container Information",
|
||||
"actions": "Actions",
|
||||
"logs": "Logs",
|
||||
"containerId": "Container ID",
|
||||
"image": "Image",
|
||||
"state": "State",
|
||||
"created": "Created",
|
||||
"startContainer": "Start Container",
|
||||
"stopContainer": "Stop Container",
|
||||
"loadingLogs": "Loading logs...",
|
||||
"noLogs": "No logs available"
|
||||
},
|
||||
"marketplaceDetails": {
|
||||
"backToStore": "Back to App Store",
|
||||
"screenshots": "Screenshots",
|
||||
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
|
||||
"about": "About {name}",
|
||||
"features": "Features",
|
||||
"information": "Information",
|
||||
"requirements": "Requirements",
|
||||
"noRequirements": "No additional dependencies required",
|
||||
"installRequirements": "Install Requirements",
|
||||
"links": "Links",
|
||||
"downloadPackage": "Download Package",
|
||||
"installed": "Installed",
|
||||
"notInstalled": "Not Installed",
|
||||
"open": "Open",
|
||||
"loadingDetails": "Loading app details...",
|
||||
"notFoundTitle": "App Not Found",
|
||||
"notFoundMessage": "The requested application could not be found in the marketplace",
|
||||
"installFailed": "Installation Failed",
|
||||
"depRunning": "Running",
|
||||
"depStopped": "Installed but stopped",
|
||||
"depNotInstalled": "Not installed"
|
||||
},
|
||||
"goalDetail": {
|
||||
"backToGoals": "Back to Goals",
|
||||
"notFound": "Goal not found.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"notStarted": "Not Started",
|
||||
"inProgress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"syncTitle": "Sovereignty takes a little patience",
|
||||
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
|
||||
"installApp": "Install {name}",
|
||||
"openAndConfigure": "Open & Configure",
|
||||
"iveDoneThis": "I've Done This",
|
||||
"complete": "Complete",
|
||||
"allSet": "All Set!",
|
||||
"goalReady": "{title} is ready to go.",
|
||||
"viewMyServices": "View My Services"
|
||||
},
|
||||
"monitoring": {
|
||||
"title": "Monitoring",
|
||||
"subtitle": "Real-time system metrics and container resource usage",
|
||||
"cpuUsage": "CPU Usage (%)",
|
||||
"memoryUsage": "Memory Usage (%)",
|
||||
"networkIo": "Network I/O (bytes)",
|
||||
"rpcLatency": "RPC Latency (ms)",
|
||||
"alertHistory": "Alert History",
|
||||
"hideConfig": "Hide Config",
|
||||
"noAlerts": "No alerts fired",
|
||||
"containerResources": "Container Resources",
|
||||
"noContainerMetrics": "No container metrics available",
|
||||
"systemHealth": "System Health",
|
||||
"load": "Load:",
|
||||
"exportCsv": "Export CSV",
|
||||
"exportJson": "Export JSON",
|
||||
"diskUsage": "Disk Usage",
|
||||
"ramUsage": "RAM Usage",
|
||||
"containerCrash": "Container Crash",
|
||||
"rpcLatencySpike": "RPC Latency Spike",
|
||||
"sslCertExpiry": "SSL Cert Expiry",
|
||||
"refreshFooter": "Refreshing every 5 seconds",
|
||||
"wsConnections": "WS connections: {count}",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"network": "Network"
|
||||
},
|
||||
"systemUpdate": {
|
||||
"title": "System Update",
|
||||
"subtitle": "Manage software updates for your Archipelago node",
|
||||
"currentSystem": "Current System",
|
||||
"updateAvailable": "Update Available",
|
||||
"upToDate": "System is up to date",
|
||||
"downloading": "Downloading Update...",
|
||||
"applying": "Applying Update...",
|
||||
"updateSchedule": "Update Schedule",
|
||||
"actions": "Actions",
|
||||
"lastChecked": "Last Checked",
|
||||
"new": "New",
|
||||
"changelog": "Changelog",
|
||||
"componentsToUpdate": "{count} component(s) to update",
|
||||
"manualOnly": "Manual Only",
|
||||
"manualOnlyDesc": "Never check automatically. You control when to check and install updates.",
|
||||
"dailyCheck": "Daily Check",
|
||||
"dailyCheckDesc": "Check for updates once per day. You decide when to install.",
|
||||
"autoApply": "Auto-Apply",
|
||||
"autoApplyDesc": "Check daily and automatically install updates at 3 AM. Service restarts as needed.",
|
||||
"downloadUpdate": "Download Update",
|
||||
"applyUpdate": "Apply Update",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"rollback": "Rollback to Previous",
|
||||
"backToSettings": "Back to Settings",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"applyWarning": "Installing components and restarting services. Do not power off.",
|
||||
"applyTitle": "Apply Update?",
|
||||
"applyMessage": "The backend service will restart. This may take a moment.",
|
||||
"rollbackTitle": "Rollback Version?",
|
||||
"rollbackMessage": "This will restore the previous version. The backend service will restart.",
|
||||
"applyNow": "Apply Now",
|
||||
"rollbackButton": "Rollback",
|
||||
"upToDateMessage": "Your system is up to date. No updates available. Your system is running the latest version.",
|
||||
"checkFailed": "Failed to check for updates. Check your internet connection.",
|
||||
"downloadSuccess": "Downloaded {count} component(s) ({size}MB)",
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"applySuccess": "Update applied. The service will restart momentarily.",
|
||||
"applyFailed": "Failed to apply update. You can try again or rollback.",
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed."
|
||||
},
|
||||
"kioskRecovery": {
|
||||
"title": "Archipelago Recovery",
|
||||
"subtitle": "Kiosk failsafe — no authentication required",
|
||||
"serverAddress": "Server Address",
|
||||
"webUi": "Web UI: http://{address}",
|
||||
"scanForMobile": "Scan for mobile access",
|
||||
"backend": "Backend",
|
||||
"unreachable": "Unreachable",
|
||||
"containers": "Containers",
|
||||
"goToLogin": "Go to Login",
|
||||
"lastChecked": "Last checked: {time}"
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Channels
|
||||
{{ t('appDetails.channels') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@ -70,7 +70,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@ -80,7 +80,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
{{ t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
@ -89,7 +89,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@ -100,7 +100,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
@ -109,7 +109,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -146,7 +146,7 @@
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
||||
title="Uninstall"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@ -164,7 +164,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@ -174,7 +174,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
{{ t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@ -185,7 +185,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
@ -195,7 +195,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -206,7 +206,7 @@
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
@ -218,12 +218,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ pkg.manifest.title }}</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.about', { name: pkg.manifest.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
@ -231,7 +231,7 @@
|
||||
|
||||
<!-- Features (if available) -->
|
||||
<div v-if="features.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
@ -251,26 +251,26 @@
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">License</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.license') }}</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium">App</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -278,19 +278,19 @@
|
||||
|
||||
<!-- Fedimint Services Card -->
|
||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Services</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 py-2 border-b border-white/10">
|
||||
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">Guardian</p>
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.guardian') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">Gateway</p>
|
||||
<p class="text-white/80 font-medium text-sm">{{ t('appDetails.gateway') }}</p>
|
||||
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -299,14 +299,14 @@
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.access') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">LAN</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.lan') }}</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
@ -322,9 +322,9 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">Tor</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.tor') }}</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
|
||||
<p class="text-white/50 text-xs mt-1">{{ t('appDetails.requiresTor') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -332,15 +332,15 @@
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@ -348,8 +348,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -357,7 +357,7 @@
|
||||
|
||||
<!-- Links Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="pkg.manifest.website"
|
||||
@ -369,7 +369,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
{{ t('appDetails.website') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
@ -378,7 +378,7 @@
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Source Code
|
||||
{{ t('appDetails.sourceCode') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
@ -387,7 +387,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documentation
|
||||
{{ t('appDetails.documentation') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -400,8 +400,8 @@
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found</p>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('appDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
@ -424,10 +424,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -437,13 +436,13 @@
|
||||
@click="closeUninstallModal()"
|
||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -455,6 +454,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { PackageState } from '../types/api'
|
||||
@ -464,6 +464,7 @@ import { dummyApps } from '../utils/dummyApps'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
@ -594,11 +595,11 @@ useModalKeyboard(
|
||||
const backButtonText = computed(() => {
|
||||
// Check if we came from marketplace via query parameter
|
||||
if (route.query.from === 'marketplace') {
|
||||
return 'Back to App Store'
|
||||
return t('appDetails.backToStore')
|
||||
}
|
||||
|
||||
|
||||
// Default to My Apps
|
||||
return 'Back to My Apps'
|
||||
return t('appDetails.backToApps')
|
||||
})
|
||||
|
||||
// Check if app has a UI interface and is running
|
||||
@ -827,7 +828,7 @@ async function confirmUninstall() {
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||
alert('Failed to uninstall app')
|
||||
alert(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="hidden md:block mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
|
||||
<p class="text-white/70">Manage your installed applications</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
@ -10,8 +10,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search installed apps..."
|
||||
aria-label="Search installed apps"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@ -22,20 +22,20 @@
|
||||
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No Apps Installed</h3>
|
||||
<p class="text-white/70 mb-6">Get started by browsing the app store</p>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
|
||||
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
Browse App Store
|
||||
{{ t('apps.browseAppStore') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">No apps matching "{{ searchQuery }}"</p>
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid (alphabetically by title, stable across run state) -->
|
||||
@ -56,8 +56,8 @@
|
||||
<button
|
||||
@click.stop="showUninstallModal(id as string, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
:aria-label="`Uninstall ${pkg.manifest?.title || id}`"
|
||||
title="Uninstall"
|
||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
@ -100,7 +100,7 @@
|
||||
@click.stop="launchApp(id as string)"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>
|
||||
Launch
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||
@ -119,7 +119,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Starting...' : 'Start' }}</span>
|
||||
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running' || pkg.state === 'starting'"
|
||||
@ -138,7 +138,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Stopping...' : 'Stop' }}</span>
|
||||
<span>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -167,10 +167,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,13 +179,13 @@
|
||||
@click="closeUninstallModal()"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -198,7 +197,7 @@
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" aria-label="Dismiss error" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -208,7 +207,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { PackageState, type PackageDataEntry } from '../types/api'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
@ -2,21 +2,21 @@
|
||||
<div class="chat-fullscreen">
|
||||
<!-- Close button + connection indicator (desktop: top-right pill) -->
|
||||
<div class="chat-mode-pill hidden md:flex">
|
||||
<button class="chat-close-btn" aria-label="Close AI Assistant" @click="closeChat">
|
||||
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium">Close</span>
|
||||
<span class="text-xs font-medium">{{ t('chat.close') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="aiuiConnected"
|
||||
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
|
||||
title="AIUI connected"
|
||||
:title="t('chat.aiuiConnected')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile back button -->
|
||||
<button class="chat-mobile-back md:hidden" aria-label="Go back" @click="closeChat">
|
||||
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
@ -27,7 +27,7 @@
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">Loading AI assistant...</p>
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -37,7 +37,7 @@
|
||||
v-if="aiuiUrl"
|
||||
ref="aiuiFrame"
|
||||
:src="aiuiUrl"
|
||||
title="AI Assistant"
|
||||
:title="t('chat.aiAssistant')"
|
||||
class="chat-iframe chat-iframe-mobile"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
allow="microphone"
|
||||
@ -52,12 +52,12 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('chat.aiAssistant') }}</h2>
|
||||
<p class="text-white/60 mb-4 leading-relaxed">
|
||||
AI Assistant is not yet configured on this node.
|
||||
{{ t('chat.notConfigured') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/30">
|
||||
Deploy the AIUI app from the App Store to enable this feature.
|
||||
{{ t('chat.deployCta') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,8 +68,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
|
||||
@ -8,18 +8,18 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
{{ t('containerDetails.back') }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">Container details and management</p>
|
||||
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as any"
|
||||
:health="healthStatus as any"
|
||||
:state="container.state as ContainerStateValue"
|
||||
:health="healthStatus as HealthStatusValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,22 +41,22 @@
|
||||
<div v-else-if="container" key="content" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Container ID</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Image</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">State</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Created</span>
|
||||
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -64,7 +64,7 @@
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@ -72,7 +72,7 @@
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Start Container
|
||||
{{ t('containerDetails.startContainer') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@ -80,21 +80,21 @@
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Stop Container
|
||||
{{ t('containerDetails.stopContainer') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
{{ t('common.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -102,21 +102,21 @@
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">Logs</h2>
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
Loading logs...
|
||||
{{ t('containerDetails.loadingLogs') }}
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
No logs available
|
||||
{{ t('containerDetails.noLogs') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
@ -133,12 +133,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
type ContainerStateValue = 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
@ -148,7 +154,7 @@ const appName = computed(() => {
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<any>(null)
|
||||
const container = ref<ContainerStatusData | null>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
@ -169,7 +175,7 @@ async function loadContainer() {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -180,7 +186,7 @@ async function loadLogs() {
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs:', e)
|
||||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
@ -202,7 +208,7 @@ async function handleStart() {
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@ -214,7 +220,7 @@ async function handleStop() {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
@ -229,14 +235,14 @@ async function handleRestart() {
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to restart container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
|
||||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -245,7 +251,7 @@ async function handleRemove() {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/apps')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
error.value = e instanceof Error ? e.message : t('common.error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
||||
<!-- Skip to main content link for keyboard users -->
|
||||
<a href="#main-content" class="skip-to-content">Skip to main content</a>
|
||||
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
|
||||
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
||||
@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" aria-label="Main navigation">
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" :aria-label="t('dashboard.mainNav')">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
@ -296,7 +296,7 @@
|
||||
<nav
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
aria-label="Mobile navigation"
|
||||
:aria-label="t('dashboard.mobileNav')"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
|
||||
@ -381,7 +381,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||
|
||||
@ -5,12 +5,12 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Goals</span>
|
||||
<span>{{ t('goalDetail.backToGoals') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Goal not found -->
|
||||
<div v-if="!goal" class="glass-card p-12 text-center">
|
||||
<p class="text-white/70">Goal not found.</p>
|
||||
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Goal wizard -->
|
||||
@ -23,7 +23,7 @@
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">Step {{ currentStepDisplay }} of {{ goal.steps.length }}</span>
|
||||
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
|
||||
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@ -40,10 +40,9 @@
|
||||
v-if="showSyncMessage"
|
||||
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Sovereignty takes a little patience</h3>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm leading-relaxed">
|
||||
Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else.
|
||||
This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.
|
||||
{{ t('goalDetail.syncMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -94,28 +93,28 @@
|
||||
:disabled="isInstalling"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ isInstalling ? 'Installing...' : `Install ${step.title.replace('Install ', '')}` }}
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Open & Configure
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
I've Done This
|
||||
{{ t('goalDetail.iveDoneThis') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
|
||||
>
|
||||
Complete
|
||||
{{ t('goalDetail.complete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,10 +130,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">All Set!</h2>
|
||||
<p class="text-white/60 mb-6">{{ goal.title }} is ready to go.</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
View My Services
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@ -144,11 +143,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
@ -193,9 +194,9 @@ const progressPercent = computed(() => {
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (overallStatus.value === 'completed') return 'Completed'
|
||||
if (overallStatus.value === 'in-progress') return 'In Progress'
|
||||
return 'Not Started'
|
||||
if (overallStatus.value === 'completed') return t('goalDetail.completed')
|
||||
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
|
||||
return t('goalDetail.notStarted')
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
@ -251,7 +252,7 @@ async function installApp(step: GoalStep) {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
} catch (err) {
|
||||
console.error('[GoalDetail] Install failed:', err)
|
||||
if (import.meta.env.DEV) console.error('[GoalDetail] Install failed:', err)
|
||||
} finally {
|
||||
isInstalling.value = false
|
||||
}
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -32,13 +32,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Update Available: v{{ updateVersion }}</p>
|
||||
<p class="text-sm font-medium text-white">{{ t('home.updateAvailable', { version: updateVersion }) }}</p>
|
||||
<p v-if="updateChangelog" class="text-xs text-white/60 truncate">{{ updateChangelog }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Update Now
|
||||
{{ t('home.updateNow') }}
|
||||
</RouterLink>
|
||||
<button @click="dismissUpdate" aria-label="Dismiss update notification" class="text-white/40 hover:text-white/80 transition-colors p-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -56,8 +56,8 @@
|
||||
role="tablist"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">Setup</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'setup'" :class="{ 'mode-switcher-btn-active': homeTab === 'setup' }" @click="homeTab = 'setup'">{{ t('home.setupTab') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Setup tab: goal-based cards -->
|
||||
@ -85,10 +85,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.myApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.myAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/apps" aria-label="Go to My Apps" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/apps" :aria-label="t('home.goToApps')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@ -96,20 +96,20 @@
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.installed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Running</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.runningLabel') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Browse Store
|
||||
{{ t('home.browseStore') }}
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/apps" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Apps
|
||||
{{ t('home.manageApps') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -128,10 +128,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.cloud') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.cloudDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/cloud" aria-label="Go to Cloud" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/cloud" :aria-label="t('home.goToCloud')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@ -139,20 +139,20 @@
|
||||
</div>
|
||||
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.storageUsed') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Folders</p>
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('home.folders') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ cloudFolderDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
View Folders
|
||||
{{ t('home.viewFolders') }}
|
||||
</RouterLink>
|
||||
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Upload Files
|
||||
{{ t('home.uploadFiles') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -171,10 +171,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.network') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.networkDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/server" aria-label="Go to Network" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/server" :aria-label="t('home.goToNetwork')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@ -184,28 +184,28 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="servicesDotColor"></div>
|
||||
<span class="text-sm text-white/80">Services Status</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.servicesStatus') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="servicesStatusColor">{{ servicesStatusText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="connectivityDotColor"></div>
|
||||
<span class="text-sm text-white/80">Connectivity</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.connectivity') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="connectivityColor">{{ connectivityText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-sm text-white/80">Running Apps</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.runningApps') }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/80 font-medium">{{ runningCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Network
|
||||
{{ t('home.manageNetwork') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -224,10 +224,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.web5') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.web5Desc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/web5" aria-label="Go to Web5" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@ -237,28 +237,28 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DidStatus === 'Active' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">DID Status</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.didStatus') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DidStatus === 'Active' ? 'text-green-400' : 'text-white/50'">{{ web5DidStatus }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="web5DwnStatus === 'Synced' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<span class="text-sm text-white/80">DWN Sync</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.dwnSync') }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium" :class="web5DwnStatus === 'Synced' ? 'text-green-400' : 'text-white/50'">{{ web5DwnStatus }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/30"></div>
|
||||
<span class="text-sm text-white/80">Credentials</span>
|
||||
<span class="text-sm text-white/80">{{ t('home.credentials') }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/50 font-medium">{{ web5CredentialCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/web5" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
Manage Web5
|
||||
{{ t('home.manageWeb5') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -276,10 +276,10 @@
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">System</h2>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ systemUptimeDisplay }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/server" aria-label="Go to System" class="text-white/60 hover:text-white transition-colors">
|
||||
<RouterLink to="/dashboard/server" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@ -298,7 +298,7 @@
|
||||
<template v-else>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">CPU</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ systemStats.cpuPercent.toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@ -307,7 +307,7 @@
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">RAM</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.ram') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.memPercent)">{{ formatBytes(systemStats.memUsed) }} / {{ formatBytes(systemStats.memTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@ -316,7 +316,7 @@
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-white/60">Disk</p>
|
||||
<p class="text-xs text-white/60">{{ t('home.disk') }}</p>
|
||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.diskPercent)">{{ formatBytes(systemStats.diskUsed) }} / {{ formatBytes(systemStats.diskTotal) }}</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@ -341,8 +341,8 @@
|
||||
<div class="home-card-inner px-6 py-6">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Quick Start Goals</h2>
|
||||
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismissQuickStart"
|
||||
@ -374,7 +374,7 @@
|
||||
<!-- Chat Mode: redirect to Chat view -->
|
||||
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
|
||||
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">
|
||||
Open AI Assistant
|
||||
{{ t('home.openAI') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -383,7 +383,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
@ -407,8 +410,8 @@ const QUICK_START_RESHOW_LOGINS = 5
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const LINE1 = "Welcome Noderunner"
|
||||
const LINE2 = "Here's an overview of your sovereign life"
|
||||
const LINE1 = t('home.title')
|
||||
const LINE2 = t('home.subtitle')
|
||||
const MS_PER_CHAR = 55
|
||||
|
||||
const displayLine1 = ref('')
|
||||
@ -481,8 +484,8 @@ const servicesAllRunning = computed(() =>
|
||||
appCount.value > 0 && runningCount.value === appCount.value
|
||||
)
|
||||
const servicesStatusText = computed(() => {
|
||||
if (appCount.value === 0) return 'No Apps'
|
||||
return servicesAllRunning.value ? 'All Running' : `${runningCount.value}/${appCount.value} Running`
|
||||
if (appCount.value === 0) return t('home.noApps')
|
||||
return servicesAllRunning.value ? t('home.allRunning') : `${runningCount.value}/${appCount.value} ${t('home.runningLabel')}`
|
||||
})
|
||||
const servicesStatusColor = computed(() =>
|
||||
appCount.value === 0 ? 'text-white/60' : servicesAllRunning.value ? 'text-green-400' : 'text-yellow-400'
|
||||
@ -490,7 +493,7 @@ const servicesStatusColor = computed(() =>
|
||||
const servicesDotColor = computed(() =>
|
||||
appCount.value === 0 ? 'bg-white/40' : servicesAllRunning.value ? 'bg-green-400' : 'bg-yellow-400'
|
||||
)
|
||||
const connectivityText = computed(() => store.isConnected ? 'Connected' : 'Disconnected')
|
||||
const connectivityText = computed(() => store.isConnected ? t('common.connected') : t('common.disconnected'))
|
||||
const connectivityColor = computed(() => store.isConnected ? 'text-green-400' : 'text-red-400')
|
||||
const connectivityDotColor = computed(() => store.isConnected ? 'bg-green-400' : 'bg-red-400')
|
||||
|
||||
@ -648,7 +651,7 @@ const systemStats = reactive({
|
||||
})
|
||||
|
||||
const systemUptimeDisplay = computed(() => {
|
||||
if (systemStats.uptimeSecs === 0) return 'System monitoring'
|
||||
if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring')
|
||||
const days = Math.floor(systemStats.uptimeSecs / 86400)
|
||||
const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600)
|
||||
if (days > 0) return `Uptime: ${days}d ${hours}h`
|
||||
|
||||
131
neode-ui/src/views/KioskRecovery.vue
Normal file
131
neode-ui/src/views/KioskRecovery.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-black flex items-center justify-center p-6">
|
||||
<div class="glass-card p-8 w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ t('kioskRecovery.title') }}</h1>
|
||||
<p class="text-sm text-white/50">{{ t('kioskRecovery.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Server IP -->
|
||||
<div class="bg-white/5 rounded-lg p-4 mb-4">
|
||||
<div class="text-xs text-white/50 mb-1">{{ t('kioskRecovery.serverAddress') }}</div>
|
||||
<div class="text-lg font-mono text-white font-medium">{{ serverIp || t('common.loading') }}</div>
|
||||
<div v-if="serverIp" class="text-xs text-white/40 mt-1">{{ t('kioskRecovery.webUi', { address: serverIp }) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="serverIp" class="bg-white/5 rounded-lg p-4 mb-4 flex flex-col items-center">
|
||||
<div class="text-xs text-white/50 mb-2">{{ t('kioskRecovery.scanForMobile') }}</div>
|
||||
<div class="bg-white p-3 rounded-lg inline-block">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="w-32 h-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.backend') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="backendHealthy ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<span class="text-sm" :class="backendHealthy ? 'text-green-400' : 'text-red-400'">
|
||||
{{ backendHealthy ? t('common.healthy') : t('kioskRecovery.unreachable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('kioskRecovery.containers') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ containerCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/5 rounded-lg p-3">
|
||||
<span class="text-sm text-white/70">{{ t('monitoring.diskUsage') }}</span>
|
||||
<span class="text-sm text-white font-medium">{{ diskUsage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button @click="refreshDiagnostics" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button @click="goToLogin" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30">
|
||||
{{ t('kioskRecovery.goToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-xs text-white/30">{{ t('kioskRecovery.lastChecked', { time: lastChecked }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const serverIp = ref('')
|
||||
const backendHealthy = ref(false)
|
||||
const containerCount = ref('—')
|
||||
const diskUsage = ref('—')
|
||||
const lastChecked = ref('—')
|
||||
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (!serverIp.value) return ''
|
||||
const url = `http://${serverIp.value}`
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=128x128&data=${encodeURIComponent(url)}`
|
||||
})
|
||||
|
||||
async function refreshDiagnostics() {
|
||||
lastChecked.value = new Date().toLocaleTimeString()
|
||||
|
||||
// Detect server IP from window location
|
||||
serverIp.value = window.location.hostname !== 'localhost'
|
||||
? window.location.hostname
|
||||
: '127.0.0.1'
|
||||
|
||||
// Check backend health
|
||||
try {
|
||||
const res = await fetch('/health', { signal: AbortSignal.timeout(5000) })
|
||||
backendHealthy.value = res.ok
|
||||
} catch {
|
||||
backendHealthy.value = false
|
||||
}
|
||||
|
||||
// Get system stats (unauthenticated won't work for RPC, but try health)
|
||||
if (backendHealthy.value) {
|
||||
try {
|
||||
const statsRes = await fetch('/rpc/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'system.stats' }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await statsRes.json()
|
||||
if (data.result) {
|
||||
const disk = data.result.disk
|
||||
if (disk) {
|
||||
const usedPct = ((disk.used / disk.total) * 100).toFixed(0)
|
||||
diskUsage.value = `${usedPct}% used`
|
||||
}
|
||||
containerCount.value = String(data.result.containers?.running ?? '—')
|
||||
}
|
||||
} catch {
|
||||
// Stats require auth — show defaults
|
||||
containerCount.value = '—'
|
||||
diskUsage.value = '—'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshDiagnostics()
|
||||
})
|
||||
</script>
|
||||
@ -15,8 +15,8 @@
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
<span v-if="isSetupMode && !isSetup">Set Up Your Node</span>
|
||||
<span v-else>Welcome to Archipelago</span>
|
||||
<span v-if="isSetupMode && !isSetup">{{ t('login.setupTitle') }}</span>
|
||||
<span v-else>{{ t('login.title') }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Server Startup Progress -->
|
||||
@ -26,7 +26,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-white/60">Server starting up...</span>
|
||||
<span class="text-sm text-white/60">{{ t('login.serverStarting') }}</span>
|
||||
</div>
|
||||
<div class="startup-progress-track">
|
||||
<div class="startup-progress-bar" :style="{ width: startupProgress + '%' }"></div>
|
||||
@ -47,14 +47,14 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="setup-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter a password (min 8 characters)"
|
||||
:placeholder="t('login.enterPasswordSetup')"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@ -62,14 +62,14 @@
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="setup-confirm-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Confirm Password
|
||||
{{ t('login.confirmPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Confirm your password"
|
||||
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||
@keyup.enter="handleSetupWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@ -80,13 +80,13 @@
|
||||
:disabled="loading || formDisabled || !password || password.length < 8 || password !== confirmPassword"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Set Up Node</span>
|
||||
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Setting up...
|
||||
{{ t('login.settingUp') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@ -97,8 +97,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto mb-3 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p class="text-white/80 text-sm mb-1">Two-Factor Authentication</p>
|
||||
<p class="text-white/50 text-xs">Enter the 6-digit code from your authenticator app</p>
|
||||
<p class="text-white/80 text-sm mb-1">{{ t('login.twoFactorTitle') }}</p>
|
||||
<p class="text-white/50 text-xs">{{ t('login.totpInstruction') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@ -110,7 +110,7 @@
|
||||
pattern="[0-9]*"
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
aria-label="Two-factor authentication code"
|
||||
:aria-label="t('login.totpLabel')"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
|
||||
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
|
||||
@keyup.enter="handleTotpVerify"
|
||||
@ -123,13 +123,13 @@
|
||||
:disabled="loading || !totpCode"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed mb-3"
|
||||
>
|
||||
<span v-if="!loading">Verify</span>
|
||||
<span v-if="!loading">{{ t('login.verifyButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
{{ t('login.verifying') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -137,7 +137,7 @@
|
||||
@click="useBackupCode = !useBackupCode; totpCode = ''"
|
||||
class="w-full text-white/50 text-sm hover:text-white/70 transition-colors py-2"
|
||||
>
|
||||
{{ useBackupCode ? 'Use authenticator code' : 'Use a backup code instead' }}
|
||||
{{ useBackupCode ? t('login.useAuthCode') : t('login.useBackupCode') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@ -145,14 +145,14 @@
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
{{ t('login.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
@keyup.enter="handleLoginWithSound"
|
||||
:disabled="loading || formDisabled"
|
||||
/>
|
||||
@ -163,20 +163,20 @@
|
||||
:disabled="loading || formDisabled || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Login</span>
|
||||
<span v-if="!loading">{{ t('login.loginButton') }}</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Logging in...
|
||||
{{ t('login.loggingIn') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-6 text-center text-sm text-white/40">
|
||||
Password recovery requires SSH access to the server.
|
||||
{{ t('login.recoveryNote') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -186,7 +186,7 @@
|
||||
@click="replayIntro"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
Replay Intro
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@ -194,7 +194,7 @@
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? 'Resetting...' : 'Onboarding' }}
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -204,8 +204,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
@ -350,12 +353,12 @@ function handleSetupWithSound() {
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = 'Password must be at least 8 characters'
|
||||
error.value = t('login.errorMinLength')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
error.value = t('login.errorMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
@ -381,9 +384,9 @@ async function handleSetup() {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || 'Setup failed. Please try again.'
|
||||
error.value = msg || t('login.errorSetupFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
@ -425,9 +428,9 @@ async function handleLogin() {
|
||||
whooshAway.value = false
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
|
||||
error.value = 'Server is starting up. Please try again in a moment.'
|
||||
error.value = t('login.errorServerStarting')
|
||||
} else {
|
||||
error.value = msg || 'Login failed. Please check your password.'
|
||||
error.value = msg || t('login.errorLoginFailed')
|
||||
}
|
||||
startSynthwave()
|
||||
} finally {
|
||||
@ -464,7 +467,7 @@ async function handleTotpVerify() {
|
||||
totpCode.value = ''
|
||||
error.value = msg
|
||||
} else {
|
||||
error.value = msg || 'Invalid code. Please try again.'
|
||||
error.value = msg || t('login.errorInvalidCode')
|
||||
}
|
||||
totpCode.value = ''
|
||||
} finally {
|
||||
|
||||
@ -76,8 +76,8 @@
|
||||
|
||||
<div class="hidden md:flex mb-8 items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-white mb-2">App Store</h1>
|
||||
<p class="text-white/70">Discover and install apps for your new sovereign life</p>
|
||||
<h1 class="text-4xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -95,7 +95,7 @@
|
||||
: 'text-white/60 hover:text-white/80 border border-transparent'
|
||||
]"
|
||||
>
|
||||
Curated
|
||||
{{ t('marketplace.curatedTab') }}
|
||||
</button>
|
||||
<button
|
||||
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
|
||||
@ -108,7 +108,7 @@
|
||||
: 'text-white/60 hover:text-white/80 border border-transparent'
|
||||
]"
|
||||
>
|
||||
Community
|
||||
{{ t('marketplace.communityTab') }}
|
||||
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -133,8 +133,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@ -159,8 +159,8 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@ -227,7 +227,7 @@
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
|
||||
>
|
||||
Already Installed
|
||||
{{ t('marketplace.alreadyInstalled') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="app.source === 'local' || app.dockerImage"
|
||||
@ -241,16 +241,16 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installingApps.get(app.id)?.message || 'Installing...' }}
|
||||
{{ installingApps.get(app.id)?.message || t('common.installing') }}
|
||||
</span>
|
||||
<span v-else>Install</span>
|
||||
<span v-else>{{ t('common.install') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>
|
||||
Not Available
|
||||
{{ t('common.notAvailable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -263,14 +263,14 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">{{ marketplaceSource === 'community' ? 'Querying Nostr relays for apps...' : 'Loading apps...' }}</p>
|
||||
<p class="text-white/70">{{ marketplaceSource === 'community' ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
|
||||
</div>
|
||||
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">No community apps discovered yet.</p>
|
||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||
</div>
|
||||
<p v-else class="text-white/70">No apps found in {{ categories.find(c => c.id === selectedCategory)?.name }}{{ searchQuery ? ` matching "${searchQuery}"` : '' }}</p>
|
||||
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
@ -298,7 +298,7 @@
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
|
||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||
<button
|
||||
@click="closeFilterModal()"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
@ -372,6 +372,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
|
||||
@ -381,22 +382,23 @@ type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: n
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
// Category state
|
||||
const selectedCategory = ref('all')
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'car', name: 'Auto' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'other', name: 'Other' }
|
||||
]
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'car', name: t('marketplace.auto') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'other', name: t('marketplace.other') }
|
||||
])
|
||||
|
||||
// Installation state - support multiple concurrent installations
|
||||
interface InstallProgress {
|
||||
@ -425,7 +427,7 @@ watch(() => store.packages, (packages) => {
|
||||
...current,
|
||||
status: 'downloading',
|
||||
progress: Math.min(pct, 95),
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
|
||||
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -606,7 +608,7 @@ const allApps = computed(() => {
|
||||
// Only show categories that have at least one app
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.filter(cat => {
|
||||
return categories.value.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
|
||||
@ -5,11 +5,11 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to App Store
|
||||
{{ t('marketplaceDetails.backToStore') }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
@ -20,7 +20,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to App Store</span>
|
||||
<span>{{ t('marketplaceDetails.backToStore') }}</span>
|
||||
</button>
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
@ -30,7 +30,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">Loading app details...</p>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.loadingDetails') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- App Details -->
|
||||
@ -63,12 +63,12 @@
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
Installed
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
@ -79,7 +79,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@ -94,7 +94,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -127,7 +127,7 @@
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
Installed
|
||||
{{ t('marketplaceDetails.installed') }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
@ -144,7 +144,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open
|
||||
{{ t('marketplaceDetails.open') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@ -159,7 +159,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -170,7 +170,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium text-sm">Installation Failed</p>
|
||||
<p class="text-red-200 font-medium text-sm">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,7 +184,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium">Installation Failed</p>
|
||||
<p class="text-red-200 font-medium">{{ t('marketplaceDetails.installFailed') }}</p>
|
||||
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -196,7 +196,7 @@
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
@ -208,12 +208,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ app.title }}</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.about', { name: app.title }) }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ longDescription }}
|
||||
</p>
|
||||
@ -221,7 +221,7 @@
|
||||
|
||||
<!-- Features -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.features') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
@ -241,22 +241,22 @@
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.information') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
|
||||
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
|
||||
</div>
|
||||
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>
|
||||
<span class="text-white font-medium">{{ app.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? 'Installed' : 'Not Installed' }}</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.status') }}</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? t('marketplaceDetails.installed') : t('marketplaceDetails.notInstalled') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white/60 text-sm">{{ t('common.category') }}</span>
|
||||
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
|
||||
</div>
|
||||
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
|
||||
@ -268,7 +268,7 @@
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.requirements') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- App Dependencies -->
|
||||
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
|
||||
@ -290,7 +290,7 @@
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
|
||||
<p class="text-white/50 text-xs">
|
||||
{{ dep.status === 'running' ? 'Running' : dep.status === 'stopped' ? 'Installed but stopped' : 'Not installed' }}
|
||||
{{ dep.status === 'running' ? t('marketplaceDetails.depRunning') : dep.status === 'stopped' ? t('marketplaceDetails.depStopped') : t('marketplaceDetails.depNotInstalled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -305,19 +305,19 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installingDeps ? 'Installing...' : 'Install Requirements' }}
|
||||
{{ installingDeps ? t('common.installing') : t('marketplaceDetails.installRequirements') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="py-2 border-b border-white/10">
|
||||
<p class="text-white/60 text-sm">No additional dependencies required</p>
|
||||
<p class="text-white/60 text-sm">{{ t('marketplaceDetails.noRequirements') }}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.ram') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.ramDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@ -325,8 +325,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
<p class="text-white/80 font-medium">{{ t('appDetails.storage') }}</p>
|
||||
<p class="text-white/60 text-sm">{{ t('appDetails.storageDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -334,7 +334,7 @@
|
||||
|
||||
<!-- Links Card (no GitHub - repo link removed per product) -->
|
||||
<div v-if="app.manifestUrl" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('marketplaceDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
:href="app.manifestUrl"
|
||||
@ -345,7 +345,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Package
|
||||
{{ t('marketplaceDetails.downloadPackage') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -358,8 +358,8 @@
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found in the marketplace</p>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">{{ t('marketplaceDetails.notFoundTitle') }}</h3>
|
||||
<p class="text-white/70">{{ t('marketplaceDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@ -369,11 +369,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
|
||||
const router = useRouter()
|
||||
@ -404,7 +406,7 @@ const shortDescription = computed(() => {
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
@ -418,7 +420,7 @@ const longDescription = computed(() => {
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
@ -479,7 +481,7 @@ onMounted(() => {
|
||||
}, 500)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
loading.value = false
|
||||
pendingRedirect = setTimeout(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
@ -530,8 +532,8 @@ async function installDependencies() {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : 'Failed to install dependencies.'
|
||||
console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
|
||||
} finally {
|
||||
installingDeps.value = false
|
||||
}
|
||||
@ -540,7 +542,7 @@ async function installDependencies() {
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
}
|
||||
|
||||
@ -577,8 +579,8 @@ async function installApp() {
|
||||
|
||||
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
||||
} catch (err: unknown) {
|
||||
installError.value = err instanceof Error ? err.message : 'Installation failed. Please try again.'
|
||||
console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
installError.value = err instanceof Error ? err.message : t('marketplaceDetails.installFailed')
|
||||
if (import.meta.env.DEV) console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
|
||||
@ -3,15 +3,15 @@
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Monitoring</h1>
|
||||
<p class="text-white/70">Real-time system metrics and container resource usage</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('monitoring.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('monitoring.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('csv')">
|
||||
Export CSV
|
||||
{{ t('monitoring.exportCsv') }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="exportMetrics('json')">
|
||||
Export JSON
|
||||
{{ t('monitoring.exportJson') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -20,22 +20,22 @@
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">CPU</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
|
||||
<p class="text-xs text-white/40">Load: {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Memory</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Disk</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Network</p>
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
|
||||
</div>
|
||||
@ -44,7 +44,7 @@
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">CPU Usage (%)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.cpuUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="cpuDatasets"
|
||||
:labels="timeLabels"
|
||||
@ -54,7 +54,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">Memory Usage (%)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.memoryUsage') }}</h3>
|
||||
<LineChart
|
||||
:datasets="memDatasets"
|
||||
:labels="timeLabels"
|
||||
@ -64,7 +64,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">Network I/O (bytes)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.networkIo') }}</h3>
|
||||
<LineChart
|
||||
:datasets="netDatasets"
|
||||
:labels="timeLabels"
|
||||
@ -73,7 +73,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">RPC Latency (ms)</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-3">{{ t('monitoring.rpcLatency') }}</h3>
|
||||
<LineChart
|
||||
:datasets="latencyDatasets"
|
||||
:labels="timeLabels"
|
||||
@ -86,12 +86,12 @@
|
||||
<!-- Alert History -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">Alert History</h3>
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.alertHistory') }}</h3>
|
||||
<button
|
||||
class="glass-button text-xs px-3 py-1"
|
||||
@click="showAlertConfig = !showAlertConfig"
|
||||
>
|
||||
{{ showAlertConfig ? 'Hide Config' : 'Configure' }}
|
||||
{{ showAlertConfig ? t('monitoring.hideConfig') : t('common.configure') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -128,7 +128,7 @@
|
||||
|
||||
<!-- Fired Alerts List -->
|
||||
<div v-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No alerts fired
|
||||
{{ t('monitoring.noAlerts') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
@ -150,7 +150,7 @@
|
||||
class="text-xs text-white/40 hover:text-white/70 flex-shrink-0"
|
||||
@click="acknowledgeAlert(alert.id)"
|
||||
>
|
||||
Dismiss
|
||||
{{ t('common.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -158,9 +158,9 @@
|
||||
|
||||
<!-- Container Resource Breakdown -->
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Container Resources</h3>
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">{{ t('monitoring.containerResources') }}</h3>
|
||||
<div v-if="!containers.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No container metrics available
|
||||
{{ t('monitoring.noContainerMetrics') }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
@ -190,11 +190,11 @@
|
||||
<!-- System Health Timeline -->
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">System Health</h3>
|
||||
<h3 class="text-sm font-medium text-white/80">{{ t('monitoring.systemHealth') }}</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> Healthy
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> Elevated
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> Critical
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span> {{ t('common.healthy') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-orange-400 ml-2"></span> {{ t('common.elevated') }}
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-400 ml-2"></span> {{ t('common.critical') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-0.5 h-6">
|
||||
@ -213,13 +213,14 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/30 mt-4 text-center">
|
||||
Refreshing every 5 seconds · WS connections: {{ current?.ws_connections ?? 0 }}
|
||||
{{ t('monitoring.refreshFooter') }} · {{ t('monitoring.wsConnections', { count: current?.ws_connections ?? 0 }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
@ -279,6 +280,8 @@ interface FiredAlert {
|
||||
acknowledged: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
const containers = ref<ContainerMetrics[]>([])
|
||||
@ -382,11 +385,11 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
function ruleLabel(kind: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
disk_usage: 'Disk Usage',
|
||||
ram_usage: 'RAM Usage',
|
||||
container_crash: 'Container Crash',
|
||||
backend_error_spike: 'RPC Latency Spike',
|
||||
ssl_cert_expiry: 'SSL Cert Expiry',
|
||||
disk_usage: t('monitoring.diskUsage'),
|
||||
ram_usage: t('monitoring.ramUsage'),
|
||||
container_crash: t('monitoring.containerCrash'),
|
||||
backend_error_spike: t('monitoring.rpcLatencySpike'),
|
||||
ssl_cert_expiry: t('monitoring.sslCertExpiry'),
|
||||
}
|
||||
return labels[kind] ?? kind
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
<div>
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ t('settings.title') }}</h1>
|
||||
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden">
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-6">Account</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-6">{{ t('settings.account') }}</h2>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
@ -23,7 +23,7 @@
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Server Name</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.serverName') }}</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
|
||||
</div>
|
||||
@ -34,7 +34,7 @@
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Version</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('common.version') }}</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
|
||||
</div>
|
||||
@ -45,9 +45,9 @@
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Session Status</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.sessionStatus') }}</p>
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
|
||||
@ -59,7 +59,7 @@
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.yourDid') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyDid"
|
||||
@ -68,12 +68,12 @@
|
||||
<svg v-if="!copiedDid" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedDid">Copy</span>
|
||||
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
|
||||
<span v-if="!copiedDid">{{ t('common.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||
@ -83,7 +83,7 @@
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyOnionAddress"
|
||||
@ -92,12 +92,12 @@
|
||||
<svg v-if="!copiedOnion" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedOnion">Copy</span>
|
||||
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
|
||||
<span v-if="!copiedOnion">{{ t('common.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.onionHelper') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -111,7 +111,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span>Change Password</span>
|
||||
<span>{{ t('settings.changePassword') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -123,45 +123,45 @@
|
||||
@click.self="closeChangePasswordModal()"
|
||||
>
|
||||
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">{{ t('settings.changePasswordTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm mb-4">{{ t('settings.changePasswordDesc') }}</p>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.currentPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter current password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.newPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="12+ chars, upper, lower, digit, special"
|
||||
:placeholder="t('settings.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.confirmNewPassword') }}</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Re-enter new password"
|
||||
:placeholder="t('settings.confirmNewPassword')"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
|
||||
Also update SSH password (recommended)
|
||||
{{ t('settings.updateSshCheckbox') }}
|
||||
</label>
|
||||
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||
@ -171,14 +171,14 @@
|
||||
:disabled="changingPassword"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ changingPassword ? 'Updating...' : 'Update Password' }}
|
||||
{{ changingPassword ? t('settings.updatingPassword') : t('settings.updatePassword') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -194,15 +194,15 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white/90">Two-Factor Authentication</p>
|
||||
<p class="text-xs text-white/50">Protect your account with an authenticator app</p>
|
||||
<p class="text-sm font-medium text-white/90">{{ t('settings.twoFactorAuth') }}</p>
|
||||
<p class="text-xs text-white/50">{{ t('settings.twoFaProtect') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-semibold px-2 py-1 rounded-full"
|
||||
:class="totpEnabled ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-white/50'"
|
||||
>
|
||||
{{ totpEnabled ? 'Enabled' : 'Disabled' }}
|
||||
{{ totpEnabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@ -213,7 +213,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Enable 2FA</span>
|
||||
<span>{{ t('settings.enable2fa') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@ -223,7 +223,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Disable 2FA</span>
|
||||
<span>{{ t('settings.disable2fa') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -238,8 +238,8 @@
|
||||
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-setup-title">
|
||||
<!-- Step 1: Enter password -->
|
||||
<template v-if="totpSetupStep === 1">
|
||||
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">Enable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password to begin setup.</p>
|
||||
<h3 id="totp-setup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.setup2faTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.setup2faPasswordPrompt') }}</p>
|
||||
<form @submit.prevent="beginTotpSetup" class="space-y-4">
|
||||
<input
|
||||
v-model="totpSetupPassword"
|
||||
@ -247,7 +247,7 @@
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
@ -256,20 +256,20 @@
|
||||
:disabled="totpSetupLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Loading...' : 'Continue' }}
|
||||
{{ totpSetupLoading ? t('common.loading') : t('common.continue') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Scan QR + verify code -->
|
||||
<template v-else-if="totpSetupStep === 2">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Scan QR Code</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code.</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
|
||||
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
|
||||
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4">
|
||||
<p class="text-xs text-white/50 mb-1">Manual entry key:</p>
|
||||
<p class="text-xs text-white/50 mb-1">{{ t('settings.manualEntryKey') }}</p>
|
||||
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
|
||||
</div>
|
||||
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
|
||||
@ -282,7 +282,7 @@
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
|
||||
placeholder="000000"
|
||||
:placeholder="t('login.totpPlaceholder')"
|
||||
/>
|
||||
<p v-if="totpSetupError" class="text-sm text-red-400">{{ totpSetupError }}</p>
|
||||
<div class="flex gap-3">
|
||||
@ -291,17 +291,17 @@
|
||||
:disabled="totpSetupLoading || totpSetupCode.length !== 6"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpSetupLoading ? 'Verifying...' : 'Verify & Enable' }}
|
||||
{{ totpSetupLoading ? t('login.verifying') : t('settings.verifyAndEnable') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpSetup" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Show backup codes -->
|
||||
<template v-else-if="totpSetupStep === 3">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Save Your Backup Codes</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Store these codes safely. Each can be used once if you lose access to your authenticator app.</p>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.saveBackupCodes') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.backupCodesInstruction') }}</p>
|
||||
<div class="bg-black/30 rounded-xl p-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
@ -320,13 +320,13 @@
|
||||
<svg v-if="!backupCodesCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ backupCodesCopied ? 'Copied!' : 'Copy All Codes' }}</span>
|
||||
<span>{{ backupCodesCopied ? t('common.copiedBang') : t('settings.copyAllCodes') }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="closeTotpSetup"
|
||||
class="w-full px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Done
|
||||
{{ t('common.done') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@ -342,22 +342,22 @@
|
||||
@keydown.escape="closeTotpDisable"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full" role="dialog" aria-modal="true" aria-labelledby="totp-disable-title">
|
||||
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-white/60 text-sm mb-4">Enter your password and a current TOTP code to disable 2FA.</p>
|
||||
<h3 id="totp-disable-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.disable2faTitle') }}</h3>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('settings.disable2faDesc') }}</p>
|
||||
<form @submit.prevent="disableTotp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Password</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('login.password') }}</label>
|
||||
<input
|
||||
v-model="totpDisablePassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter your password"
|
||||
:placeholder="t('login.enterPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Authenticator Code</label>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('settings.authenticatorCode') }}</label>
|
||||
<input
|
||||
v-model="totpDisableCode"
|
||||
type="text"
|
||||
@ -367,7 +367,7 @@
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-3 py-3 rounded-lg bg-white/10 text-white text-center text-2xl tracking-[0.5em] border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 font-mono"
|
||||
placeholder="000000"
|
||||
:placeholder="t('login.totpPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="totpDisableError" class="text-sm text-red-400">{{ totpDisableError }}</p>
|
||||
@ -377,9 +377,9 @@
|
||||
:disabled="totpDisableLoading"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-red-500 text-white font-medium hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ totpDisableLoading ? 'Disabling...' : 'Disable 2FA' }}
|
||||
{{ totpDisableLoading ? t('common.disabling') : t('settings.disable2fa') }}
|
||||
</button>
|
||||
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">Cancel</button>
|
||||
<button type="button" @click="closeTotpDisable" class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -394,14 +394,14 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
<span>{{ t('settings.logout') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Interface Mode Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Interface Mode</h2>
|
||||
<p class="text-sm text-white/60 mb-6">Choose how you want to interact with your node.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
@ -431,8 +431,8 @@
|
||||
|
||||
<!-- Claude Authentication Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">Claude Authentication</h2>
|
||||
<p class="text-sm text-white/60 mb-6">Connect your Claude Max account to enable AI chat features.</p>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
|
||||
@ -441,10 +441,10 @@
|
||||
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Connection Status</p>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.connectionStatus') }}</p>
|
||||
</div>
|
||||
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
|
||||
{{ claudeConnected ? 'Connected' : 'Not connected' }}
|
||||
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -458,7 +458,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>{{ claudeConnected ? 'Re-authenticate' : 'Login with Claude' }}</span>
|
||||
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -471,7 +471,7 @@
|
||||
>
|
||||
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<h3 class="text-sm font-semibold text-white/80">Claude Authentication</h3>
|
||||
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
|
||||
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@ -491,9 +491,9 @@
|
||||
<!-- AI Data Access Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="mb-2">
|
||||
<h2 class="text-xl font-semibold text-white/96">AI Data Access</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.aiDataAccess') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/60 mb-6">Control what data the AI assistant can see. All categories are off by default.</p>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.aiDataAccessDesc') }}</p>
|
||||
|
||||
<!-- Enable All toggle -->
|
||||
<button
|
||||
@ -507,8 +507,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">Enable All</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Grant access to all data categories at once</p>
|
||||
<p class="text-sm font-medium" :class="aiPermissions.allEnabled ? 'text-white/95' : 'text-white/70'">{{ t('common.enableAll') }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ t('settings.enableAllDesc') }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
@ -560,14 +560,14 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">System Updates</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Check for and install software updates</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.systemUpdates') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.systemUpdatesDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Manage Updates
|
||||
{{ t('common.manageUpdates') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -576,18 +576,18 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Webhook Notifications</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Get push notifications for critical events via webhook</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.webhookNotifications') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.webhookNotificationsDesc') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleWebhookEnabled"
|
||||
role="switch"
|
||||
:aria-checked="webhookConfig.enabled"
|
||||
:aria-label="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
|
||||
:aria-label="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
:class="webhookConfig.enabled ? 'bg-orange-500' : 'bg-white/15'"
|
||||
:title="webhookConfig.enabled ? 'Disable webhooks' : 'Enable webhooks'"
|
||||
:title="webhookConfig.enabled ? t('settings.disableWebhooks') : t('settings.enableWebhooks')"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
@ -600,29 +600,29 @@
|
||||
<div class="space-y-4">
|
||||
<!-- Webhook URL -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Webhook URL</label>
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookUrlLabel') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.url"
|
||||
type="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
:placeholder="t('settings.webhookUrlPlaceholder')"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secret (optional) -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Secret (optional, for HMAC-SHA256 signing)</label>
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.webhookSecretLabel') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.secret"
|
||||
type="password"
|
||||
placeholder="Shared secret for payload signing"
|
||||
:placeholder="t('settings.webhookSecretPlaceholderFull')"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-2">Events to notify</label>
|
||||
<label class="text-xs text-white/50 block mb-2">{{ t('settings.eventsToNotify') }}</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="evt in webhookEventTypes"
|
||||
@ -661,14 +661,14 @@
|
||||
:disabled="savingWebhook"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ savingWebhook ? 'Saving...' : 'Save Configuration' }}
|
||||
{{ savingWebhook ? t('settings.savingWebhook') : t('common.saveConfiguration') }}
|
||||
</button>
|
||||
<button
|
||||
@click="testWebhook"
|
||||
:disabled="testingWebhook || !webhookConfig.url"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{{ testingWebhook ? 'Sending...' : 'Send Test' }}
|
||||
{{ testingWebhook ? t('common.sending') : t('common.sendTest') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -683,37 +683,37 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Backup & Restore</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Encrypted backups of your identity, settings, and data</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.backupRestoreDesc') }}</p>
|
||||
</div>
|
||||
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Backup
|
||||
{{ t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">Loading backups...</div>
|
||||
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">No backups yet. Create one to protect your node data.</div>
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
|
||||
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-white font-medium">{{ b.description || 'System Backup' }}</div>
|
||||
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
|
||||
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} · {{ formatBackupSize(b.size_bytes) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" title="Verify">
|
||||
{{ verifyingBackupId === b.id ? '...' : 'Verify' }}
|
||||
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" :title="t('common.verify')">
|
||||
{{ verifyingBackupId === b.id ? '...' : t('common.verify') }}
|
||||
</button>
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" title="Copy to USB">
|
||||
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" :title="t('settings.copyToUsb')">
|
||||
{{ usbCopyingId === b.id ? '...' : 'USB' }}
|
||||
</button>
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" title="Restore">
|
||||
Restore
|
||||
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" :title="t('common.restore')">
|
||||
{{ t('common.restore') }}
|
||||
</button>
|
||||
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" aria-label="Delete backup" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" title="Delete">
|
||||
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" :aria-label="t('settings.deleteBackup')" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" :title="t('common.delete')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@ -730,21 +730,21 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">Create Encrypted Backup</h3>
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||
<input v-model="backupPassphrase" type="password" placeholder="Enter a strong passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
|
||||
<input v-model="backupPassphrase" type="password" :placeholder="t('settings.enterPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Description (optional)</label>
|
||||
<input v-model="backupDescription" type="text" placeholder="e.g. Before update" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.descriptionOptional') }}</label>
|
||||
<input v-model="backupDescription" type="text" :placeholder="t('settings.descriptionPlaceholder')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
||||
{{ creatingBackup ? 'Creating...' : 'Create Backup' }}
|
||||
{{ creatingBackup ? t('settings.creatingBackup') : t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -755,16 +755,16 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">Restore Backup</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">This will overwrite current node data. Make sure you have the correct passphrase.</p>
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
|
||||
<div>
|
||||
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||
<input v-model="restorePassphrase" type="password" placeholder="Enter backup passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
<label class="text-xs text-white/50 block mb-1">{{ t('settings.encryptionPassphrase') }}</label>
|
||||
<input v-model="restorePassphrase" type="password" :placeholder="t('settings.enterBackupPassphrase')" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">{{ t('common.cancel') }}</button>
|
||||
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
|
||||
{{ restoringBackup ? 'Restoring...' : 'Restore' }}
|
||||
{{ restoringBackup ? t('common.restoring') : t('common.restore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -775,14 +775,14 @@
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Network</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Network connectivity, UPnP, and diagnostics</p>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.networkDesc') }}</p>
|
||||
</div>
|
||||
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
Network Diagnostics
|
||||
{{ t('common.networkDiagnostics') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -792,6 +792,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
@ -801,6 +802,7 @@ import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
const uiMode = useUIModeStore()
|
||||
const aiPermissions = useAIPermissionsStore()
|
||||
@ -817,26 +819,26 @@ const aiCategoryGroups = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
|
||||
const interfaceModes = computed<{ id: UIMode; label: string; description: string; iconPaths: string[] }[]>(() => [
|
||||
{
|
||||
id: 'easy',
|
||||
label: 'Easy',
|
||||
description: 'Goal-based interface. Choose what you want to do, and the system handles the rest.',
|
||||
label: t('settings.modeEasy'),
|
||||
description: t('settings.modeEasyDesc'),
|
||||
iconPaths: ['M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
},
|
||||
{
|
||||
id: 'gamer',
|
||||
label: 'Pro',
|
||||
description: 'Full control over all services. Configure everything manually with all technical details.',
|
||||
label: t('settings.modePro'),
|
||||
description: t('settings.modeProDesc'),
|
||||
iconPaths: ['M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'Conversational AI interface. Manage your node through natural language. Coming soon.',
|
||||
label: t('settings.modeChat'),
|
||||
description: t('settings.modeChatDesc'),
|
||||
iconPaths: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
@ -928,7 +930,7 @@ async function beginTotpSetup() {
|
||||
totpPendingToken.value = res.pending_token
|
||||
totpSetupStep.value = 2
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Setup failed'
|
||||
totpSetupError.value = e instanceof Error ? e.message : t('settings.setupFailed')
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
@ -947,7 +949,7 @@ async function confirmTotpSetup() {
|
||||
totpEnabled.value = true
|
||||
totpSetupStep.value = 3
|
||||
} catch (e) {
|
||||
totpSetupError.value = e instanceof Error ? e.message : 'Verification failed'
|
||||
totpSetupError.value = e instanceof Error ? e.message : t('settings.verificationFailed')
|
||||
} finally {
|
||||
totpSetupLoading.value = false
|
||||
}
|
||||
@ -974,7 +976,7 @@ async function disableTotp() {
|
||||
totpEnabled.value = false
|
||||
closeTotpDisable()
|
||||
} catch (e) {
|
||||
totpDisableError.value = e instanceof Error ? e.message : 'Failed to disable 2FA'
|
||||
totpDisableError.value = e instanceof Error ? e.message : t('settings.disableFailed')
|
||||
} finally {
|
||||
totpDisableLoading.value = false
|
||||
}
|
||||
@ -1022,11 +1024,11 @@ const changePasswordForm = ref({
|
||||
})
|
||||
|
||||
function validatePasswordStrength(pw: string): string | null {
|
||||
if (pw.length < 12) return 'Password must be at least 12 characters'
|
||||
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
|
||||
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
|
||||
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
|
||||
if (pw.length < 12) return t('settings.passwordMinLength')
|
||||
if (!/[A-Z]/.test(pw)) return t('settings.passwordNeedUppercase')
|
||||
if (!/[a-z]/.test(pw)) return t('settings.passwordNeedLowercase')
|
||||
if (!/\d/.test(pw)) return t('settings.passwordNeedDigit')
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return t('settings.passwordNeedSpecial')
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1035,11 +1037,11 @@ async function handleChangePassword() {
|
||||
changePasswordSuccess.value = ''
|
||||
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
changePasswordError.value = 'All fields are required'
|
||||
changePasswordError.value = t('settings.passwordAllFieldsRequired')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
changePasswordError.value = 'New passwords do not match'
|
||||
changePasswordError.value = t('settings.passwordMismatch')
|
||||
return
|
||||
}
|
||||
const strengthError = validatePasswordStrength(newPassword)
|
||||
@ -1054,13 +1056,13 @@ async function handleChangePassword() {
|
||||
newPassword,
|
||||
alsoChangeSsh,
|
||||
})
|
||||
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
|
||||
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
||||
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
@ -1190,28 +1192,28 @@ async function createBackup() {
|
||||
showCreateBackupModal.value = false
|
||||
backupPassphrase.value = ''
|
||||
backupDescription.value = ''
|
||||
showBackupStatus('Backup created successfully', 'success')
|
||||
showBackupStatus(t('settings.backupCreatedSuccess'), 'success')
|
||||
await loadBackups()
|
||||
} catch {
|
||||
showBackupStatus('Failed to create backup', 'error')
|
||||
showBackupStatus(t('settings.backupCreateFailed'), 'error')
|
||||
} finally {
|
||||
creatingBackup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBackup(id: string) {
|
||||
const passphrase = prompt('Enter backup passphrase to verify:')
|
||||
const passphrase = prompt(t('settings.verifyPassphrasePrompt'))
|
||||
if (!passphrase) return
|
||||
verifyingBackupId.value = id
|
||||
try {
|
||||
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
|
||||
if (res.valid) {
|
||||
showBackupStatus('Backup verified — integrity OK', 'success')
|
||||
showBackupStatus(t('settings.backupVerifiedOk'), 'success')
|
||||
} else {
|
||||
showBackupStatus(`Verification failed: ${res.error || 'Unknown error'}`, 'error')
|
||||
showBackupStatus(t('settings.backupVerifyFailed', { error: res.error || 'Unknown error' }), 'error')
|
||||
}
|
||||
} catch {
|
||||
showBackupStatus('Verification request failed', 'error')
|
||||
showBackupStatus(t('settings.backupVerifyRequestFailed'), 'error')
|
||||
} finally {
|
||||
verifyingBackupId.value = null
|
||||
}
|
||||
@ -1229,23 +1231,23 @@ async function restoreBackup() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
|
||||
showRestoreModal.value = false
|
||||
showBackupStatus('Backup restored. Restart may be needed.', 'success')
|
||||
showBackupStatus(t('settings.backupRestored'), 'success')
|
||||
} catch {
|
||||
showBackupStatus('Restore failed — check passphrase', 'error')
|
||||
showBackupStatus(t('settings.backupRestoreFailed'), 'error')
|
||||
} finally {
|
||||
restoringBackup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(id: string) {
|
||||
if (!confirm('Delete this backup permanently?')) return
|
||||
if (!confirm(t('settings.deleteBackupConfirm'))) return
|
||||
deletingBackupId.value = id
|
||||
try {
|
||||
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
||||
showBackupStatus('Backup deleted', 'success')
|
||||
showBackupStatus(t('settings.backupDeleted'), 'success')
|
||||
await loadBackups()
|
||||
} catch {
|
||||
showBackupStatus('Failed to delete backup', 'error')
|
||||
showBackupStatus(t('settings.backupDeleteFailed'), 'error')
|
||||
} finally {
|
||||
deletingBackupId.value = null
|
||||
}
|
||||
@ -1269,12 +1271,12 @@ const testingWebhook = ref(false)
|
||||
const webhookStatusMsg = ref('')
|
||||
const webhookStatusType = ref<'success' | 'error'>('success')
|
||||
|
||||
const webhookEventTypes = [
|
||||
{ id: 'container_crash', label: 'Container Crash', description: 'A running container stops unexpectedly' },
|
||||
{ id: 'update_available', label: 'Update Available', description: 'A new system or app update is ready' },
|
||||
{ id: 'disk_warning', label: 'Disk Space Warning', description: 'Disk usage exceeds warning threshold' },
|
||||
{ id: 'backup_complete', label: 'Backup Complete', description: 'A scheduled or manual backup finishes' },
|
||||
]
|
||||
const webhookEventTypes = computed(() => [
|
||||
{ id: 'container_crash', label: t('settings.containerCrash'), description: t('settings.containerCrashDesc') },
|
||||
{ id: 'update_available', label: t('settings.updateAvailableEvent'), description: t('settings.updateAvailableDesc') },
|
||||
{ id: 'disk_warning', label: t('settings.diskSpaceWarning'), description: t('settings.diskWarningDesc') },
|
||||
{ id: 'backup_complete', label: t('settings.backupComplete'), description: t('settings.backupCompleteDesc') },
|
||||
])
|
||||
|
||||
function showWebhookStatus(msg: string, type: 'success' | 'error') {
|
||||
webhookStatusMsg.value = msg
|
||||
@ -1319,9 +1321,9 @@ async function saveWebhookConfig() {
|
||||
events: webhookConfig.value.events,
|
||||
},
|
||||
})
|
||||
showWebhookStatus('Webhook configuration saved', 'success')
|
||||
showWebhookStatus(t('settings.webhookSaved'), 'success')
|
||||
} catch {
|
||||
showWebhookStatus('Failed to save webhook configuration', 'error')
|
||||
showWebhookStatus(t('settings.webhookSaveFailed'), 'error')
|
||||
} finally {
|
||||
savingWebhook.value = false
|
||||
}
|
||||
@ -1332,12 +1334,12 @@ async function testWebhook() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ sent: boolean; url: string }>({ method: 'webhook.test' })
|
||||
if (res.sent) {
|
||||
showWebhookStatus('Test webhook sent successfully', 'success')
|
||||
showWebhookStatus(t('settings.webhookTestSent'), 'success')
|
||||
} else {
|
||||
showWebhookStatus('Test failed: webhook not sent', 'error')
|
||||
showWebhookStatus(t('settings.webhookTestFailed'), 'error')
|
||||
}
|
||||
} catch {
|
||||
showWebhookStatus('Failed to send test webhook', 'error')
|
||||
showWebhookStatus(t('settings.webhookSendFailed'), 'error')
|
||||
} finally {
|
||||
testingWebhook.value = false
|
||||
}
|
||||
@ -1361,15 +1363,15 @@ async function backupToUsb(backupId: string) {
|
||||
const mounted = drives.filter(d => d.mount_point)
|
||||
const target = mounted[0]
|
||||
if (!target?.mount_point) {
|
||||
showBackupStatus('No mounted USB drives found. Insert and mount a USB drive first.', 'error')
|
||||
showBackupStatus(t('settings.noUsbDrives'), 'error')
|
||||
return
|
||||
}
|
||||
const label = target.label || target.device
|
||||
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
|
||||
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
|
||||
showBackupStatus(`Backup copied to ${target.mount_point}`, 'success')
|
||||
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
|
||||
} catch {
|
||||
showBackupStatus('Failed to copy backup to USB', 'error')
|
||||
showBackupStatus(t('settings.backupUsbFailed'), 'error')
|
||||
} finally {
|
||||
usbCopyingId.value = null
|
||||
}
|
||||
|
||||
422
neode-ui/src/views/SystemUpdate.vue
Normal file
422
neode-ui/src/views/SystemUpdate.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="mb-4 p-3 rounded-lg text-sm"
|
||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Current Version -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.currentSystem') }}</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
|
||||
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
|
||||
<p class="text-sm font-medium text-white">{{ lastCheckDisplay }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">{{ t('common.status') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="statusDotColor"></div>
|
||||
<p class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Update -->
|
||||
<div v-if="updateInfo" class="glass-card p-6 mb-6 border border-orange-400/30">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
|
||||
<p class="text-sm text-white/60">Version {{ updateInfo.version }} — {{ updateInfo.release_date }}</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Changelog -->
|
||||
<div v-if="updateInfo.changelog.length" class="mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-2">{{ t('systemUpdate.changelog') }}</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="(entry, i) in updateInfo.changelog" :key="i" class="text-sm text-white/60 flex gap-2">
|
||||
<span class="text-orange-400 shrink-0">•</span>
|
||||
<span>{{ entry }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Components -->
|
||||
<div v-if="updateInfo.components > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.componentsToUpdate', { count: updateInfo.components }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="!downloading && !applying"
|
||||
@click="downloadUpdate"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('systemUpdate.downloadUpdate') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="downloaded && !applying"
|
||||
@click="requestApply"
|
||||
class="glass-button rounded-lg px-6 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30"
|
||||
>
|
||||
{{ t('systemUpdate.applyUpdate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No update available -->
|
||||
<div v-else-if="!loading" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.upToDate') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">{{ t('systemUpdate.upToDateMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Download Progress -->
|
||||
<div v-if="downloading" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
|
||||
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full bg-orange-400 rounded-full transition-all duration-500"
|
||||
:style="{ width: downloadPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/60">{{ t('systemUpdate.percentComplete', { percent: downloadPercent }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
<div v-if="applying" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.applying') }}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="text-sm text-white/70">{{ t('systemUpdate.applyWarning') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Schedule -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-2">{{ t('systemUpdate.updateSchedule') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('systemUpdate.subtitle') }}</p>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
v-for="opt in scheduleOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
|
||||
:class="{ 'ring-1 ring-orange-400/50 bg-orange-500/10': schedule === opt.value }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="update-schedule"
|
||||
:value="opt.value"
|
||||
:checked="schedule === opt.value"
|
||||
@change="setSchedule(opt.value)"
|
||||
class="mt-1 accent-orange-400"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
|
||||
<p class="text-xs text-white/50">{{ opt.description }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="checkForUpdates"
|
||||
:disabled="loading"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ loading ? t('systemUpdate.checking') : t('systemUpdate.checkForUpdates') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="rollbackAvailable"
|
||||
@click="requestRollback"
|
||||
class="glass-button rounded-lg px-5 py-2 text-sm font-medium bg-red-500/10 border-red-400/20"
|
||||
>
|
||||
{{ t('systemUpdate.rollback') }}
|
||||
</button>
|
||||
<RouterLink to="/dashboard/settings" class="glass-button rounded-lg px-5 py-2 text-sm font-medium text-center">
|
||||
{{ t('systemUpdate.backToSettings') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'apply'
|
||||
? t('systemUpdate.applyMessage')
|
||||
: t('systemUpdate.rollbackMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'apply' ? t('systemUpdate.applyNow') : t('systemUpdate.rollbackButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface UpdateDetail {
|
||||
version: string
|
||||
release_date: string
|
||||
changelog: string[]
|
||||
components: number
|
||||
}
|
||||
|
||||
type ScheduleValue = 'manual' | 'daily_check' | 'auto_apply'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const scheduleOptions = computed<{ value: ScheduleValue; label: string; description: string }[]>(() => [
|
||||
{ value: 'manual', label: t('systemUpdate.manualOnly'), description: t('systemUpdate.manualOnlyDesc') },
|
||||
{ value: 'daily_check', label: t('systemUpdate.dailyCheck'), description: t('systemUpdate.dailyCheckDesc') },
|
||||
{ value: 'auto_apply', label: t('systemUpdate.autoApply'), description: t('systemUpdate.autoApplyDesc') },
|
||||
])
|
||||
|
||||
const schedule = ref<ScheduleValue>('daily_check')
|
||||
const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
const applying = ref(false)
|
||||
const confirmAction = ref<'apply' | 'rollback' | null>(null)
|
||||
const currentVersion = ref('0.0.0')
|
||||
const lastCheck = ref<string | null>(null)
|
||||
const updateInfo = ref<UpdateDetail | null>(null)
|
||||
const rollbackAvailable = ref(false)
|
||||
const updateInProgress = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const downloadPercent = ref(0)
|
||||
|
||||
const lastCheckDisplay = computed(() => {
|
||||
if (!lastCheck.value) return t('common.never')
|
||||
try {
|
||||
const d = new Date(lastCheck.value)
|
||||
return d.toLocaleString()
|
||||
} catch {
|
||||
return lastCheck.value
|
||||
}
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (applying.value) return t('systemUpdate.applying')
|
||||
if (downloading.value) return t('systemUpdate.downloading')
|
||||
if (updateInProgress.value) return t('systemUpdate.applying')
|
||||
if (updateInfo.value) return t('systemUpdate.updateAvailable')
|
||||
if (rollbackAvailable.value) return t('systemUpdate.rollback')
|
||||
return t('systemUpdate.upToDate')
|
||||
})
|
||||
|
||||
const statusDotColor = computed(() => {
|
||||
if (applying.value || downloading.value) return 'bg-orange-400 animate-pulse'
|
||||
if (updateInfo.value || updateInProgress.value) return 'bg-orange-400'
|
||||
return 'bg-green-400'
|
||||
})
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
if (applying.value || downloading.value || updateInfo.value || updateInProgress.value) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
|
||||
function showStatus(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
current_version: string
|
||||
last_check: string | null
|
||||
update_available: boolean
|
||||
update_in_progress: boolean
|
||||
rollback_available: boolean
|
||||
}>({ method: 'update.status' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInProgress.value = res.update_in_progress
|
||||
rollbackAvailable.value = res.rollback_available
|
||||
|
||||
if (res.update_in_progress) {
|
||||
downloaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load update status', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
loading.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
current_version: string
|
||||
last_check: string | null
|
||||
update_available: boolean
|
||||
update: UpdateDetail | null
|
||||
}>({ method: 'update.check' })
|
||||
currentVersion.value = res.current_version
|
||||
lastCheck.value = res.last_check
|
||||
updateInfo.value = res.update
|
||||
if (!res.update_available) {
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.checkFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Update check failed', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
downloading.value = true
|
||||
downloadPercent.value = 0
|
||||
statusMessage.value = ''
|
||||
|
||||
// Simulate incremental progress while waiting for the RPC
|
||||
const progressInterval = setInterval(() => {
|
||||
if (downloadPercent.value < 90) {
|
||||
downloadPercent.value += Math.random() * 15
|
||||
}
|
||||
}, 500)
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
total_bytes: number
|
||||
downloaded_bytes: number
|
||||
components_downloaded: number
|
||||
}>({ method: 'update.download' })
|
||||
downloadPercent.value = 100
|
||||
downloaded.value = true
|
||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.downloadFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Download failed', e)
|
||||
} finally {
|
||||
clearInterval(progressInterval)
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestApply() {
|
||||
confirmAction.value = 'apply'
|
||||
}
|
||||
|
||||
function requestRollback() {
|
||||
confirmAction.value = 'rollback'
|
||||
}
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmAction.value = null
|
||||
}
|
||||
|
||||
async function executeConfirm() {
|
||||
if (confirmAction.value === 'apply') {
|
||||
confirmAction.value = null
|
||||
await applyUpdate()
|
||||
} else if (confirmAction.value === 'rollback') {
|
||||
confirmAction.value = null
|
||||
await rollbackUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
applying.value = true
|
||||
statusMessage.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.apply' })
|
||||
showStatus(t('systemUpdate.applySuccess'))
|
||||
updateInfo.value = null
|
||||
downloaded.value = false
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackUpdate() {
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.rollback' })
|
||||
showStatus(t('systemUpdate.rollbackSuccess'))
|
||||
rollbackAvailable.value = false
|
||||
await loadStatus()
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.rollbackFailed'), true)
|
||||
if (import.meta.env.DEV) console.warn('Rollback failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ schedule: ScheduleValue }>({ method: 'update.get-schedule' })
|
||||
schedule.value = res.schedule
|
||||
} catch {
|
||||
if (import.meta.env.DEV) console.warn('Failed to load update schedule')
|
||||
}
|
||||
}
|
||||
|
||||
async function setSchedule(value: ScheduleValue) {
|
||||
schedule.value = value
|
||||
try {
|
||||
await rpcClient.call({ method: 'update.set-schedule', params: { schedule: value } })
|
||||
showStatus(`Schedule set to ${scheduleOptions.value.find(o => o.value === value)?.label}`)
|
||||
} catch (e) {
|
||||
showStatus('Failed to save schedule', true)
|
||||
if (import.meta.env.DEV) console.warn('Set schedule failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user