Skip to content

Commit 3f6946b

Browse files
feat(ui): Simplify Proxmox setup with auto-discovery
- Remove manual node/storage input fields from initial form - Add auto-discovery of nodes and storage pools after connection test - Show dropdown selectors for node/storage after successful test - Fix Docker warning to only show after Docker is selected - Add .env.example template for Proxmox credentials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c5d9b53 commit 3f6946b

4 files changed

Lines changed: 152 additions & 44 deletions

File tree

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Proxmox API Configuration
2+
# Copy this file to .env and fill in your values
3+
# DO NOT commit .env to version control!
4+
5+
# Proxmox host address (IP:port or hostname:port)
6+
PROXMOX_HOST=10.114.1.101:8006
7+
8+
# API Token ID in format: user@realm!tokenname
9+
PROXMOX_TOKEN_ID=root@pam!cosmos
10+
11+
# API Token Secret (generated by pveum user token add)
12+
PROXMOX_TOKEN_SECRET=your-token-secret-here
13+
14+
# Skip TLS verification (true for self-signed certs)
15+
PROXMOX_SKIP_TLS=true

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ restic-arm
2727
rclone
2828
rclone-arm
2929
test-backup
30-
/backups
30+
/backups.env

client/src/pages/newInstall/newInstall.jsx

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
33
import { Trans, useTranslation } from 'react-i18next';
44

55
// material-ui
6-
import { Alert, Button, Checkbox, CircularProgress, FormControl, FormHelperText, Grid, Stack, Tooltip, Typography, Card, CardContent, CardActionArea, TextField } from '@mui/material';
6+
import { Alert, Button, Checkbox, CircularProgress, FormControl, FormHelperText, Grid, Stack, Tooltip, Typography, Card, CardContent, CardActionArea, TextField, Select, MenuItem, InputLabel } from '@mui/material';
77

88
// ant-ui icons
99
import { CheckCircleOutlined, LeftOutlined, QuestionCircleFilled, QuestionCircleOutlined, RightOutlined } from '@ant-design/icons';
@@ -63,24 +63,44 @@ const NewInstall = () => {
6363
const [cleanInstall, setCleanInstall] = useState(true);
6464

6565
// Runtime selection state
66-
const [runtimeType, setRuntimeType] = useState('docker');
66+
const [runtimeType, setRuntimeType] = useState(null); // null = not selected yet
6767
const [proxmoxConfig, setProxmoxConfig] = useState({
6868
host: '',
69-
node: '',
7069
tokenID: '',
7170
tokenSecret: '',
72-
storage: 'local-lvm',
73-
skipTLSVerify: false
71+
node: '',
72+
storage: '',
73+
skipTLSVerify: true
7474
});
7575
const [proxmoxTestResult, setProxmoxTestResult] = useState(null);
7676
const [proxmoxTesting, setProxmoxTesting] = useState(false);
77+
const [availableNodes, setAvailableNodes] = useState([]);
78+
const [availableStorages, setAvailableStorages] = useState([]);
7779

7880
const testProxmoxConnection = async () => {
7981
setProxmoxTesting(true);
8082
setProxmoxTestResult(null);
83+
setAvailableNodes([]);
84+
setAvailableStorages([]);
8185
try {
82-
const result = await API.testProxmoxConnection(proxmoxConfig);
86+
const result = await API.testProxmoxConnection({
87+
host: proxmoxConfig.host,
88+
tokenID: proxmoxConfig.tokenID,
89+
tokenSecret: proxmoxConfig.tokenSecret,
90+
skipTLSVerify: proxmoxConfig.skipTLSVerify
91+
});
8392
setProxmoxTestResult(result);
93+
if (result.success) {
94+
// Populate discovered nodes and storage
95+
if (result.nodes && result.nodes.length > 0) {
96+
setAvailableNodes(result.nodes);
97+
setProxmoxConfig(prev => ({...prev, node: result.nodes[0]}));
98+
}
99+
if (result.storages && result.storages.length > 0) {
100+
setAvailableStorages(result.storages);
101+
setProxmoxConfig(prev => ({...prev, storage: result.storages[0]}));
102+
}
103+
}
84104
} catch (error) {
85105
setProxmoxTestResult({ success: false, message: error.message });
86106
}
@@ -192,7 +212,7 @@ const NewInstall = () => {
192212
transition: 'all 0.2s ease-in-out',
193213
'&:hover': { borderColor: '#1976d2' }
194214
}}
195-
onClick={() => { setRuntimeType('docker'); setProxmoxTestResult(null); }}
215+
onClick={() => { setRuntimeType('docker'); setProxmoxTestResult(null); setAvailableNodes([]); setAvailableStorages([]); }}
196216
>
197217
<CardContent sx={{ textAlign: 'center', py: 3 }}>
198218
<Typography variant="h1" sx={{ fontSize: '48px', mb: 1 }}>🐋</Typography>
@@ -214,7 +234,7 @@ const NewInstall = () => {
214234
transition: 'all 0.2s ease-in-out',
215235
'&:hover': { borderColor: '#e65100' }
216236
}}
217-
onClick={() => setRuntimeType('proxmox')}
237+
onClick={() => { setRuntimeType('proxmox'); }}
218238
>
219239
<CardContent sx={{ textAlign: 'center', py: 3 }}>
220240
<Typography variant="h1" sx={{ fontSize: '48px', mb: 1 }}>🖥️</Typography>
@@ -267,13 +287,6 @@ const NewInstall = () => {
267287
onChange={(e) => setProxmoxConfig({...proxmoxConfig, host: e.target.value})}
268288
placeholder="192.168.1.100:8006"
269289
/>
270-
<TextField
271-
fullWidth
272-
label={t('newInstall.proxmoxNode') || "Node Name"}
273-
value={proxmoxConfig.node}
274-
onChange={(e) => setProxmoxConfig({...proxmoxConfig, node: e.target.value})}
275-
placeholder="pve"
276-
/>
277290
<TextField
278291
fullWidth
279292
label={t('newInstall.proxmoxTokenID') || "API Token ID (user@realm!tokenname)"}
@@ -289,13 +302,6 @@ const NewInstall = () => {
289302
onChange={(e) => setProxmoxConfig({...proxmoxConfig, tokenSecret: e.target.value})}
290303
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
291304
/>
292-
<TextField
293-
fullWidth
294-
label={t('newInstall.proxmoxStorage') || "Storage Pool"}
295-
value={proxmoxConfig.storage}
296-
onChange={(e) => setProxmoxConfig({...proxmoxConfig, storage: e.target.value})}
297-
placeholder="local-lvm"
298-
/>
299305
<Stack direction="row" alignItems="center" spacing={1}>
300306
<Checkbox
301307
checked={proxmoxConfig.skipTLSVerify}
@@ -320,14 +326,51 @@ const NewInstall = () => {
320326
{proxmoxTestResult.message}
321327
</Alert>
322328
)}
329+
330+
{/* Show node/storage selection after successful connection */}
331+
{proxmoxTestResult && proxmoxTestResult.success && availableNodes.length > 0 && (
332+
<Stack spacing={2}>
333+
<FormControl fullWidth>
334+
<InputLabel>{t('newInstall.proxmoxNode') || "Node"}</InputLabel>
335+
<Select
336+
value={proxmoxConfig.node}
337+
label={t('newInstall.proxmoxNode') || "Node"}
338+
onChange={(e) => setProxmoxConfig({...proxmoxConfig, node: e.target.value})}
339+
>
340+
{availableNodes.map((node) => (
341+
<MenuItem key={node} value={node}>{node}</MenuItem>
342+
))}
343+
</Select>
344+
</FormControl>
345+
346+
{availableStorages.length > 0 && (
347+
<FormControl fullWidth>
348+
<InputLabel>{t('newInstall.proxmoxStorage') || "Storage Pool"}</InputLabel>
349+
<Select
350+
value={proxmoxConfig.storage}
351+
label={t('newInstall.proxmoxStorage') || "Storage Pool"}
352+
onChange={(e) => setProxmoxConfig({...proxmoxConfig, storage: e.target.value})}
353+
>
354+
{availableStorages.map((storage) => (
355+
<MenuItem key={storage} value={storage}>{storage}</MenuItem>
356+
))}
357+
</Select>
358+
</FormControl>
359+
)}
360+
</Stack>
361+
)}
323362
</Stack>
324363
)}
325364
</Stack>,
326365
nextButtonLabel: () => {
366+
if (!runtimeType) {
367+
return ''; // No runtime selected yet
368+
}
327369
if (runtimeType === 'docker') {
328370
return status && status.docker ? t('global.next') : t('newInstall.skipAction');
329371
} else {
330-
return proxmoxTestResult && proxmoxTestResult.success ? t('global.next') : '';
372+
// Proxmox: require successful test AND node selected
373+
return proxmoxTestResult && proxmoxTestResult.success && proxmoxConfig.node ? t('global.next') : '';
331374
}
332375
}
333376
},

src/proxmoxAPI.go

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ import (
1212

1313
type ProxmoxTestRequest struct {
1414
Host string `json:"host"`
15-
Node string `json:"node"`
1615
TokenID string `json:"tokenID"`
1716
TokenSecret string `json:"tokenSecret"`
1817
SkipTLSVerify bool `json:"skipTLSVerify"`
1918
}
2019

2120
type ProxmoxTestResponse struct {
22-
Success bool `json:"success"`
23-
Message string `json:"message"`
24-
Version string `json:"version,omitempty"`
21+
Success bool `json:"success"`
22+
Message string `json:"message"`
23+
Version string `json:"version,omitempty"`
24+
Nodes []string `json:"nodes,omitempty"`
25+
Storages []string `json:"storages,omitempty"`
2526
}
2627

2728
func TestProxmoxConnectionRoute(w http.ResponseWriter, req *http.Request) {
@@ -127,26 +128,75 @@ func TestProxmoxConnectionRoute(w http.ResponseWriter, req *http.Request) {
127128
return
128129
}
129130

130-
// If node specified, verify it exists
131-
if request.Node != "" {
132-
nodeURL := fmt.Sprintf("%s/api2/json/nodes/%s/status", host, request.Node)
133-
nodeReq, _ := http.NewRequest("GET", nodeURL, nil)
134-
nodeReq.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", request.TokenID, request.TokenSecret))
131+
// Fetch available nodes
132+
nodesURL := fmt.Sprintf("%s/api2/json/nodes", host)
133+
nodesReq, _ := http.NewRequest("GET", nodesURL, nil)
134+
nodesReq.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", request.TokenID, request.TokenSecret))
135+
136+
var nodeNames []string
137+
nodesResp, err := client.Do(nodesReq)
138+
if err == nil && nodesResp.StatusCode == 200 {
139+
var nodesData struct {
140+
Data []struct {
141+
Node string `json:"node"`
142+
} `json:"data"`
143+
}
144+
if err := json.NewDecoder(nodesResp.Body).Decode(&nodesData); err == nil {
145+
for _, n := range nodesData.Data {
146+
nodeNames = append(nodeNames, n.Node)
147+
}
148+
}
149+
nodesResp.Body.Close()
150+
}
135151

136-
nodeResp, err := client.Do(nodeReq)
137-
if err != nil || nodeResp.StatusCode != 200 {
138-
json.NewEncoder(w).Encode(ProxmoxTestResponse{
139-
Success: false,
140-
Message: fmt.Sprintf("Connected to Proxmox %s but node '%s' not found or inaccessible", versionResp.Data.Version, request.Node),
141-
})
142-
return
152+
// Fetch storage for first node (if available)
153+
var storageNames []string
154+
if len(nodeNames) > 0 {
155+
storageURL := fmt.Sprintf("%s/api2/json/nodes/%s/storage", host, nodeNames[0])
156+
storageReq, _ := http.NewRequest("GET", storageURL, nil)
157+
storageReq.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", request.TokenID, request.TokenSecret))
158+
159+
storageResp, err := client.Do(storageReq)
160+
if err == nil && storageResp.StatusCode == 200 {
161+
var storageData struct {
162+
Data []struct {
163+
Storage string `json:"storage"`
164+
Content string `json:"content"`
165+
} `json:"data"`
166+
}
167+
if err := json.NewDecoder(storageResp.Body).Decode(&storageData); err == nil {
168+
for _, s := range storageData.Data {
169+
// Only include storage that can hold rootdir or images
170+
if s.Content == "" ||
171+
contains(s.Content, "rootdir") ||
172+
contains(s.Content, "images") ||
173+
contains(s.Content, "vztmpl") {
174+
storageNames = append(storageNames, s.Storage)
175+
}
176+
}
177+
}
178+
storageResp.Body.Close()
143179
}
144-
nodeResp.Body.Close()
145180
}
146181

147182
json.NewEncoder(w).Encode(ProxmoxTestResponse{
148-
Success: true,
149-
Message: fmt.Sprintf("Connected to Proxmox VE %s", versionResp.Data.Version),
150-
Version: versionResp.Data.Version,
183+
Success: true,
184+
Message: fmt.Sprintf("Connected to Proxmox VE %s", versionResp.Data.Version),
185+
Version: versionResp.Data.Version,
186+
Nodes: nodeNames,
187+
Storages: storageNames,
151188
})
152189
}
190+
191+
func contains(s, substr string) bool {
192+
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsImpl(s, substr))
193+
}
194+
195+
func containsImpl(s, substr string) bool {
196+
for i := 0; i <= len(s)-len(substr); i++ {
197+
if s[i:i+len(substr)] == substr {
198+
return true
199+
}
200+
}
201+
return false
202+
}

0 commit comments

Comments
 (0)