Compare commits
9 Commits
dfc7694fb9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf3612c62 | ||
|
|
b324540ce0 | ||
|
|
6bbf48fe20 | ||
|
|
4307bc9996 | ||
|
|
35bc44bc32 | ||
|
|
a63519968e | ||
|
|
960282d4ed | ||
|
|
854a6023cd | ||
|
|
ee63423cab |
@@ -18,12 +18,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"@tanstack/react-query": "^5.62.10",
|
"@tanstack/react-query": "^5.62.10",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.4",
|
"react-router": "^7.9.4",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
|
|||||||
162
pnpm-lock.yaml
generated
162
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
|
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
|
||||||
|
'@radix-ui/react-checkbox':
|
||||||
|
specifier: ^1.3.3
|
||||||
|
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@radix-ui/react-collapsible':
|
'@radix-ui/react-collapsible':
|
||||||
specifier: ^1.1.11
|
specifier: ^1.1.11
|
||||||
version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -32,6 +35,12 @@ importers:
|
|||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
version: 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-switch':
|
||||||
|
specifier: ^1.2.6
|
||||||
|
version: 1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-tabs':
|
||||||
|
specifier: ^1.1.13
|
||||||
|
version: 1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.7
|
specifier: ^1.2.7
|
||||||
version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -68,6 +77,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.9.4
|
specifier: ^7.9.4
|
||||||
version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
@@ -564,6 +576,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-checkbox@1.3.3':
|
||||||
|
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-collapsible@1.1.11':
|
'@radix-ui/react-collapsible@1.1.11':
|
||||||
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
|
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -761,6 +786,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-presence@1.1.5':
|
||||||
|
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-primitive@2.1.3':
|
'@radix-ui/react-primitive@2.1.3':
|
||||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -774,6 +812,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-roving-focus@1.1.11':
|
||||||
|
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6':
|
'@radix-ui/react-select@2.2.6':
|
||||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -809,6 +860,32 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-switch@1.2.6':
|
||||||
|
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-tabs@1.1.13':
|
||||||
|
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-tooltip@1.2.7':
|
'@radix-ui/react-tooltip@1.2.7':
|
||||||
resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==}
|
resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2273,6 +2350,12 @@ packages:
|
|||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
|
sonner@2.0.7:
|
||||||
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2977,6 +3060,22 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.2
|
'@radix-ui/primitive': 1.1.2
|
||||||
@@ -3166,6 +3265,16 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||||
@@ -3175,6 +3284,23 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
@@ -3220,6 +3346,37 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
|
|
||||||
|
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.1.8
|
||||||
|
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||||
|
|
||||||
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.2
|
'@radix-ui/primitive': 1.1.2
|
||||||
@@ -4781,6 +4938,11 @@ snapshots:
|
|||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink, useParams } from 'react-router';
|
import { NavLink, useParams } from 'react-router';
|
||||||
import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Usb } from 'lucide-react';
|
import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Usb, Download, CheckCircle } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -71,7 +71,23 @@ export function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-2">
|
<div className="px-2 group-data-[collapsible=icon]:px-2">
|
||||||
<InstanceSwitcher />
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<InstanceSwitcher />
|
||||||
|
</div>
|
||||||
|
<NavLink to={`/instances/${instanceId}/cloud`}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip="Configure instance settings"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
@@ -100,29 +116,6 @@ export function AppSidebar() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<NavLink to={`/instances/${instanceId}/cloud`}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={isActive}
|
|
||||||
tooltip="Configure cloud settings and domains"
|
|
||||||
>
|
|
||||||
<div className={cn(
|
|
||||||
"p-1 rounded-md",
|
|
||||||
isActive && "bg-primary/10"
|
|
||||||
)}>
|
|
||||||
<CloudLightning className={cn(
|
|
||||||
"h-4 w-4",
|
|
||||||
isActive && "text-primary",
|
|
||||||
!isActive && "text-muted-foreground"
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
<span className="truncate">Cloud</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
@@ -177,6 +170,54 @@ export function AppSidebar() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem> */}
|
</SidebarMenuSubItem> */}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
<Container className="h-4 w-4" />
|
||||||
|
Cluster
|
||||||
|
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
<SidebarMenuSubItem>
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<NavLink to={`/instances/${instanceId}/control`}>
|
||||||
|
<div className="p-1 rounded-md">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Control Nodes</span>
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
|
<SidebarMenuSubItem>
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<NavLink to={`/instances/${instanceId}/worker`}>
|
||||||
|
<div className="p-1 rounded-md">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Worker Nodes</span>
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
|
<SidebarMenuSubItem>
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<NavLink to={`/instances/${instanceId}/cluster`}>
|
||||||
|
<div className="p-1 rounded-md">
|
||||||
|
<Container className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Cluster Services</span>
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
@@ -197,8 +238,8 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton>
|
<SidebarMenuButton>
|
||||||
<Container className="h-4 w-4" />
|
<AppWindow className="h-4 w-4" />
|
||||||
Cluster
|
Apps
|
||||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
@@ -206,22 +247,22 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
<NavLink to={`/instances/${instanceId}/infrastructure`}>
|
<NavLink to={`/instances/${instanceId}/apps/available`}>
|
||||||
<div className="p-1 rounded-md">
|
<div className="p-1 rounded-md">
|
||||||
<Play className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">Cluster Nodes</span>
|
<span className="truncate">Available</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
<NavLink to={`/instances/${instanceId}/cluster`}>
|
<NavLink to={`/instances/${instanceId}/apps/installed`}>
|
||||||
<div className="p-1 rounded-md">
|
<div className="p-1 rounded-md">
|
||||||
<Container className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">Cluster Services</span>
|
<span className="truncate">Installed</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
@@ -230,17 +271,6 @@ export function AppSidebar() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton asChild tooltip="Install and manage applications">
|
|
||||||
<NavLink to={`/instances/${instanceId}/apps`}>
|
|
||||||
<div className="p-1 rounded-md">
|
|
||||||
<AppWindow className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<span className="truncate">Apps</span>
|
|
||||||
</NavLink>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
||||||
<NavLink to={`/instances/${instanceId}/advanced`}>
|
<NavLink to={`/instances/${instanceId}/advanced`}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -37,6 +38,7 @@ interface MergedApp extends App {
|
|||||||
type TabView = 'available' | 'installed';
|
type TabView = 'available' | 'installed';
|
||||||
|
|
||||||
export function AppsComponent() {
|
export function AppsComponent() {
|
||||||
|
const location = useLocation();
|
||||||
const { currentInstance } = useInstanceContext();
|
const { currentInstance } = useInstanceContext();
|
||||||
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
||||||
const {
|
const {
|
||||||
@@ -51,7 +53,8 @@ export function AppsComponent() {
|
|||||||
isDeleting
|
isDeleting
|
||||||
} = useDeployedApps(currentInstance);
|
} = useDeployedApps(currentInstance);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabView>('available');
|
// Determine active tab from URL path
|
||||||
|
const activeTab: TabView = location.pathname.endsWith('/installed') ? 'installed' : 'available';
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||||
@@ -323,22 +326,6 @@ export function AppsComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="flex gap-2 mb-6 border-b pb-4">
|
|
||||||
<Button
|
|
||||||
variant={activeTab === 'available' ? 'default' : 'outline'}
|
|
||||||
onClick={() => setActiveTab('available')}
|
|
||||||
>
|
|
||||||
Available Apps ({availableApps.length})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={activeTab === 'installed' ? 'default' : 'outline'}
|
|
||||||
onClick={() => setActiveTab('installed')}
|
|
||||||
>
|
|
||||||
Installed Apps ({installedApps.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -8,13 +8,24 @@ import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, E
|
|||||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||||
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
|
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
|
||||||
import { useCluster } from '../hooks/useCluster';
|
import { useCluster } from '../hooks/useCluster';
|
||||||
|
import { useClusterStatus } from '../services/api/hooks/useCluster';
|
||||||
import { BootstrapModal } from './cluster/BootstrapModal';
|
import { BootstrapModal } from './cluster/BootstrapModal';
|
||||||
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
|
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
|
||||||
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
|
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
|
||||||
import type { NodeFormData } from './nodes/NodeForm';
|
import type { NodeFormData } from './nodes/NodeForm';
|
||||||
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
|
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
|
||||||
|
|
||||||
export function ClusterNodesComponent() {
|
interface ClusterNodesComponentProps {
|
||||||
|
filterRole?: 'controlplane' | 'worker';
|
||||||
|
hideDiscoveryWhenNodesGte?: number;
|
||||||
|
showBootstrap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClusterNodesComponent({
|
||||||
|
filterRole,
|
||||||
|
hideDiscoveryWhenNodesGte,
|
||||||
|
showBootstrap = true
|
||||||
|
}: ClusterNodesComponentProps = {}) {
|
||||||
const { currentInstance } = useInstanceContext();
|
const { currentInstance } = useInstanceContext();
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -23,7 +34,6 @@ export function ClusterNodesComponent() {
|
|||||||
addNode,
|
addNode,
|
||||||
addError,
|
addError,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
isDeleting,
|
|
||||||
deleteError,
|
deleteError,
|
||||||
discover,
|
discover,
|
||||||
isDiscovering,
|
isDiscovering,
|
||||||
@@ -47,7 +57,8 @@ export function ClusterNodesComponent() {
|
|||||||
status: clusterStatus
|
status: clusterStatus
|
||||||
} = useCluster(currentInstance);
|
} = useCluster(currentInstance);
|
||||||
|
|
||||||
const [discoverSubnet, setDiscoverSubnet] = useState('');
|
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
|
||||||
|
|
||||||
const [addNodeIp, setAddNodeIp] = useState('');
|
const [addNodeIp, setAddNodeIp] = useState('');
|
||||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||||
const [detectError, setDetectError] = useState<string | null>(null);
|
const [detectError, setDetectError] = useState<string | null>(null);
|
||||||
@@ -63,6 +74,8 @@ export function ClusterNodesComponent() {
|
|||||||
open: false,
|
open: false,
|
||||||
mode: 'add',
|
mode: 'add',
|
||||||
});
|
});
|
||||||
|
const [drawerEverOpened, setDrawerEverOpened] = useState(false);
|
||||||
|
const [deletingNodeHostname, setDeletingNodeHostname] = useState<string | null>(null);
|
||||||
|
|
||||||
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
|
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
|
||||||
|
|
||||||
@@ -119,6 +132,7 @@ export function ClusterNodesComponent() {
|
|||||||
// Fetch full hardware details for the discovered node
|
// Fetch full hardware details for the discovered node
|
||||||
try {
|
try {
|
||||||
const hardware = await getHardware(discovered.ip);
|
const hardware = await getHardware(discovered.ip);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
setDrawerState({
|
setDrawerState({
|
||||||
open: true,
|
open: true,
|
||||||
mode: 'add',
|
mode: 'add',
|
||||||
@@ -135,6 +149,7 @@ export function ClusterNodesComponent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const hardware = await getHardware(addNodeIp);
|
const hardware = await getHardware(addNodeIp);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
setDrawerState({
|
setDrawerState({
|
||||||
open: true,
|
open: true,
|
||||||
mode: 'add',
|
mode: 'add',
|
||||||
@@ -151,6 +166,7 @@ export function ClusterNodesComponent() {
|
|||||||
if (node.target_ip) {
|
if (node.target_ip) {
|
||||||
try {
|
try {
|
||||||
const hardware = await getHardware(node.target_ip);
|
const hardware = await getHardware(node.target_ip);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
setDrawerState({
|
setDrawerState({
|
||||||
open: true,
|
open: true,
|
||||||
mode: 'configure',
|
mode: 'configure',
|
||||||
@@ -165,6 +181,7 @@ export function ClusterNodesComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open drawer without detection data (either no target_ip or detection failed)
|
// Open drawer without detection data (either no target_ip or detection failed)
|
||||||
|
setDrawerEverOpened(true);
|
||||||
setDrawerState({
|
setDrawerState({
|
||||||
open: true,
|
open: true,
|
||||||
mode: 'configure',
|
mode: 'configure',
|
||||||
@@ -173,16 +190,27 @@ export function ClusterNodesComponent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSubmit = async (data: NodeFormData) => {
|
const handleAddSubmit = async (data: NodeFormData) => {
|
||||||
await addNode({
|
const nodeData = {
|
||||||
hostname: data.hostname,
|
hostname: data.hostname,
|
||||||
role: data.role,
|
role: filterRole || data.role,
|
||||||
disk: data.disk,
|
disk: data.disk,
|
||||||
target_ip: data.targetIp,
|
target_ip: data.targetIp,
|
||||||
current_ip: data.currentIp,
|
|
||||||
interface: data.interface,
|
interface: data.interface,
|
||||||
schematic_id: data.schematicId,
|
schematic_id: data.schematicId,
|
||||||
maintenance: data.maintenance,
|
maintenance: data.maintenance,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Add node configuration (if this fails, error is shown and drawer stays open)
|
||||||
|
await addNode(nodeData);
|
||||||
|
|
||||||
|
// Apply configuration immediately for new nodes
|
||||||
|
try {
|
||||||
|
await applyNode(data.hostname);
|
||||||
|
} catch (applyError) {
|
||||||
|
// Apply failed but node is added - user can use Apply button on card
|
||||||
|
console.error('Failed to apply node configuration:', applyError);
|
||||||
|
}
|
||||||
|
|
||||||
closeDrawer();
|
closeDrawer();
|
||||||
setAddNodeIp('');
|
setAddNodeIp('');
|
||||||
};
|
};
|
||||||
@@ -194,14 +222,10 @@ export function ClusterNodesComponent() {
|
|||||||
nodeName: drawerState.node.hostname,
|
nodeName: drawerState.node.hostname,
|
||||||
updates: {
|
updates: {
|
||||||
role: data.role,
|
role: data.role,
|
||||||
config: {
|
target_ip: data.targetIp,
|
||||||
disk: data.disk,
|
interface: data.interface,
|
||||||
target_ip: data.targetIp,
|
schematic_id: data.schematicId,
|
||||||
current_ip: data.currentIp,
|
maintenance: data.maintenance,
|
||||||
interface: data.interface,
|
|
||||||
schematic_id: data.schematicId,
|
|
||||||
maintenance: data.maintenance,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
closeDrawer();
|
closeDrawer();
|
||||||
@@ -214,23 +238,32 @@ export function ClusterNodesComponent() {
|
|||||||
await applyNode(drawerState.node.hostname);
|
await applyNode(drawerState.node.hostname);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNode = (hostname: string) => {
|
const handleDeleteNode = async (hostname: string) => {
|
||||||
if (!currentInstance) return;
|
if (!currentInstance) return;
|
||||||
if (confirm(`Are you sure you want to remove node ${hostname}?`)) {
|
if (confirm(`Reset and remove node ${hostname}?\n\nThis will reset the node and remove it from the cluster. The node will reboot to maintenance mode and can be reconfigured.`)) {
|
||||||
deleteNode(hostname);
|
setDeletingNodeHostname(hostname);
|
||||||
|
try {
|
||||||
|
await deleteNode(hostname);
|
||||||
|
} finally {
|
||||||
|
setDeletingNodeHostname(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscover = () => {
|
const handleDiscover = () => {
|
||||||
setDiscoverError(null);
|
setDiscoverError(null);
|
||||||
setDiscoverSuccess(null);
|
setDiscoverSuccess(null);
|
||||||
// Pass subnet only if it's not empty, otherwise auto-detect
|
// Always use auto-detect to scan all local networks
|
||||||
discover(discoverSubnet || undefined);
|
discover(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Derive status from backend state flags for each node
|
// Derive status from backend state flags for each node
|
||||||
const assignedNodes = nodes.map(node => {
|
const assignedNodes = useMemo(() => {
|
||||||
|
const allNodes = nodes.map(node => {
|
||||||
|
// Get runtime status from cluster status
|
||||||
|
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
|
||||||
|
|
||||||
let status = 'pending';
|
let status = 'pending';
|
||||||
if (node.maintenance) {
|
if (node.maintenance) {
|
||||||
status = 'provisioning';
|
status = 'provisioning';
|
||||||
@@ -239,9 +272,23 @@ export function ClusterNodesComponent() {
|
|||||||
} else if (node.applied) {
|
} else if (node.applied) {
|
||||||
status = 'ready';
|
status = 'ready';
|
||||||
}
|
}
|
||||||
return { ...node, status };
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
status,
|
||||||
|
isReachable: runtimeStatus?.ready,
|
||||||
|
inKubernetes: runtimeStatus?.ready, // Whether in cluster (from backend 'ready' field)
|
||||||
|
kubernetesReady: runtimeStatus?.kubernetes_ready, // Whether K8s Ready condition is true
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter by role if specified
|
||||||
|
if (filterRole) {
|
||||||
|
return allNodes.filter(node => node.role === filterRole);
|
||||||
|
}
|
||||||
|
return allNodes;
|
||||||
|
}, [nodes, clusterStatusData, filterRole]);
|
||||||
|
|
||||||
// Check if cluster needs bootstrap
|
// Check if cluster needs bootstrap
|
||||||
const needsBootstrap = useMemo(() => {
|
const needsBootstrap = useMemo(() => {
|
||||||
// Find first ready control plane node
|
// Find first ready control plane node
|
||||||
@@ -251,7 +298,9 @@ export function ClusterNodesComponent() {
|
|||||||
|
|
||||||
// Check if cluster is already bootstrapped using cluster status
|
// Check if cluster is already bootstrapped using cluster status
|
||||||
// The backend checks for kubeconfig existence and cluster connectivity
|
// The backend checks for kubeconfig existence and cluster connectivity
|
||||||
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped' && clusterStatus?.status !== undefined;
|
// Status is "not_bootstrapped" when kubeconfig doesn't exist
|
||||||
|
// Any other status (ready, degraded, unreachable) means cluster is bootstrapped
|
||||||
|
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped';
|
||||||
|
|
||||||
return hasReadyControlPlane && !hasBootstrapped;
|
return hasReadyControlPlane && !hasBootstrapped;
|
||||||
}, [assignedNodes, clusterStatus]);
|
}, [assignedNodes, clusterStatus]);
|
||||||
@@ -319,7 +368,7 @@ export function ClusterNodesComponent() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bootstrap Alert */}
|
{/* Bootstrap Alert */}
|
||||||
{needsBootstrap && firstReadyControl && (
|
{showBootstrap && needsBootstrap && firstReadyControl && (
|
||||||
<Alert variant="info" className="mb-6">
|
<Alert variant="info" className="mb-6">
|
||||||
<CheckCircle className="h-5 w-5" />
|
<CheckCircle className="h-5 w-5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -416,26 +465,22 @@ export function ClusterNodesComponent() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* DISCOVERY SECTION - Scan subnet for nodes */}
|
{/* ADD NODES SECTION - Discovery and manual add combined */}
|
||||||
|
{(!hideDiscoveryWhenNodesGte || assignedNodes.length < hideDiscoveryWhenNodesGte) && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||||
Discover Nodes on Network
|
Add Nodes to Cluster
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Scan a specific subnet or leave empty to auto-detect all local networks
|
Discover nodes on the network or manually add by IP address
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3 mb-4">
|
{/* Discovery button */}
|
||||||
<Input
|
<div className="flex gap-2 mb-4">
|
||||||
type="text"
|
|
||||||
value={discoverSubnet}
|
|
||||||
onChange={(e) => setDiscoverSubnet(e.target.value)}
|
|
||||||
placeholder="192.168.1.0/24 (optional - leave empty to auto-detect)"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDiscover}
|
onClick={handleDiscover}
|
||||||
disabled={isDiscovering || discoveryStatus?.active}
|
disabled={isDiscovering || discoveryStatus?.active}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{isDiscovering || discoveryStatus?.active ? (
|
{isDiscovering || discoveryStatus?.active ? (
|
||||||
<>
|
<>
|
||||||
@@ -443,7 +488,7 @@ export function ClusterNodesComponent() {
|
|||||||
Discovering...
|
Discovering...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Discover'
|
'Discover Nodes'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{(isDiscovering || discoveryStatus?.active) && (
|
{(isDiscovering || discoveryStatus?.active) && (
|
||||||
@@ -458,71 +503,63 @@ export function ClusterNodesComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Discovered nodes display */}
|
||||||
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
|
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="space-y-3 mb-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
{discoveryStatus.nodes_found.map((discovered) => (
|
||||||
Discovered {discoveryStatus.nodes_found.length} node(s)
|
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||||
</h4>
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
{discoveryStatus.nodes_found.map((discovered) => (
|
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
|
||||||
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
{discovered.version && discovered.version !== 'maintenance' && (
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''}
|
{discovered.version}
|
||||||
</p>
|
</p>
|
||||||
{discovered.hostname && (
|
)}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">{discovered.hostname}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleAddFromDiscovery(discovered)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Add to Cluster
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddFromDiscovery(discovered)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Add to Cluster
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ADD NODE SECTION - Add single node by IP */}
|
{/* Manual add by IP - styled like a list item */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="border border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
<div className="flex items-center gap-3">
|
||||||
Add Single Node
|
<Input
|
||||||
</h3>
|
type="text"
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
value={addNodeIp}
|
||||||
Add a node by IP address to detect hardware and configure
|
onChange={(e) => setAddNodeIp(e.target.value)}
|
||||||
</p>
|
placeholder="192.168.8.128"
|
||||||
|
className="flex-1 font-mono"
|
||||||
<div className="flex gap-3">
|
/>
|
||||||
<Input
|
<Button
|
||||||
type="text"
|
onClick={handleAddNode}
|
||||||
value={addNodeIp}
|
disabled={isGettingHardware}
|
||||||
onChange={(e) => setAddNodeIp(e.target.value)}
|
size="sm"
|
||||||
placeholder="192.168.8.128"
|
>
|
||||||
className="flex-1"
|
{isGettingHardware ? (
|
||||||
/>
|
<>
|
||||||
<Button
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
onClick={handleAddNode}
|
Detecting...
|
||||||
disabled={isGettingHardware}
|
</>
|
||||||
variant="secondary"
|
) : (
|
||||||
>
|
'Add to Cluster'
|
||||||
{isGettingHardware ? (
|
)}
|
||||||
<>
|
</Button>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
</div>
|
||||||
Detecting...
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
||||||
</>
|
Add a node by IP address if not discovered automatically
|
||||||
) : (
|
</p>
|
||||||
'Add Node'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -576,10 +613,21 @@ export function ClusterNodesComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.talosVersion && (
|
{(node.version || node.schematic_id) && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Talos: {node.talosVersion}
|
{node.version && <span>Talos: {node.version}</span>}
|
||||||
{node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`}
|
{node.version && node.schematic_id && <span> • </span>}
|
||||||
|
{node.schematic_id && (
|
||||||
|
<span
|
||||||
|
title={node.schematic_id}
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(node.schematic_id!);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Schema: {node.schematic_id.substring(0, 8)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -604,9 +652,9 @@ export function ClusterNodesComponent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => handleDeleteNode(node.hostname)}
|
onClick={() => handleDeleteNode(node.hostname)}
|
||||||
disabled={isDeleting}
|
disabled={deletingNodeHostname === node.hostname}
|
||||||
>
|
>
|
||||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
|
{deletingNodeHostname === node.hostname ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,17 +689,19 @@ export function ClusterNodesComponent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Node Form Drawer */}
|
{/* Node Form Drawer - only render after first open to prevent infinite loop on initial mount */}
|
||||||
<NodeFormDrawer
|
{drawerEverOpened && (
|
||||||
open={drawerState.open}
|
<NodeFormDrawer
|
||||||
onClose={closeDrawer}
|
open={drawerState.open}
|
||||||
mode={drawerState.mode}
|
onClose={closeDrawer}
|
||||||
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
|
mode={drawerState.mode}
|
||||||
detection={drawerState.detection}
|
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
|
||||||
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
|
detection={drawerState.detection}
|
||||||
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
|
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
|
||||||
instanceName={currentInstance || ''}
|
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
|
||||||
/>
|
instanceName={currentInstance || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
5
src/components/ControlNodesComponent.tsx
Normal file
5
src/components/ControlNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||||
|
|
||||||
|
export function ControlNodesComponent() {
|
||||||
|
return <ClusterNodesComponent filterRole="controlplane" hideDiscoveryWhenNodesGte={3} showBootstrap={true} />;
|
||||||
|
}
|
||||||
5
src/components/WorkerNodesComponent.tsx
Normal file
5
src/components/WorkerNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||||
|
|
||||||
|
export function WorkerNodesComponent() {
|
||||||
|
return <ClusterNodesComponent filterRole="worker" hideDiscoveryWhenNodesGte={undefined} showBootstrap={false} />;
|
||||||
|
}
|
||||||
@@ -221,7 +221,7 @@ export function AppDetailModal({
|
|||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={{
|
components={{
|
||||||
// Style code blocks
|
// Style code blocks
|
||||||
code: ({node, inline, className, children, ...props}) => {
|
code: ({inline, children, ...props}) => {
|
||||||
return inline ? (
|
return inline ? (
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
|
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
|
||||||
{children}
|
{children}
|
||||||
@@ -233,7 +233,7 @@ export function AppDetailModal({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
// Make links open in new tab
|
// Make links open in new tab
|
||||||
a: ({node, children, href, ...props}) => (
|
a: ({children, href, ...props}) => (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export { CentralComponent } from './CentralComponent';
|
|||||||
export { DnsComponent } from './DnsComponent';
|
export { DnsComponent } from './DnsComponent';
|
||||||
export { DhcpComponent } from './DhcpComponent';
|
export { DhcpComponent } from './DhcpComponent';
|
||||||
export { PxeComponent } from './PxeComponent';
|
export { PxeComponent } from './PxeComponent';
|
||||||
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
|
||||||
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
||||||
export { AppsComponent } from './AppsComponent';
|
export { AppsComponent } from './AppsComponent';
|
||||||
export { SecretInput } from './SecretInput';
|
export { SecretInput } from './SecretInput';
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-fills currentIp from detection', async () => {
|
it('auto-fills targetIp from detection', async () => {
|
||||||
const config = createMockConfig();
|
const config = createMockConfig();
|
||||||
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
||||||
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
||||||
@@ -122,8 +122,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
{ wrapper: createWrapper(createTestQueryClient()) }
|
{ wrapper: createWrapper(createTestQueryClient()) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement;
|
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
|
||||||
expect(currentIpInput.value).toBe('192.168.1.75');
|
expect(targetIpInput.value).toBe('192.168.1.75');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits form with correct data', async () => {
|
it('submits form with correct data', async () => {
|
||||||
@@ -132,7 +132,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
||||||
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
||||||
|
|
||||||
const detection = createMockHardwareInfo();
|
// Don't provide detection.ip so VIP-based auto-calculation happens
|
||||||
|
const detection = createMockHardwareInfo({ ip: undefined });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<NodeForm
|
<NodeForm
|
||||||
@@ -154,7 +155,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
role: 'controlplane',
|
role: 'controlplane',
|
||||||
disk: '/dev/sda',
|
disk: '/dev/sda',
|
||||||
interface: 'eth0',
|
interface: 'eth0',
|
||||||
currentIp: '192.168.1.50',
|
|
||||||
maintenance: true,
|
maintenance: true,
|
||||||
schematicId: 'default-schematic-123',
|
schematicId: 'default-schematic-123',
|
||||||
targetIp: '192.168.1.101',
|
targetIp: '192.168.1.101',
|
||||||
@@ -201,7 +201,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
||||||
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
|
||||||
|
|
||||||
const detection = createMockHardwareInfo();
|
// Don't provide detection.ip so VIP-based auto-calculation happens
|
||||||
|
const detection = createMockHardwareInfo({ ip: undefined });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<NodeForm
|
<NodeForm
|
||||||
@@ -239,7 +240,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
||||||
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
|
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
|
||||||
|
|
||||||
const detection = createMockHardwareInfo();
|
// Don't provide detection.ip so VIP-based auto-calculation happens
|
||||||
|
const detection = createMockHardwareInfo({ ip: undefined });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<NodeForm
|
<NodeForm
|
||||||
@@ -275,7 +277,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
|
||||||
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
|
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
|
||||||
|
|
||||||
const detection = createMockHardwareInfo();
|
// Don't provide detection.ip so VIP-based auto-calculation happens
|
||||||
|
const detection = createMockHardwareInfo({ ip: undefined });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<NodeForm
|
<NodeForm
|
||||||
@@ -306,7 +309,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
role: 'controlplane',
|
role: 'controlplane',
|
||||||
disk: '/dev/nvme0n1',
|
disk: '/dev/nvme0n1',
|
||||||
targetIp: '192.168.1.105',
|
targetIp: '192.168.1.105',
|
||||||
currentIp: '192.168.1.60',
|
|
||||||
interface: 'eth1',
|
interface: 'eth1',
|
||||||
schematicId: 'existing-schematic-456',
|
schematicId: 'existing-schematic-456',
|
||||||
maintenance: false,
|
maintenance: false,
|
||||||
@@ -327,14 +329,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
|
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
|
||||||
expect(targetIpInput.value).toBe('192.168.1.105');
|
expect(targetIpInput.value).toBe('192.168.1.105');
|
||||||
|
|
||||||
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement;
|
|
||||||
expect(currentIpInput.value).toBe('192.168.1.60');
|
|
||||||
|
|
||||||
const schematicInput = screen.getByLabelText(/schematic id/i) as HTMLInputElement;
|
const schematicInput = screen.getByLabelText(/schematic id/i) as HTMLInputElement;
|
||||||
expect(schematicInput.value).toBe('existing-schematic-456');
|
expect(schematicInput.value).toBe('existing-schematic-456');
|
||||||
|
|
||||||
const maintenanceCheckbox = screen.getByLabelText(/maintenance/i) as HTMLInputElement;
|
|
||||||
expect(maintenanceCheckbox.checked).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT auto-generate hostname', async () => {
|
it('does NOT auto-generate hostname', async () => {
|
||||||
@@ -418,7 +414,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
role: 'controlplane',
|
role: 'controlplane',
|
||||||
disk: '/dev/nvme0n1',
|
disk: '/dev/nvme0n1',
|
||||||
targetIp: '192.168.1.105',
|
targetIp: '192.168.1.105',
|
||||||
currentIp: '192.168.1.60',
|
|
||||||
interface: 'eth0',
|
interface: 'eth0',
|
||||||
schematicId: 'existing-schematic-456',
|
schematicId: 'existing-schematic-456',
|
||||||
maintenance: false,
|
maintenance: false,
|
||||||
@@ -553,7 +548,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
disk: '/dev/nvme0n1',
|
disk: '/dev/nvme0n1',
|
||||||
interface: 'eth1',
|
interface: 'eth1',
|
||||||
targetIp: '192.168.1.105',
|
targetIp: '192.168.1.105',
|
||||||
currentIp: '192.168.1.60',
|
|
||||||
schematicId: 'existing-schematic',
|
schematicId: 'existing-schematic',
|
||||||
maintenance: false,
|
maintenance: false,
|
||||||
};
|
};
|
||||||
@@ -589,7 +583,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
disk: '/dev/nvme0n1', // NOT /dev/sda from detection
|
disk: '/dev/nvme0n1', // NOT /dev/sda from detection
|
||||||
interface: 'eth1', // NOT eth0 from detection
|
interface: 'eth1', // NOT eth0 from detection
|
||||||
targetIp: '192.168.1.105',
|
targetIp: '192.168.1.105',
|
||||||
currentIp: '192.168.1.60',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -881,8 +874,9 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
const hostnameInput = screen.getByLabelText(/hostname/i) as HTMLInputElement;
|
const hostnameInput = screen.getByLabelText(/hostname/i) as HTMLInputElement;
|
||||||
expect(hostnameInput.value).toBe('test-control-1');
|
expect(hostnameInput.value).toBe('test-control-1');
|
||||||
|
|
||||||
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement;
|
// Control plane nodes should auto-calculate targetIp from VIP (192.168.1.100 + 1)
|
||||||
expect(currentIpInput.value).toBe('');
|
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
|
||||||
|
expect(targetIpInput.value).toBe('192.168.1.101');
|
||||||
|
|
||||||
const diskInput = screen.getByLabelText(/disk/i) as HTMLInputElement;
|
const diskInput = screen.getByLabelText(/disk/i) as HTMLInputElement;
|
||||||
expect(diskInput.value).toBe('');
|
expect(diskInput.value).toBe('');
|
||||||
@@ -906,8 +900,8 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
{ wrapper: createWrapper(createTestQueryClient()) }
|
{ wrapper: createWrapper(createTestQueryClient()) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement;
|
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
|
||||||
expect(currentIpInput.value).toBe('192.168.1.75');
|
expect(targetIpInput.value).toBe('192.168.1.75');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles detection with no disks', async () => {
|
it('handles detection with no disks', async () => {
|
||||||
@@ -1219,7 +1213,6 @@ describe('NodeForm Integration Tests', () => {
|
|||||||
role: 'worker' as const,
|
role: 'worker' as const,
|
||||||
disk: '/dev/sda',
|
disk: '/dev/sda',
|
||||||
interface: 'eth0',
|
interface: 'eth0',
|
||||||
currentIp: '192.168.1.50',
|
|
||||||
maintenance: true,
|
maintenance: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export interface NodeFormData {
|
|||||||
role: 'controlplane' | 'worker';
|
role: 'controlplane' | 'worker';
|
||||||
disk: string;
|
disk: string;
|
||||||
targetIp: string;
|
targetIp: string;
|
||||||
currentIp?: string;
|
|
||||||
interface?: string;
|
interface?: string;
|
||||||
schematicId?: string;
|
schematicId?: string;
|
||||||
maintenance: boolean;
|
maintenance: boolean;
|
||||||
@@ -28,6 +27,7 @@ interface NodeFormProps {
|
|||||||
detection?: HardwareInfo;
|
detection?: HardwareInfo;
|
||||||
onSubmit: (data: NodeFormData) => Promise<void>;
|
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||||
onApply?: (data: NodeFormData) => Promise<void>;
|
onApply?: (data: NodeFormData) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
showApplyButton?: boolean;
|
showApplyButton?: boolean;
|
||||||
instanceName?: string;
|
instanceName?: string;
|
||||||
@@ -110,8 +110,7 @@ function getInitialValues(
|
|||||||
hostname: initial?.hostname || defaultHostname,
|
hostname: initial?.hostname || defaultHostname,
|
||||||
role,
|
role,
|
||||||
disk: defaultDisk,
|
disk: defaultDisk,
|
||||||
targetIp: initial?.targetIp || '', // Don't auto-fill targetIp from detection
|
targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
|
||||||
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
|
|
||||||
interface: defaultInterface,
|
interface: defaultInterface,
|
||||||
schematicId: initial?.schematicId || '',
|
schematicId: initial?.schematicId || '',
|
||||||
maintenance: initial?.maintenance ?? true,
|
maintenance: initial?.maintenance ?? true,
|
||||||
@@ -123,6 +122,7 @@ export function NodeForm({
|
|||||||
detection,
|
detection,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onApply,
|
onApply,
|
||||||
|
onCancel,
|
||||||
submitLabel = 'Save',
|
submitLabel = 'Save',
|
||||||
showApplyButton = false,
|
showApplyButton = false,
|
||||||
instanceName,
|
instanceName,
|
||||||
@@ -150,19 +150,29 @@ export function NodeForm({
|
|||||||
const role = watch('role');
|
const role = watch('role');
|
||||||
const hostname = watch('hostname');
|
const hostname = watch('hostname');
|
||||||
|
|
||||||
// Reset form when initialValues change (e.g., switching to configure a different node)
|
// Reset form when switching between different nodes in configure mode
|
||||||
// This ensures select boxes and all fields show the current values
|
// This ensures select boxes and all fields show the current values
|
||||||
// Use a ref to track the hostname to avoid infinite loops from object reference changes
|
// Use refs to track both the hostname and mode to avoid unnecessary resets
|
||||||
const prevHostnameRef = useRef<string | undefined>(undefined);
|
const prevHostnameRef = useRef<string | undefined>(undefined);
|
||||||
|
const prevModeRef = useRef<'add' | 'configure' | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentHostname = initialValues?.hostname;
|
const currentHostname = initialValues?.hostname;
|
||||||
// Only reset if the hostname actually changed (switching between nodes)
|
const currentMode = initialValues?.hostname ? 'configure' : 'add';
|
||||||
if (currentHostname !== prevHostnameRef.current) {
|
|
||||||
|
// Only reset if we're actually switching between different nodes in configure mode
|
||||||
|
// or switching from add to configure mode (or vice versa)
|
||||||
|
const modeChanged = currentMode !== prevModeRef.current;
|
||||||
|
const hostnameChanged = currentMode === 'configure' && currentHostname !== prevHostnameRef.current;
|
||||||
|
|
||||||
|
if (modeChanged || hostnameChanged) {
|
||||||
prevHostnameRef.current = currentHostname;
|
prevHostnameRef.current = currentHostname;
|
||||||
|
prevModeRef.current = currentMode;
|
||||||
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
|
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
|
||||||
reset(newValues);
|
reset(newValues);
|
||||||
}
|
}
|
||||||
}, [initialValues, detection, nodes, hostnamePrefix, reset]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialValues, detection, nodes, hostnamePrefix]);
|
||||||
|
|
||||||
// Set default role based on existing control plane nodes
|
// Set default role based on existing control plane nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,14 +186,16 @@ export function NodeForm({
|
|||||||
setValue('role', defaultRole);
|
setValue('role', defaultRole);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [nodes, initialValues?.role, setValue, watch]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nodes, initialValues?.role]);
|
||||||
|
|
||||||
// Pre-populate schematic ID from cluster config if available
|
// Pre-populate schematic ID from cluster config if available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
|
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
|
||||||
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
|
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
|
||||||
}
|
}
|
||||||
}, [instanceConfig, schematicId, setValue]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceConfig, schematicId]);
|
||||||
|
|
||||||
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -282,13 +294,21 @@ export function NodeForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [role, nodes, hostnamePrefix, setValue, watch, isExistingNode]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [role, nodes, hostnamePrefix, isExistingNode]);
|
||||||
|
|
||||||
// Auto-calculate target IP for control plane nodes
|
// Auto-calculate target IP for control plane nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if this is an existing node (configure mode)
|
// Skip if this is an existing node (configure mode)
|
||||||
if (initialValues?.targetIp) return;
|
if (initialValues?.targetIp) return;
|
||||||
|
|
||||||
|
// Skip if there's a detection IP (hardware detection provides the actual IP)
|
||||||
|
if (detection?.ip) return;
|
||||||
|
|
||||||
|
// Skip if there's already a targetIp from detection
|
||||||
|
const currentTargetIp = watch('targetIp');
|
||||||
|
if (currentTargetIp && role === 'worker') return; // For workers, keep any existing value
|
||||||
|
|
||||||
const clusterConfig = instanceConfig?.cluster as any;
|
const clusterConfig = instanceConfig?.cluster as any;
|
||||||
const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
|
const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
|
||||||
|
|
||||||
@@ -340,8 +360,8 @@ export function NodeForm({
|
|||||||
|
|
||||||
// Set the calculated IP
|
// Set the calculated IP
|
||||||
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
||||||
} else if (role === 'worker') {
|
} else if (role === 'worker' && !detection?.ip) {
|
||||||
// For new worker nodes, clear target IP (let user set if needed)
|
// For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP
|
||||||
const currentTargetIp = watch('targetIp');
|
const currentTargetIp = watch('targetIp');
|
||||||
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
|
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
|
||||||
if (currentTargetIp && vip) {
|
if (currentTargetIp && vip) {
|
||||||
@@ -351,7 +371,8 @@ export function NodeForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [role, instanceConfig, nodes, initialValues?.targetIp, detection?.ip]);
|
||||||
|
|
||||||
// Build disk options from both detection and initial values
|
// Build disk options from both detection and initial values
|
||||||
const diskOptions = (() => {
|
const diskOptions = (() => {
|
||||||
@@ -431,21 +452,25 @@ export function NodeForm({
|
|||||||
name="disk"
|
name="disk"
|
||||||
control={control}
|
control={control}
|
||||||
rules={{ required: 'Disk is required' }}
|
rules={{ required: 'Disk is required' }}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
// Ensure we have a value - use the field value or fall back to first option
|
||||||
<SelectTrigger className="mt-1">
|
const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
|
||||||
<SelectValue placeholder="Select a disk" />
|
return (
|
||||||
</SelectTrigger>
|
<Select value={value} onValueChange={field.onChange}>
|
||||||
<SelectContent>
|
<SelectTrigger className="mt-1">
|
||||||
{diskOptions.map((disk) => (
|
<SelectValue placeholder="Select a disk" />
|
||||||
<SelectItem key={disk.path} value={disk.path}>
|
</SelectTrigger>
|
||||||
{disk.path}
|
<SelectContent>
|
||||||
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
{diskOptions.map((disk) => (
|
||||||
</SelectItem>
|
<SelectItem key={disk.path} value={disk.path}>
|
||||||
))}
|
{disk.path}
|
||||||
</SelectContent>
|
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
||||||
</Select>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -485,45 +510,30 @@ export function NodeForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="currentIp">Current IP Address</Label>
|
|
||||||
<Input
|
|
||||||
id="currentIp"
|
|
||||||
type="text"
|
|
||||||
{...register('currentIp')}
|
|
||||||
className="mt-1"
|
|
||||||
disabled={!!detection?.ip}
|
|
||||||
/>
|
|
||||||
{errors.currentIp && (
|
|
||||||
<p className="text-sm text-red-600 mt-1">{errors.currentIp.message}</p>
|
|
||||||
)}
|
|
||||||
{detection?.ip && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Auto-detected from hardware (read-only)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="interface">Network Interface</Label>
|
<Label htmlFor="interface">Network Interface</Label>
|
||||||
{interfaceOptions.length > 0 ? (
|
{interfaceOptions.length > 0 ? (
|
||||||
<Controller
|
<Controller
|
||||||
name="interface"
|
name="interface"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
// Ensure we have a value - use the field value or fall back to first option
|
||||||
<SelectTrigger className="mt-1">
|
const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
|
||||||
<SelectValue placeholder="Select interface..." />
|
return (
|
||||||
</SelectTrigger>
|
<Select value={value} onValueChange={field.onChange}>
|
||||||
<SelectContent>
|
<SelectTrigger className="mt-1">
|
||||||
{interfaceOptions.map((iface) => (
|
<SelectValue placeholder="Select interface..." />
|
||||||
<SelectItem key={iface} value={iface}>
|
</SelectTrigger>
|
||||||
{iface}
|
<SelectContent>
|
||||||
</SelectItem>
|
{interfaceOptions.map((iface) => (
|
||||||
))}
|
<SelectItem key={iface} value={iface}>
|
||||||
</SelectContent>
|
{iface}
|
||||||
</Select>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -557,37 +567,37 @@ export function NodeForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="maintenance"
|
|
||||||
type="checkbox"
|
|
||||||
{...register('maintenance')}
|
|
||||||
className="h-4 w-4 rounded border-input"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="maintenance" className="font-normal">
|
|
||||||
Start in maintenance mode
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{onCancel && (
|
||||||
type="submit"
|
<Button
|
||||||
disabled={isSubmitting}
|
type="button"
|
||||||
className="flex-1"
|
variant="outline"
|
||||||
>
|
onClick={() => {
|
||||||
{isSubmitting ? 'Saving...' : submitLabel}
|
reset();
|
||||||
</Button>
|
onCancel();
|
||||||
|
}}
|
||||||
{showApplyButton && onApply && (
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showApplyButton && onApply ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit(onApply)}
|
onClick={handleSubmit(onApply)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
|
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : submitLabel}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export function NodeFormDrawer({
|
|||||||
role: node.role,
|
role: node.role,
|
||||||
disk: node.disk,
|
disk: node.disk,
|
||||||
targetIp: node.target_ip,
|
targetIp: node.target_ip,
|
||||||
currentIp: node.current_ip,
|
|
||||||
interface: node.interface,
|
interface: node.interface,
|
||||||
schematicId: node.schematic_id,
|
schematicId: node.schematic_id,
|
||||||
maintenance: node.maintenance ?? true,
|
maintenance: node.maintenance ?? true,
|
||||||
@@ -58,6 +57,7 @@ export function NodeFormDrawer({
|
|||||||
detection={detection}
|
detection={detection}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onApply={onApply}
|
onApply={onApply}
|
||||||
|
onCancel={onClose}
|
||||||
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
|
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
|
||||||
showApplyButton={mode === 'configure'}
|
showApplyButton={mode === 'configure'}
|
||||||
instanceName={instanceName}
|
instanceName={instanceName}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ interface ServiceConfigEditorProps {
|
|||||||
export function ServiceConfigEditor({
|
export function ServiceConfigEditor({
|
||||||
instanceName,
|
instanceName,
|
||||||
serviceName,
|
serviceName,
|
||||||
manifest: _manifestProp, // Ignore the prop, fetch from status instead
|
manifest: _manifest, // Ignore the prop, fetch from status instead
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ServiceConfigEditorProps) {
|
}: ServiceConfigEditorProps) {
|
||||||
|
// Suppress unused variable warning - kept for API compatibility
|
||||||
|
void _manifest;
|
||||||
const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName);
|
const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName);
|
||||||
const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName);
|
const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName);
|
||||||
|
|
||||||
|
|||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -54,6 +54,9 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
|
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTemplatesMutation = useMutation({
|
const fetchTemplatesMutation = useMutation({
|
||||||
@@ -71,6 +74,13 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip),
|
mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetMutation = useMutation({
|
||||||
|
mutationFn: (nodeName: string) => nodesApi.reset(instanceName!, nodeName),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: nodesQuery.data?.nodes || [],
|
nodes: nodesQuery.data?.nodes || [],
|
||||||
isLoading: nodesQuery.isLoading,
|
isLoading: nodesQuery.isLoading,
|
||||||
@@ -101,6 +111,9 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
isFetchingTemplates: fetchTemplatesMutation.isPending,
|
isFetchingTemplates: fetchTemplatesMutation.isPending,
|
||||||
cancelDiscovery: cancelDiscoveryMutation.mutate,
|
cancelDiscovery: cancelDiscoveryMutation.mutate,
|
||||||
isCancellingDiscovery: cancelDiscoveryMutation.isPending,
|
isCancellingDiscovery: cancelDiscoveryMutation.isPending,
|
||||||
|
resetNode: resetMutation.mutate,
|
||||||
|
isResetting: resetMutation.isPending,
|
||||||
|
resetError: resetMutation.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,17 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
|
||||||
Usb,
|
Usb,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CloudLightning,
|
CloudLightning,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
|
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
|
||||||
import { assetsApi } from '../../services/api/assets';
|
import { assetsApi } from '../../services/api/assets';
|
||||||
import type { AssetType } from '../../services/api/types/asset';
|
|
||||||
|
|
||||||
export function AssetsIsoPage() {
|
export function AssetsIsoPage() {
|
||||||
const { data, isLoading, error } = useAssetList();
|
const { data, isLoading, error } = useAssetList();
|
||||||
const downloadAsset = useDownloadAsset();
|
const downloadAsset = useDownloadAsset();
|
||||||
const [selectedSchematicId, setSelectedSchematicId] = useState<string | null>(null);
|
const [selectedSchematicId] = useState<string | null>(null);
|
||||||
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
|
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
|
||||||
const { data: statusData } = useAssetStatus(selectedSchematicId);
|
const { data: statusData } = useAssetStatus(selectedSchematicId);
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export function ClusterHealthPage() {
|
|||||||
<Skeleton className="h-8 w-24" />
|
<Skeleton className="h-8 w-24" />
|
||||||
) : status ? (
|
) : status ? (
|
||||||
<div>
|
<div>
|
||||||
<Badge variant={status.ready ? 'outline' : 'secondary'} className={status.ready ? 'border-green-500' : ''}>
|
<Badge variant={status.status === 'ready' ? 'outline' : 'secondary'} className={status.status === 'ready' ? 'border-green-500' : ''}>
|
||||||
{status.ready ? 'Ready' : 'Not Ready'}
|
{status.status === 'ready' ? 'Ready' : 'Not Ready'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{status.nodes} nodes total
|
{status.nodes} nodes total
|
||||||
|
|||||||
10
src/router/pages/ControlNodesPage.tsx
Normal file
10
src/router/pages/ControlNodesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { ControlNodesComponent } from '../../components/ControlNodesComponent';
|
||||||
|
|
||||||
|
export function ControlNodesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ControlNodesComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
|
<div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{status.ready ? 'Ready' : 'Not ready'}
|
{status.status === 'ready' ? 'Ready' : 'Not ready'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { ErrorBoundary } from '../../components';
|
|
||||||
import { ClusterNodesComponent } from '../../components/ClusterNodesComponent';
|
|
||||||
|
|
||||||
export function InfrastructurePage() {
|
|
||||||
// Note: onComplete callback removed as phase management will be handled differently with routing
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ClusterNodesComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,22 +15,20 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
|
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
|
||||||
import { assetsApi } from '../../services/api/assets';
|
import { assetsApi } from '../../services/api/assets';
|
||||||
import type { Platform } from '../../services/api/types/asset';
|
import type { Platform, Asset } from '../../services/api/types/asset';
|
||||||
|
|
||||||
// Helper function to extract version from ISO filename
|
// Helper function to extract platform from filename
|
||||||
// Filename format: talos-v1.11.2-metal-amd64.iso
|
// Filename format: metal-amd64.iso
|
||||||
function extractVersionFromPath(path: string): string {
|
function extractPlatformFromPath(path: string): string {
|
||||||
const filename = path.split('/').pop() || '';
|
const filename = path.split('/').pop() || '';
|
||||||
const match = filename.match(/talos-(v\d+\.\d+\.\d+)-metal/);
|
const match = filename.match(/-(amd64|arm64)\./);
|
||||||
return match ? match[1] : 'unknown';
|
return match ? match[1] : 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to extract platform from ISO filename
|
// Type for ISO asset with schematic and version info
|
||||||
// Filename format: talos-v1.11.2-metal-amd64.iso
|
interface IsoAssetWithMetadata extends Asset {
|
||||||
function extractPlatformFromPath(path: string): string {
|
schematic_id: string;
|
||||||
const filename = path.split('/').pop() || '';
|
version: string;
|
||||||
const match = filename.match(/-(amd64|arm64)\.iso$/);
|
|
||||||
return match ? match[1] : 'unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IsoPage() {
|
export function IsoPage() {
|
||||||
@@ -38,8 +36,8 @@ export function IsoPage() {
|
|||||||
const downloadAsset = useDownloadAsset();
|
const downloadAsset = useDownloadAsset();
|
||||||
const deleteAsset = useDeleteAsset();
|
const deleteAsset = useDeleteAsset();
|
||||||
|
|
||||||
const [schematicId, setSchematicId] = useState('');
|
const [schematicId, setSchematicId] = useState('434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82');
|
||||||
const [selectedVersion, setSelectedVersion] = useState('v1.11.2');
|
const [selectedVersion, setSelectedVersion] = useState('v1.11.5');
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
|
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
@@ -53,10 +51,10 @@ export function IsoPage() {
|
|||||||
try {
|
try {
|
||||||
await downloadAsset.mutateAsync({
|
await downloadAsset.mutateAsync({
|
||||||
schematicId,
|
schematicId,
|
||||||
|
version: selectedVersion,
|
||||||
request: {
|
request: {
|
||||||
version: selectedVersion,
|
|
||||||
platform: selectedPlatform,
|
platform: selectedPlatform,
|
||||||
assets: ['iso']
|
asset_types: ['iso']
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Refresh the list after download
|
// Refresh the list after download
|
||||||
@@ -69,13 +67,13 @@ export function IsoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (schematicIdToDelete: string) => {
|
const handleDelete = async (schematicIdToDelete: string, versionToDelete: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this schematic and all its assets? This action cannot be undone.')) {
|
if (!confirm(`Are you sure you want to delete ${schematicIdToDelete}@${versionToDelete} and all its assets? This action cannot be undone.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAsset.mutateAsync(schematicIdToDelete);
|
await deleteAsset.mutateAsync({ schematicId: schematicIdToDelete, version: versionToDelete });
|
||||||
await refetch();
|
await refetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err);
|
console.error('Delete failed:', err);
|
||||||
@@ -83,17 +81,16 @@ export function IsoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find all ISO assets from all schematics (including multiple ISOs per schematic)
|
// Find all ISO assets from all assets (schematic@version combinations)
|
||||||
const isoAssets = data?.schematics
|
const isoAssets = data?.assets?.flatMap(asset => {
|
||||||
.flatMap(schematic => {
|
// Get ALL ISO assets for this schematic@version
|
||||||
// Get ALL ISO assets for this schematic (not just the first one)
|
const isoAssetsForAsset = asset.assets.filter(a => a.type === 'iso');
|
||||||
const isoAssetsForSchematic = schematic.assets.filter(asset => asset.type === 'iso');
|
return isoAssetsForAsset.map(isoAsset => ({
|
||||||
return isoAssetsForSchematic.map(isoAsset => ({
|
...isoAsset,
|
||||||
...isoAsset,
|
schematic_id: asset.schematic_id,
|
||||||
schematic_id: schematic.schematic_id,
|
version: asset.version
|
||||||
version: schematic.version
|
}));
|
||||||
}));
|
}) || [];
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -146,46 +143,6 @@ export function IsoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Schematic ID Input */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">
|
|
||||||
Schematic ID
|
|
||||||
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={schematicId}
|
|
||||||
onChange={(e) => setSchematicId(e.target.value)}
|
|
||||||
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
|
|
||||||
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Get your schematic ID from the{' '}
|
|
||||||
<a
|
|
||||||
href="https://factory.talos.dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Talos Image Factory
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">Talos Version</label>
|
|
||||||
<select
|
|
||||||
value={selectedVersion}
|
|
||||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
|
||||||
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
|
|
||||||
>
|
|
||||||
<option value="v1.11.2">v1.11.2</option>
|
|
||||||
<option value="v1.11.1">v1.11.1</option>
|
|
||||||
<option value="v1.11.0">v1.11.0</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Platform Selection */}
|
{/* Platform Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-2 block">Platform</label>
|
<label className="text-sm font-medium mb-2 block">Platform</label>
|
||||||
@@ -215,6 +172,49 @@ export function IsoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">Talos Version</label>
|
||||||
|
<select
|
||||||
|
value={selectedVersion}
|
||||||
|
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||||
|
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
|
||||||
|
>
|
||||||
|
<option value="v1.11.5">v1.11.5</option>
|
||||||
|
<option value="v1.11.4">v1.11.4</option>
|
||||||
|
<option value="v1.11.3">v1.11.3</option>
|
||||||
|
<option value="v1.11.2">v1.11.2</option>
|
||||||
|
<option value="v1.11.1">v1.11.1</option>
|
||||||
|
<option value="v1.11.0">v1.11.0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schematic ID Input */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
Schematic ID
|
||||||
|
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={schematicId}
|
||||||
|
onChange={(e) => setSchematicId(e.target.value)}
|
||||||
|
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Get your schematic ID from the{' '}
|
||||||
|
<a
|
||||||
|
href="https://factory.talos.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Talos Image Factory
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Download Button */}
|
{/* Download Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
@@ -264,11 +264,12 @@ export function IsoPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isoAssets.map((asset: any) => {
|
{isoAssets.map((asset: IsoAssetWithMetadata) => {
|
||||||
const version = extractVersionFromPath(asset.path || '');
|
|
||||||
const platform = extractPlatformFromPath(asset.path || '');
|
const platform = extractPlatformFromPath(asset.path || '');
|
||||||
|
// Use composite key for React key
|
||||||
|
const compositeKey = `${asset.schematic_id}@${asset.version}`;
|
||||||
return (
|
return (
|
||||||
<Card key={asset.schematic_id} className="p-4">
|
<Card key={compositeKey} className="p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
<Disc className="h-5 w-5 text-primary" />
|
<Disc className="h-5 w-5 text-primary" />
|
||||||
@@ -276,7 +277,7 @@ export function IsoPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h5 className="font-medium">Talos ISO</h5>
|
<h5 className="font-medium">Talos ISO</h5>
|
||||||
<Badge variant="outline">{version}</Badge>
|
<Badge variant="outline">{asset.version}</Badge>
|
||||||
<Badge variant="outline" className="uppercase">{platform}</Badge>
|
<Badge variant="outline" className="uppercase">{platform}</Badge>
|
||||||
{asset.downloaded ? (
|
{asset.downloaded ? (
|
||||||
<Badge variant="success" className="flex items-center gap-1">
|
<Badge variant="success" className="flex items-center gap-1">
|
||||||
@@ -292,7 +293,7 @@ export function IsoPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<div className="font-mono text-xs truncate">
|
<div className="font-mono text-xs truncate">
|
||||||
Schematic: {asset.schematic_id}
|
{asset.schematic_id}@{asset.version}
|
||||||
</div>
|
</div>
|
||||||
{asset.size && (
|
{asset.size && (
|
||||||
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
|
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||||
@@ -305,7 +306,7 @@ export function IsoPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = assetsApi.getAssetUrl(asset.schematic_id, 'iso');
|
window.location.href = assetsApi.getAssetUrl(asset.schematic_id, asset.version, 'iso');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-1" />
|
<Download className="h-4 w-4 mr-1" />
|
||||||
@@ -315,7 +316,7 @@ export function IsoPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
onClick={() => handleDelete(asset.schematic_id)}
|
onClick={() => handleDelete(asset.schematic_id, asset.version)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
10
src/router/pages/WorkerNodesPage.tsx
Normal file
10
src/router/pages/WorkerNodesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { WorkerNodesComponent } from '../../components/WorkerNodesComponent';
|
||||||
|
|
||||||
|
export function WorkerNodesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<WorkerNodesComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import { DnsPage } from './pages/DnsPage';
|
|||||||
import { DhcpPage } from './pages/DhcpPage';
|
import { DhcpPage } from './pages/DhcpPage';
|
||||||
import { PxePage } from './pages/PxePage';
|
import { PxePage } from './pages/PxePage';
|
||||||
import { IsoPage } from './pages/IsoPage';
|
import { IsoPage } from './pages/IsoPage';
|
||||||
import { InfrastructurePage } from './pages/InfrastructurePage';
|
import { ControlNodesPage } from './pages/ControlNodesPage';
|
||||||
|
import { WorkerNodesPage } from './pages/WorkerNodesPage';
|
||||||
import { ClusterPage } from './pages/ClusterPage';
|
import { ClusterPage } from './pages/ClusterPage';
|
||||||
import { AppsPage } from './pages/AppsPage';
|
import { AppsPage } from './pages/AppsPage';
|
||||||
|
import { BackupsPage } from './pages/BackupsPage';
|
||||||
import { AdvancedPage } from './pages/AdvancedPage';
|
import { AdvancedPage } from './pages/AdvancedPage';
|
||||||
import { AssetsIsoPage } from './pages/AssetsIsoPage';
|
import { AssetsIsoPage } from './pages/AssetsIsoPage';
|
||||||
import { AssetsPxePage } from './pages/AssetsPxePage';
|
import { AssetsPxePage } from './pages/AssetsPxePage';
|
||||||
@@ -92,8 +94,12 @@ export const routes: RouteObject[] = [
|
|||||||
element: <IsoPage />,
|
element: <IsoPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'infrastructure',
|
path: 'control',
|
||||||
element: <InfrastructurePage />,
|
element: <ControlNodesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'worker',
|
||||||
|
element: <WorkerNodesPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cluster',
|
path: 'cluster',
|
||||||
@@ -101,7 +107,24 @@ export const routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'apps',
|
path: 'apps',
|
||||||
element: <AppsPage />,
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <Navigate to="available" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'available',
|
||||||
|
element: <AppsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'installed',
|
||||||
|
element: <AppsPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'backups',
|
||||||
|
element: <BackupsPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'advanced',
|
path: 'advanced',
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import type { AssetListResponse, Schematic, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
|
import type { AssetListResponse, PXEAsset, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
|
||||||
|
|
||||||
// Get API base URL
|
// Get API base URL
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
||||||
|
|
||||||
export const assetsApi = {
|
export const assetsApi = {
|
||||||
// List all schematics
|
// List all assets (schematic@version combinations)
|
||||||
list: async (): Promise<AssetListResponse> => {
|
list: async (): Promise<AssetListResponse> => {
|
||||||
const response = await apiClient.get('/api/v1/assets');
|
const response = await apiClient.get('/api/v1/pxe/assets');
|
||||||
return response as AssetListResponse;
|
return response as AssetListResponse;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get schematic details
|
// Get asset details for specific schematic@version
|
||||||
get: async (schematicId: string): Promise<Schematic> => {
|
get: async (schematicId: string, version: string): Promise<PXEAsset> => {
|
||||||
const response = await apiClient.get(`/api/v1/assets/${schematicId}`);
|
const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}`);
|
||||||
return response as Schematic;
|
return response as PXEAsset;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download assets for a schematic
|
// Download assets for a schematic@version
|
||||||
download: async (schematicId: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
|
download: async (schematicId: string, version: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post(`/api/v1/assets/${schematicId}/download`, request);
|
const response = await apiClient.post(`/api/v1/pxe/assets/${schematicId}/${version}/download`, request);
|
||||||
return response as { message: string };
|
return response as { message: string };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get download status
|
// Get download status
|
||||||
status: async (schematicId: string): Promise<AssetStatusResponse> => {
|
status: async (schematicId: string, version: string): Promise<AssetStatusResponse> => {
|
||||||
const response = await apiClient.get(`/api/v1/assets/${schematicId}/status`);
|
const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}/status`);
|
||||||
return response as AssetStatusResponse;
|
return response as AssetStatusResponse;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get download URL for an asset (includes base URL for direct download)
|
// Get download URL for an asset (includes base URL for direct download)
|
||||||
getAssetUrl: (schematicId: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
|
getAssetUrl: (schematicId: string, version: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
|
||||||
return `${API_BASE_URL}/api/v1/assets/${schematicId}/pxe/${assetType}`;
|
return `${API_BASE_URL}/api/v1/pxe/assets/${schematicId}/${version}/pxe/${assetType}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete a schematic and all its assets
|
// Delete an asset (schematic@version) and all its files
|
||||||
delete: async (schematicId: string): Promise<{ message: string }> => {
|
delete: async (schematicId: string, version: string): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.delete(`/api/v1/assets/${schematicId}`);
|
const response = await apiClient.delete(`/api/v1/pxe/assets/${schematicId}/${version}`);
|
||||||
return response as { message: string };
|
return response as { message: string };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ export function useAssetList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAsset(schematicId: string | null | undefined) {
|
export function useAsset(schematicId: string | null | undefined, version: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['assets', schematicId],
|
queryKey: ['assets', schematicId, version],
|
||||||
queryFn: () => assetsApi.get(schematicId!),
|
queryFn: () => assetsApi.get(schematicId!, version!),
|
||||||
enabled: !!schematicId,
|
enabled: !!schematicId && !!version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAssetStatus(schematicId: string | null | undefined) {
|
export function useAssetStatus(schematicId: string | null | undefined, version: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['assets', schematicId, 'status'],
|
queryKey: ['assets', schematicId, version, 'status'],
|
||||||
queryFn: () => assetsApi.status(schematicId!),
|
queryFn: () => assetsApi.status(schematicId!, version!),
|
||||||
enabled: !!schematicId,
|
enabled: !!schematicId && !!version,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const data = query.state.data;
|
const data = query.state.data;
|
||||||
// Poll every 2 seconds if downloading
|
// Poll every 2 seconds if downloading
|
||||||
@@ -34,12 +34,12 @@ export function useDownloadAsset() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ schematicId, request }: { schematicId: string; request: DownloadAssetRequest }) =>
|
mutationFn: ({ schematicId, version, request }: { schematicId: string; version: string; request: DownloadAssetRequest }) =>
|
||||||
assetsApi.download(schematicId, request),
|
assetsApi.download(schematicId, version, request),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId] });
|
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, 'status'] });
|
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version, 'status'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,12 @@ export function useDeleteAsset() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (schematicId: string) => assetsApi.delete(schematicId),
|
mutationFn: ({ schematicId, version }: { schematicId: string; version: string }) =>
|
||||||
onSuccess: (_, schematicId) => {
|
assetsApi.delete(schematicId, version),
|
||||||
|
onSuccess: (_, { schematicId, version }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets', schematicId] });
|
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, version] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, 'status'] });
|
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, version, 'status'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,8 @@ export const nodesApi = {
|
|||||||
async fetchTemplates(instanceName: string): Promise<OperationResponse> {
|
async fetchTemplates(instanceName: string): Promise<OperationResponse> {
|
||||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`);
|
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reset(instanceName: string, nodeName: string): Promise<OperationResponse> {
|
||||||
|
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/${nodeName}/reset`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export interface Asset {
|
|||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schematic representation matching backend
|
// PXEAsset represents a schematic@version combination (composite key)
|
||||||
export interface Schematic {
|
export interface PXEAsset {
|
||||||
schematic_id: string;
|
schematic_id: string;
|
||||||
version: string;
|
version: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -19,13 +19,12 @@ export interface Schematic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetListResponse {
|
export interface AssetListResponse {
|
||||||
schematics: Schematic[];
|
assets: PXEAsset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadAssetRequest {
|
export interface DownloadAssetRequest {
|
||||||
version: string;
|
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
assets?: AssetType[];
|
asset_types?: string[];
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ export interface ClusterConfig {
|
|||||||
version?: string;
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClusterStatus {
|
export interface NodeStatus {
|
||||||
|
hostname: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
kubernetes_ready: boolean;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterStatus {
|
||||||
|
status: string; // "ready", "pending", "error", "not_bootstrapped", "unreachable", "degraded"
|
||||||
nodes: number;
|
nodes: number;
|
||||||
controlPlaneNodes: number;
|
controlPlaneNodes: number;
|
||||||
workerNodes: number;
|
workerNodes: number;
|
||||||
kubernetesVersion?: string;
|
kubernetesVersion?: string;
|
||||||
talosVersion?: string;
|
talosVersion?: string;
|
||||||
|
node_statuses?: Record<string, NodeStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthCheck {
|
export interface HealthCheck {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface Node {
|
|||||||
// Optional runtime fields for enhanced status
|
// Optional runtime fields for enhanced status
|
||||||
isReachable?: boolean;
|
isReachable?: boolean;
|
||||||
inKubernetes?: boolean;
|
inKubernetes?: boolean;
|
||||||
|
kubernetesReady?: boolean;
|
||||||
lastHealthCheck?: string;
|
lastHealthCheck?: string;
|
||||||
// Optional fields (not yet returned by API)
|
// Optional fields (not yet returned by API)
|
||||||
hardware?: HardwareInfo;
|
hardware?: HardwareInfo;
|
||||||
|
|||||||
@@ -35,24 +35,29 @@ export function deriveNodeStatus(node: Node): NodeStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.applied) {
|
if (node.applied) {
|
||||||
// Check Kubernetes membership for healthy state
|
// Check Kubernetes membership and readiness
|
||||||
if (node.inKubernetes === true) {
|
if (node.inKubernetes === true && node.kubernetesReady === true) {
|
||||||
return NodeStatus.HEALTHY;
|
return NodeStatus.HEALTHY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applied but not yet in Kubernetes (could be provisioning or ready)
|
// In Kubernetes but not Ready
|
||||||
if (node.isReachable === true) {
|
if (node.inKubernetes === true && node.kubernetesReady === false) {
|
||||||
|
return NodeStatus.DEGRADED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied and reachable but not yet in Kubernetes
|
||||||
|
if (node.isReachable === true && node.inKubernetes !== true) {
|
||||||
return NodeStatus.READY;
|
return NodeStatus.READY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applied but status unknown
|
// Applied but status unknown (no cluster status data yet)
|
||||||
if (node.isReachable === undefined && node.inKubernetes === undefined) {
|
if (node.isReachable === undefined && node.inKubernetes === undefined) {
|
||||||
return NodeStatus.READY;
|
return NodeStatus.READY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applied but having issues
|
// Applied but not reachable at all
|
||||||
if (node.inKubernetes === false) {
|
if (node.isReachable === false) {
|
||||||
return NodeStatus.DEGRADED;
|
return NodeStatus.UNREACHABLE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
wild-web-app/src/components/apps/appUtils.tsx
Normal file
111
wild-web-app/src/components/apps/appUtils.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
Database,
|
||||||
|
Globe,
|
||||||
|
Shield,
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import type { App } from '../../services/api';
|
||||||
|
|
||||||
|
export interface MergedApp extends App {
|
||||||
|
deploymentStatus?: 'added' | 'deployed';
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusIcon(status?: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'deploying':
|
||||||
|
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||||
|
case 'stopped':
|
||||||
|
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
||||||
|
case 'added':
|
||||||
|
return <Settings className="h-5 w-5 text-blue-500" />;
|
||||||
|
case 'deployed':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'available':
|
||||||
|
return <Download className="h-5 w-5 text-muted-foreground" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusBadge(app: MergedApp) {
|
||||||
|
// Determine status: runtime status > deployment status > available
|
||||||
|
const status = app.status?.status || app.deploymentStatus || 'available';
|
||||||
|
|
||||||
|
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
|
||||||
|
available: 'secondary',
|
||||||
|
added: 'outline',
|
||||||
|
deploying: 'default',
|
||||||
|
running: 'success',
|
||||||
|
error: 'destructive',
|
||||||
|
stopped: 'warning',
|
||||||
|
deployed: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
available: 'Available',
|
||||||
|
added: 'Added',
|
||||||
|
deploying: 'Deploying',
|
||||||
|
running: 'Running',
|
||||||
|
error: 'Error',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
deployed: 'Deployed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variants[status]}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategoryIcon(category?: string) {
|
||||||
|
switch (category) {
|
||||||
|
case 'database':
|
||||||
|
return <Database className="h-4 w-4" />;
|
||||||
|
case 'web':
|
||||||
|
return <Globe className="h-4 w-4" />;
|
||||||
|
case 'security':
|
||||||
|
return <Shield className="h-4 w-4" />;
|
||||||
|
case 'monitoring':
|
||||||
|
return <BarChart3 className="h-4 w-4" />;
|
||||||
|
case 'communication':
|
||||||
|
return <MessageSquare className="h-4 w-4" />;
|
||||||
|
case 'storage':
|
||||||
|
return <Database className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <AppWindow className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppIcon({ app }: { app: MergedApp }) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{app.icon && !imageError ? (
|
||||||
|
<img
|
||||||
|
src={app.icon}
|
||||||
|
alt={app.name}
|
||||||
|
className="h-full w-full object-contain p-1"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getCategoryIcon(app.category)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user