Add custom display name support for Docker hosts
This implements the ability for users to assign custom display names to Docker hosts,
similar to the existing functionality for Proxmox nodes. This addresses the issue where
multiple Docker hosts with identical hostnames but different IPs/domains cannot be
easily distinguished in the UI.
Backend changes:
- Add CustomDisplayName field to DockerHost model (internal/models/models.go:201)
- Update UpsertDockerHost to preserve custom display names across updates (internal/models/models.go:1110-1113)
- Add SetDockerHostCustomDisplayName method to State for updating names (internal/models/models.go:1221-1235)
- Add SetDockerHostCustomDisplayName method to Monitor (internal/monitoring/monitor.go:1070-1088)
- Add HandleSetCustomDisplayName API handler (internal/api/docker_agents.go:385-426)
- Route /api/agents/docker/hosts/{id}/display-name PUT requests (internal/api/docker_agents.go:117-120)
Frontend changes:
- Add customDisplayName field to DockerHost TypeScript interface (frontend-modern/src/types/api.ts:136)
- Add MonitoringAPI.setDockerHostDisplayName method (frontend-modern/src/api/monitoring.ts:151-187)
- Update getDisplayName function to prioritize custom names (frontend-modern/src/components/Settings/DockerAgents.tsx:84-89)
- Add inline editing UI with save/cancel buttons in Docker Agents settings (frontend-modern/src/components/Settings/DockerAgents.tsx:1349-1413)
- Update sorting to use custom display names (frontend-modern/src/components/Docker/DockerHosts.tsx:58-59)
- Update DockerHostSummaryTable to display custom names (frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx:40-42, 87, 120, 254)
Users can now click the edit icon next to any Docker host name in Settings > Docker Agents
to set a custom display name. The custom name will be preserved across agent reconnections
and takes priority over the hostname reported by the agent.
Related to #623
This commit is contained in:
parent
0647a76c55
commit
7936808193
8 changed files with 242 additions and 8 deletions
|
|
@ -148,6 +148,44 @@ export class MonitoringAPI {
|
|||
}
|
||||
}
|
||||
|
||||
static async setDockerHostDisplayName(hostId: string, displayName: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/display-name`;
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Docker host not found');
|
||||
}
|
||||
|
||||
let message = `Failed with status ${response.status}`;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text?.trim()) {
|
||||
message = text.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
}
|
||||
} catch (_jsonErr) {
|
||||
// ignore JSON parse errors, fallback to raw text
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore read error, keep default message
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
static async allowDockerHostReenroll(hostId: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/allow-reenroll`;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ const isHostOnline = (host: DockerHost) => {
|
|||
return status === 'online' || status === 'running' || status === 'healthy';
|
||||
};
|
||||
|
||||
const getDisplayName = (host: DockerHost) => {
|
||||
return host.customDisplayName || host.displayName || host.hostname || host.id;
|
||||
};
|
||||
|
||||
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
|
||||
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
||||
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
|
||||
|
|
@ -80,7 +84,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
let value = 0;
|
||||
switch (key) {
|
||||
case 'name':
|
||||
value = hostA.displayName.localeCompare(hostB.displayName);
|
||||
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
|
||||
break;
|
||||
case 'uptime':
|
||||
value = (a.uptimeSeconds || 0) - (b.uptimeSeconds || 0);
|
||||
|
|
@ -113,7 +117,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
}
|
||||
|
||||
if (value === 0) {
|
||||
value = hostA.displayName.localeCompare(hostB.displayName);
|
||||
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
|
||||
}
|
||||
|
||||
return dir === 'asc' ? value : -value;
|
||||
|
|
@ -247,9 +251,9 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
<td class="pr-2 py-1 pl-3 align-middle">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:flex-nowrap sm:whitespace-nowrap sm:min-w-0">
|
||||
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 sm:truncate sm:max-w-[200px]">
|
||||
{summary.host.displayName}
|
||||
{getDisplayName(summary.host)}
|
||||
</span>
|
||||
<Show when={summary.host.displayName !== summary.host.hostname}>
|
||||
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
|
||||
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 sm:whitespace-nowrap">
|
||||
({summary.host.hostname})
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
const sortedHosts = createMemo(() => {
|
||||
const hosts = props.hosts || [];
|
||||
return [...hosts].sort((a, b) => {
|
||||
const aName = a.displayName || a.hostname || a.id || '';
|
||||
const bName = b.displayName || b.hostname || b.id || '';
|
||||
const aName = a.customDisplayName || a.displayName || a.hostname || a.id || '';
|
||||
const bName = b.customDisplayName || b.displayName || b.hostname || b.id || '';
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ export const DockerAgents: Component = () => {
|
|||
const [tokenName, setTokenName] = createSignal('');
|
||||
const [commandQueuedTime, setCommandQueuedTime] = createSignal<Date | null>(null);
|
||||
const [elapsedSeconds, setElapsedSeconds] = createSignal(0);
|
||||
const [editingHostId, setEditingHostId] = createSignal<string | null>(null);
|
||||
const [editingDisplayName, setEditingDisplayName] = createSignal('');
|
||||
const [savingDisplayName, setSavingDisplayName] = createSignal(false);
|
||||
|
||||
const pulseUrl = () => getPulseBaseUrl();
|
||||
|
||||
|
|
@ -81,7 +84,10 @@ export const DockerAgents: Component = () => {
|
|||
return (state.dockerHosts || []).find(host => host.id === id) ?? null;
|
||||
});
|
||||
|
||||
const getDisplayName = (host: DockerHost | { id: string; displayName?: string | null; hostname?: string | null }) => {
|
||||
const getDisplayName = (host: DockerHost | { id: string; displayName?: string | null; hostname?: string | null; customDisplayName?: string | null }) => {
|
||||
if ('customDisplayName' in host && host.customDisplayName) {
|
||||
return host.customDisplayName;
|
||||
}
|
||||
return host.displayName || host.hostname || host.id;
|
||||
};
|
||||
|
||||
|
|
@ -518,6 +524,34 @@ WantedBy=multi-user.target`;
|
|||
}
|
||||
};
|
||||
|
||||
const startEditingDisplayName = (host: DockerHost) => {
|
||||
setEditingHostId(host.id);
|
||||
setEditingDisplayName(host.customDisplayName || '');
|
||||
};
|
||||
|
||||
const cancelEditingDisplayName = () => {
|
||||
setEditingHostId(null);
|
||||
setEditingDisplayName('');
|
||||
};
|
||||
|
||||
const saveDisplayName = async (hostId: string, originalName: string) => {
|
||||
const newName = editingDisplayName().trim();
|
||||
|
||||
setSavingDisplayName(true);
|
||||
try {
|
||||
await MonitoringAPI.setDockerHostDisplayName(hostId, newName);
|
||||
notificationStore.success(`Updated display name for ${originalName}`, 3500);
|
||||
setEditingHostId(null);
|
||||
setEditingDisplayName('');
|
||||
} catch (error) {
|
||||
logger.error('Failed to update display name', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update display name';
|
||||
notificationStore.error(message, 8000);
|
||||
} finally {
|
||||
setSavingDisplayName(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<Show when={hasRemovedHosts()}>
|
||||
|
|
@ -1312,7 +1346,71 @@ WantedBy=multi-user.target`;
|
|||
return (
|
||||
<tr class={`${isOnline ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800/50 opacity-60'}`}>
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{displayName}</div>
|
||||
<Show
|
||||
when={editingHostId() === host.id}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{displayName}</div>
|
||||
<Show when={host.customDisplayName && host.customDisplayName !== host.displayName}>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Original: {host.displayName || host.hostname}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
onClick={() => startEditingDisplayName(host)}
|
||||
title="Edit display name"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
value={editingDisplayName()}
|
||||
onInput={(e) => setEditingDisplayName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
saveDisplayName(host.id, displayName);
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEditingDisplayName();
|
||||
}
|
||||
}}
|
||||
placeholder="Custom display name"
|
||||
disabled={savingDisplayName()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-green-600 hover:text-green-700 disabled:opacity-50"
|
||||
onClick={() => saveDisplayName(host.id, displayName)}
|
||||
disabled={savingDisplayName()}
|
||||
title="Save"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
onClick={cancelEditingDisplayName}
|
||||
disabled={savingDisplayName()}
|
||||
title="Cancel"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{host.hostname}</div>
|
||||
<div class="mt-1 text-[10px] uppercase tracking-wide">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export interface DockerHost {
|
|||
agentId: string;
|
||||
hostname: string;
|
||||
displayName: string;
|
||||
customDisplayName?: string;
|
||||
machineId?: string;
|
||||
os?: string;
|
||||
kernelVersion?: string;
|
||||
|
|
|
|||
|
|
@ -113,6 +113,12 @@ func (h *DockerAgentHandlers) HandleDockerHostActions(w http.ResponseWriter, r *
|
|||
return
|
||||
}
|
||||
|
||||
// Check if this is a custom display name update request
|
||||
if strings.HasSuffix(r.URL.Path, "/display-name") && r.Method == http.MethodPut {
|
||||
h.HandleSetCustomDisplayName(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, handle as delete/hide request
|
||||
if r.Method == http.MethodDelete {
|
||||
h.HandleDeleteHost(w, r)
|
||||
|
|
@ -380,3 +386,47 @@ func (h *DockerAgentHandlers) HandleMarkPendingUninstall(w http.ResponseWriter,
|
|||
log.Error().Err(err).Msg("Failed to serialize docker host pending uninstall response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSetCustomDisplayName updates the custom display name for a docker host.
|
||||
func (h *DockerAgentHandlers) HandleSetCustomDisplayName(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
|
||||
trimmedPath = strings.TrimSuffix(trimmedPath, "/display-name")
|
||||
hostID := strings.TrimSpace(trimmedPath)
|
||||
if hostID == "" {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
customName := strings.TrimSpace(req.DisplayName)
|
||||
|
||||
host, err := h.monitor.SetDockerHostCustomDisplayName(hostID, customName)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
||||
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"hostId": host.ID,
|
||||
"message": "Docker host custom display name updated",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host custom display name response")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ type DockerHost struct {
|
|||
AgentID string `json:"agentId"`
|
||||
Hostname string `json:"hostname"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom name
|
||||
MachineID string `json:"machineId,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
|
|
@ -1106,6 +1107,10 @@ func (s *State) UpsertDockerHost(host DockerHost) {
|
|||
updated := false
|
||||
for i, existing := range s.DockerHosts {
|
||||
if existing.ID == host.ID {
|
||||
// Preserve custom display name if it was set
|
||||
if existing.CustomDisplayName != "" {
|
||||
host.CustomDisplayName = existing.CustomDisplayName
|
||||
}
|
||||
s.DockerHosts[i] = host
|
||||
updated = true
|
||||
break
|
||||
|
|
@ -1212,6 +1217,23 @@ func (s *State) SetDockerHostCommand(hostID string, command *DockerHostCommandSt
|
|||
return DockerHost{}, false
|
||||
}
|
||||
|
||||
// SetDockerHostCustomDisplayName updates the custom display name for a docker host.
|
||||
func (s *State) SetDockerHostCustomDisplayName(hostID string, customName string) (DockerHost, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, host := range s.DockerHosts {
|
||||
if host.ID == hostID {
|
||||
host.CustomDisplayName = customName
|
||||
s.DockerHosts[i] = host
|
||||
s.LastUpdate = time.Now()
|
||||
return host, true
|
||||
}
|
||||
}
|
||||
|
||||
return DockerHost{}, false
|
||||
}
|
||||
|
||||
// TouchDockerHost updates the last seen timestamp for a docker host.
|
||||
func (s *State) TouchDockerHost(hostID string, ts time.Time) bool {
|
||||
s.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,27 @@ func (m *Monitor) MarkDockerHostPendingUninstall(hostID string) (models.DockerHo
|
|||
return host, nil
|
||||
}
|
||||
|
||||
// SetDockerHostCustomDisplayName updates the custom display name for a docker host.
|
||||
func (m *Monitor) SetDockerHostCustomDisplayName(hostID string, customName string) (models.DockerHost, error) {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
if hostID == "" {
|
||||
return models.DockerHost{}, fmt.Errorf("docker host id is required")
|
||||
}
|
||||
|
||||
host, ok := m.state.SetDockerHostCustomDisplayName(hostID, strings.TrimSpace(customName))
|
||||
if !ok {
|
||||
return models.DockerHost{}, fmt.Errorf("docker host %q not found", hostID)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("dockerHost", host.Hostname).
|
||||
Str("dockerHostID", hostID).
|
||||
Str("customDisplayName", customName).
|
||||
Msg("Docker host custom display name updated")
|
||||
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// AllowDockerHostReenroll removes a host ID from the removal blocklist so it can report again.
|
||||
func (m *Monitor) AllowDockerHostReenroll(hostID string) error {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
|
|
|
|||
Loading…
Reference in a new issue