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).
This commit is contained in:
parent
28277757ab
commit
0d2bd7a304
3 changed files with 673 additions and 27 deletions
41
frontend-modern/src/api/dockerMetadata.ts
Normal file
41
frontend-modern/src/api/dockerMetadata.ts
Normal file
|
|
@ -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<DockerMetadata> {
|
||||||
|
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all docker metadata
|
||||||
|
static async getAllMetadata(): Promise<Record<string, DockerMetadata>> {
|
||||||
|
return apiFetchJSON(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata for a docker resource
|
||||||
|
static async updateMetadata(
|
||||||
|
resourceId: string,
|
||||||
|
metadata: Partial<DockerMetadata>,
|
||||||
|
): Promise<DockerMetadata> {
|
||||||
|
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(metadata),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete metadata for a docker resource
|
||||||
|
static async deleteMetadata(resourceId: string): Promise<void> {
|
||||||
|
await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(resourceId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { DockerUnifiedTable } from './DockerUnifiedTable';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { formatBytes, formatRelativeTime } from '@/utils/format';
|
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 OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
|
||||||
const DEGRADED_HOST_STATUSES = new Set([
|
const DEGRADED_HOST_STATUSES = new Set([
|
||||||
|
|
@ -21,6 +22,10 @@ const DEGRADED_HOST_STATUSES = new Set([
|
||||||
'unknown',
|
'unknown',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DOCKER_METADATA_STORAGE_KEY = 'pulseDockerMetadata';
|
||||||
|
|
||||||
|
type DockerMetadataRecord = Record<string, DockerMetadata>;
|
||||||
|
|
||||||
interface DockerHostsProps {
|
interface DockerHostsProps {
|
||||||
hosts: DockerHost[];
|
hosts: DockerHost[];
|
||||||
activeAlerts?: Record<string, unknown> | any;
|
activeAlerts?: Record<string, unknown> | any;
|
||||||
|
|
@ -30,6 +35,19 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
||||||
|
|
||||||
|
// Load docker metadata from localStorage or API
|
||||||
|
const [dockerMetadata, setDockerMetadata] = createSignal<DockerMetadataRecord>(() => {
|
||||||
|
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 sortedHosts = createMemo(() => {
|
||||||
const hosts = props.hosts || [];
|
const hosts = props.hosts || [];
|
||||||
return [...hosts].sort((a, b) => {
|
return [...hosts].sort((a, b) => {
|
||||||
|
|
@ -141,9 +159,52 @@ export const DockerHosts: Component<DockerHostsProps> = (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));
|
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(() => {
|
createEffect(() => {
|
||||||
const hostId = selectedHostId();
|
const hostId = selectedHostId();
|
||||||
if (!hostId) {
|
if (!hostId) {
|
||||||
|
|
@ -183,7 +244,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
const statsFilter = createMemo(() => {
|
const statsFilter = createMemo(() => {
|
||||||
const status = statusFilter();
|
const status = statusFilter();
|
||||||
if (status === 'all') return null;
|
if (status === 'all') return null;
|
||||||
return { type: 'host-status', value: status };
|
return { type: 'host-status' as const, value: status };
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleHostSelect = (hostId: string) => {
|
const handleHostSelect = (hostId: string) => {
|
||||||
|
|
@ -286,6 +347,8 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
searchTerm={debouncedSearch()}
|
searchTerm={debouncedSearch()}
|
||||||
statsFilter={statsFilter()}
|
statsFilter={statsFilter()}
|
||||||
selectedHostId={selectedHostId}
|
selectedHostId={selectedHostId}
|
||||||
|
dockerMetadata={dockerMetadata()}
|
||||||
|
onCustomUrlUpdate={handleCustomUrlUpdate}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -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 type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||||
import { formatBytes, formatPercent, formatUptime, formatRelativeTime } from '@/utils/format';
|
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 OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
|
||||||
const DEGRADED_HOST_STATUSES = new Set([
|
const DEGRADED_HOST_STATUSES = new Set([
|
||||||
|
|
@ -68,10 +71,17 @@ interface DockerUnifiedTableProps {
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
statsFilter?: StatsFilter;
|
statsFilter?: StatsFilter;
|
||||||
selectedHostId?: () => string | null;
|
selectedHostId?: () => string | null;
|
||||||
|
dockerMetadata?: Record<string, DockerMetadata>;
|
||||||
|
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowExpandState = new Map<string, boolean>();
|
const rowExpandState = new Map<string, boolean>();
|
||||||
|
|
||||||
|
// Global editing state for Docker resource URLs
|
||||||
|
const [currentlyEditingDockerResourceId, setCurrentlyEditingDockerResourceId] = createSignal<string | null>(null);
|
||||||
|
const dockerEditingValues = new Map<string, string>();
|
||||||
|
const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0);
|
||||||
|
|
||||||
const toLower = (value?: string | null) => value?.toLowerCase() ?? '';
|
const toLower = (value?: string | null) => value?.toLowerCase() ?? '';
|
||||||
|
|
||||||
const ensureMs = (value?: number | string | null): number | null => {
|
const ensureMs = (value?: number | string | null): number | null => {
|
||||||
|
|
@ -317,10 +327,26 @@ const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> =
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DockerContainerRow: Component<{ row: Extract<DockerRow, { kind: 'container' }>; columns: number }> = (props) => {
|
const DockerContainerRow: Component<{
|
||||||
|
row: Extract<DockerRow, { kind: 'container' }>;
|
||||||
|
columns: number;
|
||||||
|
customUrl?: string;
|
||||||
|
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
||||||
|
}> = (props) => {
|
||||||
const { host, container } = props.row;
|
const { host, container } = props.row;
|
||||||
const rowId = buildRowId(host, 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<string | undefined>(props.customUrl);
|
||||||
|
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
||||||
const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? 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(() => {
|
const hasDrawerContent = createMemo(() => {
|
||||||
return (
|
return (
|
||||||
(container.ports && container.ports.length > 0) ||
|
(container.ports && container.ports.length > 0) ||
|
||||||
|
|
@ -329,10 +355,34 @@ const DockerContainerRow: Component<{ row: Extract<DockerRow, { kind: 'container
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
const toggle = (event: MouseEvent) => {
|
||||||
if (!hasDrawerContent()) return;
|
if (!hasDrawerContent()) return;
|
||||||
const target = event.target as HTMLElement;
|
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) => {
|
setExpanded((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
rowExpandState.set(rowId, next);
|
rowExpandState.set(rowId, next);
|
||||||
|
|
@ -340,6 +390,139 @@ const DockerContainerRow: Component<{ row: Extract<DockerRow, { kind: 'container
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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('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 cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0));
|
||||||
const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0));
|
const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0));
|
||||||
const memUsageLabel = () => {
|
const memUsageLabel = () => {
|
||||||
|
|
@ -395,11 +578,96 @@ const DockerContainerRow: Component<{ row: Extract<DockerRow, { kind: 'container
|
||||||
>
|
>
|
||||||
<td class="pl-4 pr-2 py-0.5">
|
<td class="pl-4 pr-2 py-0.5">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
{/* Name - show input when editing, otherwise show name with optional link */}
|
||||||
<span class="truncate font-semibold" title={containerTitle()}>
|
<Show
|
||||||
{container.name || container.id}
|
when={isEditingUrl()}
|
||||||
</span>
|
fallback={
|
||||||
</div>
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
|
||||||
|
style="cursor: text;"
|
||||||
|
title={`${containerTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
|
onClick={startEditingUrl}
|
||||||
|
data-resource-name-editable
|
||||||
|
>
|
||||||
|
{container.name || container.id}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl()}>
|
||||||
|
<a
|
||||||
|
href={customUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
||||||
|
title="Open in new tab"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingUrlValue()}
|
||||||
|
data-resource-id={resourceId()}
|
||||||
|
onInput={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
saveUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
title="Save (or press Enter)"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete URL"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-0.5">
|
<td class="px-2 py-0.5">
|
||||||
|
|
@ -523,16 +791,56 @@ const DockerContainerRow: Component<{ row: Extract<DockerRow, { kind: 'container
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DockerServiceRow: Component<{ row: Extract<DockerRow, { kind: 'service' }>; columns: number }> = (props) => {
|
const DockerServiceRow: Component<{
|
||||||
|
row: Extract<DockerRow, { kind: 'service' }>;
|
||||||
|
columns: number;
|
||||||
|
customUrl?: string;
|
||||||
|
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
||||||
|
}> = (props) => {
|
||||||
const { host, service, tasks } = props.row;
|
const { host, service, tasks } = props.row;
|
||||||
const rowId = buildRowId(host, 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<string | undefined>(props.customUrl);
|
||||||
|
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
||||||
const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? 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;
|
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) => {
|
const toggle = (event: MouseEvent) => {
|
||||||
if (!hasTasks()) return;
|
if (!hasTasks()) return;
|
||||||
const target = event.target as HTMLElement;
|
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) => {
|
setExpanded((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
rowExpandState.set(rowId, next);
|
rowExpandState.set(rowId, next);
|
||||||
|
|
@ -540,6 +848,139 @@ const DockerServiceRow: Component<{ row: Extract<DockerRow, { kind: 'service' }>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 badge = serviceHealthBadge(service);
|
||||||
const updatedAt = ensureMs(service.updatedAt ?? service.createdAt);
|
const updatedAt = ensureMs(service.updatedAt ?? service.createdAt);
|
||||||
const isHealthy = () => {
|
const isHealthy = () => {
|
||||||
|
|
@ -569,15 +1010,100 @@ const DockerServiceRow: Component<{ row: Extract<DockerRow, { kind: 'service' }>
|
||||||
>
|
>
|
||||||
<td class="pl-4 pr-2 py-0.5">
|
<td class="pl-4 pr-2 py-0.5">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
{/* Name - show input when editing, otherwise show name with optional link */}
|
||||||
<span class="truncate font-semibold" title={serviceTitle()}>
|
<Show
|
||||||
{service.name || service.id || 'Service'}
|
when={isEditingUrl()}
|
||||||
</span>
|
fallback={
|
||||||
</div>
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<Show when={service.stack}>
|
<span
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
|
class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
|
||||||
Stack: {service.stack}
|
style="cursor: text;"
|
||||||
</span>
|
title={`${serviceTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
|
onClick={startEditingUrl}
|
||||||
|
data-resource-name-editable
|
||||||
|
>
|
||||||
|
{service.name || service.id || 'Service'}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl()}>
|
||||||
|
<a
|
||||||
|
href={customUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
||||||
|
title="Open in new tab"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
<Show when={service.stack && !isEditingUrl()}>
|
||||||
|
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
|
||||||
|
Stack: {service.stack}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingUrlValue()}
|
||||||
|
data-resource-id={resourceId()}
|
||||||
|
onInput={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
saveUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
title="Save (or press Enter)"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete URL"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -951,13 +1477,29 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
<>
|
<>
|
||||||
<DockerHostGroupHeader host={group.host} colspan={8} />
|
<DockerHostGroupHeader host={group.host} colspan={8} />
|
||||||
<For each={group.rows}>
|
<For each={group.rows}>
|
||||||
{(row) =>
|
{(row) => {
|
||||||
row.kind === 'container' ? (
|
// Build resource ID for metadata lookup
|
||||||
<DockerContainerRow row={row} columns={8} />
|
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' ? (
|
||||||
|
<DockerContainerRow
|
||||||
|
row={row}
|
||||||
|
columns={8}
|
||||||
|
customUrl={metadata?.customUrl}
|
||||||
|
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DockerServiceRow row={row} columns={8} />
|
<DockerServiceRow
|
||||||
)
|
row={row}
|
||||||
}
|
columns={8}
|
||||||
|
customUrl={metadata?.customUrl}
|
||||||
|
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue