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:
parent
3194b10398
commit
b1831d7b3e
9 changed files with 66 additions and 10 deletions
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue