From 0d2bd7a304786af7d88d94f342cf25e2c6e9e5e7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 28 Oct 2025 22:50:31 +0000 Subject: [PATCH] Add custom URL editing to Docker containers and services Implements clickable name field with inline URL editor for Docker resources, matching the Proxmox guest URL feature: - Create DockerMetadataAPI for storing custom URLs - Add metadata loading and caching in DockerHosts component - Add URL editing UI to DockerContainerRow and DockerServiceRow - Global editing state prevents multiple simultaneous edits - Shows external link icon when URL is set - Supports Enter to save, Escape to cancel - Toast notifications for save/delete operations - Stores metadata with format: {hostId}:container:{containerId} Allows users to add quick links to container/service dashboards (e.g., Portainer, Traefik, internal UIs). --- frontend-modern/src/api/dockerMetadata.ts | 41 ++ .../src/components/Docker/DockerHosts.tsx | 67 +- .../components/Docker/DockerUnifiedTable.tsx | 592 +++++++++++++++++- 3 files changed, 673 insertions(+), 27 deletions(-) create mode 100644 frontend-modern/src/api/dockerMetadata.ts diff --git a/frontend-modern/src/api/dockerMetadata.ts b/frontend-modern/src/api/dockerMetadata.ts new file mode 100644 index 0000000..ab3d4ab --- /dev/null +++ b/frontend-modern/src/api/dockerMetadata.ts @@ -0,0 +1,41 @@ +// Docker Metadata API +import { apiFetchJSON } from '@/utils/apiClient'; + +export interface DockerMetadata { + id: string; + customUrl?: string; + description?: string; + tags?: string[]; +} + +export class DockerMetadataAPI { + private static baseUrl = '/api/docker/metadata'; + + // Get metadata for a specific docker resource (container or service) + static async getMetadata(resourceId: string): Promise { + return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`); + } + + // Get all docker metadata + static async getAllMetadata(): Promise> { + return apiFetchJSON(this.baseUrl); + } + + // Update metadata for a docker resource + static async updateMetadata( + resourceId: string, + metadata: Partial, + ): Promise { + return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`, { + method: 'PUT', + body: JSON.stringify(metadata), + }); + } + + // Delete metadata for a docker resource + static async deleteMetadata(resourceId: string): Promise { + await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`, { + method: 'DELETE', + }); + } +} diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 5b84848..d44b814 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -10,6 +10,7 @@ import { DockerUnifiedTable } from './DockerUnifiedTable'; import { useWebSocket } from '@/App'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { formatBytes, formatRelativeTime } from '@/utils/format'; +import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); const DEGRADED_HOST_STATUSES = new Set([ @@ -21,6 +22,10 @@ const DEGRADED_HOST_STATUSES = new Set([ 'unknown', ]); +const DOCKER_METADATA_STORAGE_KEY = 'pulseDockerMetadata'; + +type DockerMetadataRecord = Record; + interface DockerHostsProps { hosts: DockerHost[]; activeAlerts?: Record | any; @@ -30,6 +35,19 @@ export const DockerHosts: Component = (props) => { const navigate = useNavigate(); const { initialDataReceived, reconnecting, connected } = useWebSocket(); + // Load docker metadata from localStorage or API + const [dockerMetadata, setDockerMetadata] = createSignal(() => { + try { + const cached = localStorage.getItem(DOCKER_METADATA_STORAGE_KEY); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn('Failed to parse cached docker metadata:', err); + } + return {}; + }); + const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { @@ -141,9 +159,52 @@ export const DockerHosts: Component = (props) => { } }; - onMount(() => document.addEventListener('keydown', handleKeyDown)); + onMount(() => { + document.addEventListener('keydown', handleKeyDown); + + // Load docker metadata from API + DockerMetadataAPI.getAllMetadata() + .then((metadata) => { + setDockerMetadata(metadata || {}); + try { + localStorage.setItem(DOCKER_METADATA_STORAGE_KEY, JSON.stringify(metadata || {})); + } catch (err) { + console.warn('Failed to cache docker metadata:', err); + } + }) + .catch((err) => { + console.debug('Failed to load docker metadata:', err); + }); + }); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + // Handler to update docker resource custom URL + const handleCustomUrlUpdate = (resourceId: string, url: string) => { + const trimmedUrl = url.trim(); + const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl; + + setDockerMetadata((prev) => { + const updated = { ...prev }; + if (nextUrl === undefined) { + delete updated[resourceId]; + } else { + updated[resourceId] = { + ...(prev[resourceId] || { id: resourceId }), + customUrl: nextUrl, + }; + } + + // Cache to localStorage + try { + localStorage.setItem(DOCKER_METADATA_STORAGE_KEY, JSON.stringify(updated)); + } catch (err) { + console.warn('Failed to cache docker metadata:', err); + } + + return updated; + }); + }; + createEffect(() => { const hostId = selectedHostId(); if (!hostId) { @@ -183,7 +244,7 @@ export const DockerHosts: Component = (props) => { const statsFilter = createMemo(() => { const status = statusFilter(); if (status === 'all') return null; - return { type: 'host-status', value: status }; + return { type: 'host-status' as const, value: status }; }); const handleHostSelect = (hostId: string) => { @@ -286,6 +347,8 @@ export const DockerHosts: Component = (props) => { searchTerm={debouncedSearch()} statsFilter={statsFilter()} selectedHostId={selectedHostId} + dockerMetadata={dockerMetadata()} + onCustomUrlUpdate={handleCustomUrlUpdate} /> diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 2e5931e..163de71 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1,10 +1,13 @@ -import { Component, For, Show, createMemo, createSignal } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, createEffect } from 'solid-js'; import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { formatBytes, formatPercent, formatUptime, formatRelativeTime } from '@/utils/format'; +import type { DockerMetadata } from '@/api/dockerMetadata'; +import { DockerMetadataAPI } from '@/api/dockerMetadata'; +import { showSuccess, showError } from '@/utils/toast'; const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); const DEGRADED_HOST_STATUSES = new Set([ @@ -68,10 +71,17 @@ interface DockerUnifiedTableProps { searchTerm?: string; statsFilter?: StatsFilter; selectedHostId?: () => string | null; + dockerMetadata?: Record; + onCustomUrlUpdate?: (resourceId: string, url: string) => void; } const rowExpandState = new Map(); +// Global editing state for Docker resource URLs +const [currentlyEditingDockerResourceId, setCurrentlyEditingDockerResourceId] = createSignal(null); +const dockerEditingValues = new Map(); +const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0); + const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; const ensureMs = (value?: number | string | null): number | null => { @@ -317,10 +327,26 @@ const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = ); }; -const DockerContainerRow: Component<{ row: Extract; columns: number }> = (props) => { +const DockerContainerRow: Component<{ + row: Extract; + columns: number; + customUrl?: string; + onCustomUrlUpdate?: (resourceId: string, url: string) => void; +}> = (props) => { const { host, container } = props.row; const rowId = buildRowId(host, props.row); + const resourceId = () => `${host.id}:container:${container.id || container.name}`; + const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId()); + + const [customUrl, setCustomUrl] = createSignal(props.customUrl); + const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false); + const editingUrlValue = createMemo(() => { + dockerEditingValuesVersion(); // Subscribe to changes + return dockerEditingValues.get(resourceId()) || ''; + }); + let urlInputRef: HTMLInputElement | undefined; + const hasDrawerContent = createMemo(() => { return ( (container.ports && container.ports.length > 0) || @@ -329,10 +355,34 @@ const DockerContainerRow: Component<{ row: Extract { + if (currentlyEditingDockerResourceId() !== resourceId()) { + const prevUrl = customUrl(); + const newUrl = props.customUrl; + + // Only animate when URL transitions from empty to having a value + if (!prevUrl && newUrl) { + setShouldAnimateIcon(true); + setTimeout(() => setShouldAnimateIcon(false), 200); + } + + setCustomUrl(newUrl); + } + }); + + // Auto-focus the input when editing starts + createEffect(() => { + if (isEditingUrl() && urlInputRef) { + urlInputRef.focus(); + urlInputRef.select(); + } + }); + const toggle = (event: MouseEvent) => { if (!hasDrawerContent()) return; const target = event.target as HTMLElement; - if (target.closest('a, button, [data-prevent-toggle]')) return; + if (target.closest('a, button, input, [data-prevent-toggle]')) return; setExpanded((prev) => { const next = !prev; rowExpandState.set(rowId, next); @@ -340,6 +390,139 @@ const DockerContainerRow: Component<{ row: Extract { + event.stopPropagation(); + + // If another resource is being edited, save it first + const currentEditing = currentlyEditingDockerResourceId(); + if (currentEditing !== null && currentEditing !== resourceId()) { + const currentInput = document.querySelector(`input[data-resource-id="${currentEditing}"]`) as HTMLInputElement; + if (currentInput) { + currentInput.blur(); + } + } + + dockerEditingValues.set(resourceId(), customUrl() || ''); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(resourceId()); + }; + + // Add global click handler to close editor + createEffect(() => { + if (isEditingUrl()) { + const handleGlobalClick = (e: MouseEvent) => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const target = e.target as HTMLElement; + const isClickingResourceName = target.closest('[data-resource-name-editable]'); + + if (!target.closest('[data-url-editor]') && !isClickingResourceName) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + cancelEditingUrl(); + } + }; + + const handleGlobalMouseDown = (e: MouseEvent) => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const target = e.target as HTMLElement; + const isClickingResourceName = target.closest('[data-resource-name-editable]'); + + if (!target.closest('[data-url-editor]') && !isClickingResourceName) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + }; + + document.addEventListener('mousedown', handleGlobalMouseDown, true); + document.addEventListener('click', handleGlobalClick, true); + return () => { + document.removeEventListener('mousedown', handleGlobalMouseDown, true); + document.removeEventListener('click', handleGlobalClick, true); + }; + } + }); + + const saveUrl = async () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const newUrl = (dockerEditingValues.get(resourceId()) || '').trim(); + + // Clear global editing state + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + + // If URL hasn't changed, don't save + if (newUrl === (customUrl() || '')) return; + + try { + await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: newUrl }); + + // Animate if transitioning from no URL to having a URL + const hadUrl = !!customUrl(); + if (!hadUrl && newUrl) { + setShouldAnimateIcon(true); + setTimeout(() => setShouldAnimateIcon(false), 200); + } + + setCustomUrl(newUrl || undefined); + + // Notify parent to update metadata + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(resourceId(), newUrl); + } + + if (newUrl) { + showSuccess('Container URL saved'); + } else { + showSuccess('Container URL cleared'); + } + } catch (err: any) { + console.error('Failed to save container URL:', err); + showError(err.message || 'Failed to save container URL'); + } + }; + + const deleteUrl = async () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + // Clear global editing state + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + + // If there was a URL set, delete it + if (customUrl()) { + try { + await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: '' }); + setCustomUrl(undefined); + + // Notify parent to update metadata + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(resourceId(), ''); + } + + showSuccess('Container URL removed'); + } catch (err: any) { + console.error('Failed to remove container URL:', err); + showError(err.message || 'Failed to remove container URL'); + } + } + }; + + const cancelEditingUrl = () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + // Just close without saving + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + }; + const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0)); const memUsageLabel = () => { @@ -395,11 +578,96 @@ const DockerContainerRow: Component<{ row: Extract
-
- - {container.name || container.id} - -
+ {/* Name - show input when editing, otherwise show name with optional link */} + + + {container.name || container.id} + + + event.stopPropagation()} + > + + + + + +
+ } + > +
+ { + dockerEditingValues.set(resourceId(), e.currentTarget.value); + setDockerEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ @@ -523,16 +791,56 @@ const DockerContainerRow: Component<{ row: Extract; columns: number }> = (props) => { +const DockerServiceRow: Component<{ + row: Extract; + columns: number; + customUrl?: string; + onCustomUrlUpdate?: (resourceId: string, url: string) => void; +}> = (props) => { const { host, service, tasks } = props.row; const rowId = buildRowId(host, props.row); + const resourceId = () => `${host.id}:service:${service.id || service.name}`; + const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId()); + + const [customUrl, setCustomUrl] = createSignal(props.customUrl); + const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false); + const editingUrlValue = createMemo(() => { + dockerEditingValuesVersion(); // Subscribe to changes + return dockerEditingValues.get(resourceId()) || ''; + }); + let urlInputRef: HTMLInputElement | undefined; + const hasTasks = () => tasks.length > 0; + // Update custom URL when prop changes, but only if we're not currently editing + createEffect(() => { + if (currentlyEditingDockerResourceId() !== resourceId()) { + const prevUrl = customUrl(); + const newUrl = props.customUrl; + + // Only animate when URL transitions from empty to having a value + if (!prevUrl && newUrl) { + setShouldAnimateIcon(true); + setTimeout(() => setShouldAnimateIcon(false), 200); + } + + setCustomUrl(newUrl); + } + }); + + // Auto-focus the input when editing starts + createEffect(() => { + if (isEditingUrl() && urlInputRef) { + urlInputRef.focus(); + urlInputRef.select(); + } + }); + const toggle = (event: MouseEvent) => { if (!hasTasks()) return; const target = event.target as HTMLElement; - if (target.closest('a, button, [data-prevent-toggle]')) return; + if (target.closest('a, button, input, [data-prevent-toggle]')) return; setExpanded((prev) => { const next = !prev; rowExpandState.set(rowId, next); @@ -540,6 +848,139 @@ const DockerServiceRow: Component<{ row: Extract }); }; + const startEditingUrl = (event: MouseEvent) => { + event.stopPropagation(); + + // If another resource is being edited, save it first + const currentEditing = currentlyEditingDockerResourceId(); + if (currentEditing !== null && currentEditing !== resourceId()) { + const currentInput = document.querySelector(`input[data-resource-id="${currentEditing}"]`) as HTMLInputElement; + if (currentInput) { + currentInput.blur(); + } + } + + dockerEditingValues.set(resourceId(), customUrl() || ''); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(resourceId()); + }; + + // Add global click handler to close editor + createEffect(() => { + if (isEditingUrl()) { + const handleGlobalClick = (e: MouseEvent) => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const target = e.target as HTMLElement; + const isClickingResourceName = target.closest('[data-resource-name-editable]'); + + if (!target.closest('[data-url-editor]') && !isClickingResourceName) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + cancelEditingUrl(); + } + }; + + const handleGlobalMouseDown = (e: MouseEvent) => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const target = e.target as HTMLElement; + const isClickingResourceName = target.closest('[data-resource-name-editable]'); + + if (!target.closest('[data-url-editor]') && !isClickingResourceName) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + }; + + document.addEventListener('mousedown', handleGlobalMouseDown, true); + document.addEventListener('click', handleGlobalClick, true); + return () => { + document.removeEventListener('mousedown', handleGlobalMouseDown, true); + document.removeEventListener('click', handleGlobalClick, true); + }; + } + }); + + const saveUrl = async () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + const newUrl = (dockerEditingValues.get(resourceId()) || '').trim(); + + // Clear global editing state + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + + // If URL hasn't changed, don't save + if (newUrl === (customUrl() || '')) return; + + try { + await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: newUrl }); + + // Animate if transitioning from no URL to having a URL + const hadUrl = !!customUrl(); + if (!hadUrl && newUrl) { + setShouldAnimateIcon(true); + setTimeout(() => setShouldAnimateIcon(false), 200); + } + + setCustomUrl(newUrl || undefined); + + // Notify parent to update metadata + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(resourceId(), newUrl); + } + + if (newUrl) { + showSuccess('Service URL saved'); + } else { + showSuccess('Service URL cleared'); + } + } catch (err: any) { + console.error('Failed to save service URL:', err); + showError(err.message || 'Failed to save service URL'); + } + }; + + const deleteUrl = async () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + // Clear global editing state + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + + // If there was a URL set, delete it + if (customUrl()) { + try { + await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: '' }); + setCustomUrl(undefined); + + // Notify parent to update metadata + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(resourceId(), ''); + } + + showSuccess('Service URL removed'); + } catch (err: any) { + console.error('Failed to remove service URL:', err); + showError(err.message || 'Failed to remove service URL'); + } + } + }; + + const cancelEditingUrl = () => { + if (currentlyEditingDockerResourceId() !== resourceId()) return; + + // Just close without saving + dockerEditingValues.delete(resourceId()); + setDockerEditingValuesVersion(v => v + 1); + setCurrentlyEditingDockerResourceId(null); + }; + const badge = serviceHealthBadge(service); const updatedAt = ensureMs(service.updatedAt ?? service.createdAt); const isHealthy = () => { @@ -569,15 +1010,100 @@ const DockerServiceRow: Component<{ row: Extract >
-
- - {service.name || service.id || 'Service'} - -
- - - Stack: {service.stack} - + {/* Name - show input when editing, otherwise show name with optional link */} + + + {service.name || service.id || 'Service'} + + + event.stopPropagation()} + > + + + + + + + + Stack: {service.stack} + + +
+ } + > +
+ { + dockerEditingValues.set(resourceId(), e.currentTarget.value); + setDockerEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
@@ -951,13 +1477,29 @@ const DockerUnifiedTable: Component = (props) => { <> - {(row) => - row.kind === 'container' ? ( - + {(row) => { + // Build resource ID for metadata lookup + const resourceId = row.kind === 'container' + ? `${row.host.id}:container:${row.container.id || row.container.name}` + : `${row.host.id}:service:${row.service.id || row.service.name}`; + const metadata = props.dockerMetadata?.[resourceId]; + + return row.kind === 'container' ? ( + ) : ( - - ) - } + + ); + }} )}