Add guest URL support for PVE hosts

Related to discussion #615

Add optional GuestURL field to PVE instances and cluster endpoints,
allowing users to specify a separate guest-accessible URL for web UI
navigation that differs from the internal management URL.

Backend changes:
- Add GuestURL field to PVEInstance and ClusterEndpoint structs
- Add GuestURL field to Node model
- Update cluster auto-discovery to preserve existing GuestURL values
- Update node creation logic to populate GuestURL from config
- Update API handlers to accept and persist GuestURL field

Frontend changes:
- Add GuestURL input field to NodeModal for configuration
- Update NodeGroupHeader and NodeSummaryTable to use GuestURL for navigation
- Add GuestURL to Node and PVENodeConfig TypeScript interfaces

When GuestURL is configured, it will be used for navigation links
instead of the Host URL, allowing users to access PVE hosts through
a reverse proxy or different domain while maintaining internal API
connections.
This commit is contained in:
rcourtman 2025-11-05 19:06:08 +00:00
parent 3194b10398
commit b1831d7b3e
9 changed files with 66 additions and 10 deletions

View file

@ -65,6 +65,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const getCleanFormData = (nodeType: 'pve' | 'pbs' | 'pmg' = props.nodeType) => ({
name: '',
host: '',
guestURL: '',
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'),
setupMode: 'auto' as 'auto' | 'manual',
user: '',
@ -172,6 +173,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
setFormData({
name: node.name || '',
host: node.host || '',
guestURL: ('guestURL' in node ? node.guestURL : '') || '',
authType: node.hasPassword ? 'password' : 'token',
setupMode: 'auto',
user: username,
@ -211,6 +213,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
type: props.nodeType,
name: normalizedName,
host: data.host,
guestURL: data.guestURL,
fingerprint: data.fingerprint,
verifySSL: data.verifySSL,
};
@ -489,6 +492,28 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</p>
</Show>
</div>
<div class={formField}>
<label class={labelClass('flex items-center gap-1')}>
Guest URL <span class="text-gray-500 text-xs font-normal">(Optional)</span>
</label>
<input
type="text"
value={formData().guestURL}
onInput={(e) => updateField('guestURL', e.currentTarget.value)}
placeholder={
props.nodeType === 'pve'
? 'https://pve.yourdomain.com'
: props.nodeType === 'pbs'
? 'https://pbs.yourdomain.com'
: 'https://pmg.yourdomain.com'
}
class={controlClass()}
/>
<p class={formHelpText}>
Optional guest-accessible URL for navigation. If specified, this URL will be used when opening the web UI instead of the Host URL.
</p>
</div>
</div>
</div>

View file

@ -9,7 +9,7 @@ interface NodeGroupHeaderProps {
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const isOnline = () => props.node.status === 'online' && (props.node.uptime || 0) > 0;
const nodeUrl = () => props.node.host || `https://${props.node.name}:8006`;
const nodeUrl = () => props.node.guestURL || props.node.host || `https://${props.node.name}:8006`;
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);

View file

@ -509,7 +509,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<a
href={
isPVE
? node!.host || `https://${node!.name}:8006`
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"

View file

@ -40,6 +40,7 @@ export interface Node {
displayName?: string;
instance: string;
host: string;
guestURL?: string; // Optional guest-accessible URL (for navigation)
status: string;
type: string;
cpu: number;

View file

@ -6,6 +6,7 @@ export interface ClusterEndpoint {
NodeID: string;
NodeName: string;
Host: string;
GuestURL?: string;
IP: string;
Online: boolean;
LastSeen: string;
@ -15,6 +16,7 @@ export interface PVENodeConfig {
id: string;
name: string;
host: string;
guestURL?: string;
user: string;
hasPassword?: boolean;
hasToken?: boolean;

View file

@ -327,7 +327,7 @@ func (h *ConfigHandlers) maybeRefreshClusterInfo(instance *config.PVEInstance) {
h.clusterDetectMutex.Unlock()
clientConfig := config.CreateProxmoxConfig(instance)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name, instance.ClusterEndpoints)
if !isCluster || len(clusterEndpoints) == 0 {
log.Debug().
Str("instance", instance.Name).
@ -366,6 +366,7 @@ type NodeConfigRequest struct {
Type string `json:"type"` // "pve", "pbs", or "pmg"
Name string `json:"name"`
Host string `json:"host"`
GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation)
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
TokenName string `json:"tokenName,omitempty"`
@ -395,6 +396,7 @@ type NodeResponse struct {
Type string `json:"type"`
Name string `json:"name"`
Host string `json:"host"`
GuestURL string `json:"guestURL,omitempty"`
User string `json:"user,omitempty"`
HasPassword bool `json:"hasPassword"`
TokenName string `json:"tokenName,omitempty"`
@ -569,8 +571,19 @@ func validateNodeAPI(clusterNode proxmox.ClusterStatus, baseConfig proxmox.Clien
return true
}
// findExistingGuestURL looks up the GuestURL for a node from existing endpoints
func findExistingGuestURL(nodeName string, existingEndpoints []config.ClusterEndpoint) string {
for _, ep := range existingEndpoints {
if ep.NodeName == nodeName {
return ep.GuestURL
}
}
return ""
}
// detectPVECluster checks if a PVE node is part of a cluster and returns cluster information
func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) {
// If existingEndpoints is provided, GuestURL values will be preserved for matching nodes
func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, existingEndpoints []config.ClusterEndpoint) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) {
tempClient, err := proxmox.NewClient(clientConfig)
if err != nil {
log.Warn().Err(err).Msg("Failed to create client for cluster detection")
@ -664,6 +677,7 @@ func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string) (isClu
NodeID: clusterNode.ID,
NodeName: clusterNode.Name,
Host: schemePrefix + nodeHost,
GuestURL: findExistingGuestURL(clusterNode.Name, existingEndpoints),
Online: clusterNode.Online == 1,
LastSeen: time.Now(),
}
@ -695,6 +709,7 @@ func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string) (isClu
NodeID: clusterNode.ID,
NodeName: clusterNode.Name,
Host: schemePrefix + nodeHost,
GuestURL: findExistingGuestURL(clusterNode.Name, existingEndpoints),
Online: clusterNode.Online == 1,
LastSeen: time.Now(),
}
@ -737,6 +752,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
Type: "pve",
Name: pve.Name,
Host: pve.Host,
GuestURL: pve.GuestURL,
User: pve.User,
HasPassword: pve.Password != "",
TokenName: pve.TokenName,
@ -1152,7 +1168,7 @@ func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
verifySSL = *req.VerifySSL
}
clientConfig := config.CreateProxmoxConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, verifySSL)
isCluster, clusterName, clusterEndpoints = detectPVECluster(clientConfig, req.Name)
isCluster, clusterName, clusterEndpoints = detectPVECluster(clientConfig, req.Name, nil)
}
if isCluster {
@ -1187,6 +1203,7 @@ func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
pve := config.PVEInstance{
Name: req.Name,
Host: host, // Use normalized host
GuestURL: req.GuestURL,
User: req.User,
Password: req.Password,
TokenName: req.TokenName,
@ -1534,7 +1551,7 @@ func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Req
return
}
isCluster, _, clusterEndpoints := detectPVECluster(clientConfig, req.Name)
isCluster, _, clusterEndpoints := detectPVECluster(clientConfig, req.Name, nil)
response := map[string]interface{}{
"status": "success",
@ -1810,6 +1827,9 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
pve.Host = host
}
// Update GuestURL if provided
pve.GuestURL = req.GuestURL
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" || req.TokenValue != "" {
// Switching to or updating token authentication
@ -5414,7 +5434,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
VerifySSL: instance.VerifySSL,
}
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name, instance.ClusterEndpoints)
if isCluster {
instance.IsCluster = true
instance.ClusterName = clusterName
@ -5456,7 +5476,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
VerifySSL: verifySSL,
}
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, nodeConfig.Name)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, nodeConfig.Name, nil)
monitorVMs := true
if nodeConfig.MonitorVMs != nil {

View file

@ -388,7 +388,8 @@ func splitAndTrim(value string) []string {
// PVEInstance represents a Proxmox VE connection
type PVEInstance struct {
Name string
Host string // Primary endpoint (user-provided)
Host string // Primary endpoint (user-provided)
GuestURL string // Optional guest-accessible URL (for navigation)
User string
Password string
TokenName string
@ -414,6 +415,7 @@ type ClusterEndpoint struct {
NodeID string // Node ID in cluster
NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address
Online bool // Current online status
LastSeen time.Time // Last successful connection

View file

@ -66,7 +66,8 @@ type Node struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Instance string `json:"instance"`
Host string `json:"host"` // Full host URL from config
Host string `json:"host"` // Full host URL from config
GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation)
Status string `json:"status"`
Type string `json:"type"`
CPU float64 `json:"cpu"`

View file

@ -4885,12 +4885,16 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
nodeStart := time.Now()
displayName := getNodeDisplayName(instanceCfg, node.Node)
connectionHost := instanceCfg.Host
guestURL := instanceCfg.GuestURL
if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 {
for _, ep := range instanceCfg.ClusterEndpoints {
if strings.EqualFold(ep.NodeName, node.Node) {
if effective := clusterEndpointEffectiveURL(ep); effective != "" {
connectionHost = effective
}
if ep.GuestURL != "" {
guestURL = ep.GuestURL
}
break
}
}
@ -4939,6 +4943,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
DisplayName: displayName,
Instance: instanceName,
Host: connectionHost,
GuestURL: guestURL,
Status: effectiveStatus,
Type: "node",
CPU: safeFloat(node.CPU), // Already in percentage