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) => ({ const getCleanFormData = (nodeType: 'pve' | 'pbs' | 'pmg' = props.nodeType) => ({
name: '', name: '',
host: '', host: '',
guestURL: '',
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'), authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'),
setupMode: 'auto' as 'auto' | 'manual', setupMode: 'auto' as 'auto' | 'manual',
user: '', user: '',
@ -172,6 +173,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
setFormData({ setFormData({
name: node.name || '', name: node.name || '',
host: node.host || '', host: node.host || '',
guestURL: ('guestURL' in node ? node.guestURL : '') || '',
authType: node.hasPassword ? 'password' : 'token', authType: node.hasPassword ? 'password' : 'token',
setupMode: 'auto', setupMode: 'auto',
user: username, user: username,
@ -211,6 +213,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
type: props.nodeType, type: props.nodeType,
name: normalizedName, name: normalizedName,
host: data.host, host: data.host,
guestURL: data.guestURL,
fingerprint: data.fingerprint, fingerprint: data.fingerprint,
verifySSL: data.verifySSL, verifySSL: data.verifySSL,
}; };
@ -489,6 +492,28 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</p> </p>
</Show> </Show>
</div> </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>
</div> </div>

View file

@ -9,7 +9,7 @@ interface NodeGroupHeaderProps {
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => { export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
const isOnline = () => props.node.status === 'online' && (props.node.uptime || 0) > 0; 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 displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node); const showActualName = () => hasAlternateDisplayName(props.node);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,7 +66,8 @@ type Node struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Instance string `json:"instance"` 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"` Status string `json:"status"`
Type string `json:"type"` Type string `json:"type"`
CPU float64 `json:"cpu"` CPU float64 `json:"cpu"`

View file

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