Pulse/frontend-modern/src/api/monitoring.ts
rcourtman 7936808193 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
2025-11-05 23:18:03 +00:00

315 lines
9 KiB
TypeScript

import type { State, Performance, Stats, DockerHostCommand, HostLookupResponse } from '@/types/api';
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
export class MonitoringAPI {
private static baseUrl = '/api';
static async getState(): Promise<State> {
return apiFetchJSON(`${this.baseUrl}/state`);
}
static async getPerformance(): Promise<Performance> {
return apiFetchJSON(`${this.baseUrl}/performance`);
}
static async getStats(): Promise<Stats> {
return apiFetchJSON(`${this.baseUrl}/stats`);
}
static async exportDiagnostics(): Promise<Blob> {
const response = await apiFetch(`${this.baseUrl}/diagnostics/export`);
return response.blob();
}
static async deleteDockerHost(
hostId: string,
options: { hide?: boolean; force?: boolean } = {}
): Promise<DeleteDockerHostResponse> {
const params = new URLSearchParams();
if (options.hide) params.set('hide', 'true');
if (options.force) params.set('force', 'true');
const query = params.toString();
const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}${query ? `?${query}` : ''}`;
const response = await apiFetch(url, {
method: 'DELETE',
});
if (!response.ok) {
if (response.status === 404) {
return {};
}
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);
}
if (response.status === 204) {
return {};
}
const text = await response.text();
if (!text?.trim()) {
return {};
}
try {
const parsed = JSON.parse(text) as DeleteDockerHostResponse;
return parsed;
} catch (err) {
throw new Error((err as Error).message || 'Failed to parse delete docker host response');
}
}
static async unhideDockerHost(hostId: string): Promise<void> {
const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/unhide`;
const response = await apiFetch(url, {
method: 'PUT',
});
if (!response.ok) {
if (response.status === 404) {
// Host already gone; treat as success
return;
}
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 markDockerHostPendingUninstall(hostId: string): Promise<void> {
const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/pending-uninstall`;
const response = await apiFetch(url, {
method: 'PUT',
});
if (!response.ok) {
if (response.status === 404) {
// Host already gone; treat as success
return;
}
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 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`;
const response = await apiFetch(url, {
method: 'POST',
});
if (!response.ok) {
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 (_err) {
// ignore parse error, use raw text
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
}
static async deleteHostAgent(hostId: string): Promise<void> {
if (!hostId) {
throw new Error('Host ID is required to remove a host agent.');
}
const url = `${this.baseUrl}/agents/host/${encodeURIComponent(hostId)}`;
const response = await apiFetch(url, { method: 'DELETE' });
if (!response.ok) {
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();
} else if (typeof parsed?.message === 'string' && parsed.message.trim()) {
message = parsed.message.trim();
}
} catch (_err) {
// Ignore JSON parse errors, fallback to raw text.
}
}
} catch (_err) {
// Ignore body read errors, keep default message.
}
throw new Error(message);
}
// Consume and ignore the body so the fetch can be reused by the connection pool.
try {
await response.text();
} catch (_err) {
// Swallow body read errors; the deletion already succeeded.
}
}
static async lookupHost(params: { id?: string; hostname?: string }): Promise<HostLookupResponse | null> {
const search = new URLSearchParams();
if (params.id) search.set('id', params.id);
if (params.hostname) search.set('hostname', params.hostname);
if (!search.toString()) {
throw new Error('Provide a host identifier or hostname to look up.');
}
const url = `${this.baseUrl}/agents/host/lookup?${search.toString()}`;
const response = await apiFetch(url);
if (response.status === 404) {
return null;
}
if (!response.ok) {
const text = await response.text();
let message = text?.trim() || `Lookup failed with status ${response.status}`;
try {
const parsed = text ? JSON.parse(text) : null;
if (parsed?.error) {
message = parsed.error;
}
} catch (_err) {
// ignore parse error
}
throw new Error(message);
}
const text = await response.text();
if (!text?.trim()) {
return null;
}
const data = JSON.parse(text) as HostLookupResponse;
const lastSeen = data?.host?.lastSeen as unknown;
if (typeof lastSeen === 'string') {
const parsed = Date.parse(lastSeen);
data.host.lastSeen = Number.isFinite(parsed) ? parsed : Date.now();
} else if (typeof lastSeen === 'number') {
// assume already a timestamp
data.host.lastSeen = lastSeen;
} else {
data.host.lastSeen = Date.now();
}
return data;
}
}
export interface DeleteDockerHostResponse {
success?: boolean;
hostId?: string;
message?: string;
command?: DockerHostCommand;
}