refactor(ui): standardize URL editing with shared UrlEditPopover component

- Create reusable UrlEditPopover component with fixed positioning
- Add createUrlEditState hook for managing editing state
- Update DockerHostSummaryTable to use new popover
- Update DockerUnifiedTable (containers & services) to use new popover
- Update GuestRow (Proxmox VMs/containers) to use new popover
- Update HostsOverview (Proxmox hosts) to use new popover
- Add Docker host metadata API for custom URLs
- Consistent styling with save, delete, cancel buttons and keyboard shortcuts
This commit is contained in:
rcourtman 2025-12-18 22:22:55 +00:00
parent 337987cb56
commit bd1f4682be
9 changed files with 1635 additions and 1118 deletions

View file

@ -0,0 +1,40 @@
// Docker Host Metadata API - for managing custom URLs on Docker hosts
import { apiFetchJSON } from '@/utils/apiClient';
export interface DockerHostMetadata {
customDisplayName?: string;
customUrl?: string;
notes?: string[];
}
export class DockerHostMetadataAPI {
private static baseUrl = '/api/docker/hosts/metadata';
// Get metadata for a specific Docker host
static async getMetadata(hostId: string): Promise<DockerHostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`);
}
// Get all Docker host metadata
static async getAllMetadata(): Promise<Record<string, DockerHostMetadata>> {
return apiFetchJSON(this.baseUrl);
}
// Update metadata for a Docker host
static async updateMetadata(
hostId: string,
metadata: Partial<DockerHostMetadata>,
): Promise<DockerHostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
// Delete metadata for a Docker host
static async deleteMetadata(hostId: string): Promise<void> {
await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'DELETE',
});
}
}

View file

@ -10,6 +10,7 @@ import { StackedMemoryBar } from './StackedMemoryBar';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
import { GuestMetadataAPI } from '@/api/guestMetadata'; import { GuestMetadataAPI } from '@/api/guestMetadata';
import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover';
import { showSuccess, showError } from '@/utils/toast'; import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
@ -516,28 +517,17 @@ export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl); const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const [isEditingUrl, setIsEditingUrl] = createSignal(false);
const [editingUrlValue, setEditingUrlValue] = createSignal('');
const [isSavingUrl, setIsSavingUrl] = createSignal(false);
let urlInputRef: HTMLInputElement | undefined;
// Focus input when editing starts // URL editing using shared hook
createEffect(() => { const urlEdit = createUrlEditState();
if (isEditingUrl() && urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
const startEditingUrl = (event: MouseEvent) => { const handleStartEditingUrl = (event: MouseEvent) => {
event.stopPropagation(); urlEdit.startEditing(guestId(), customUrl() || '', event);
setEditingUrlValue(customUrl() || '');
setIsEditingUrl(true);
}; };
const saveUrl = async () => { const handleSaveUrl = async () => {
const newUrl = editingUrlValue().trim(); const newUrl = urlEdit.editingValue().trim();
setIsSavingUrl(true); urlEdit.setIsSaving(true);
try { try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl });
@ -549,7 +539,6 @@ export function GuestRow(props: GuestRowProps) {
} }
setCustomUrl(newUrl || undefined); setCustomUrl(newUrl || undefined);
setIsEditingUrl(false);
if (props.onCustomUrlUpdate) { if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), newUrl); props.onCustomUrlUpdate(guestId(), newUrl);
@ -560,18 +549,36 @@ export function GuestRow(props: GuestRowProps) {
} else { } else {
showSuccess('Guest URL cleared'); showSuccess('Guest URL cleared');
} }
urlEdit.finishEditing();
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save guest URL'; const message = err instanceof Error ? err.message : 'Failed to save guest URL';
logger.error('Failed to save guest URL:', err); logger.error('Failed to save guest URL:', err);
showError(message); showError(message);
} finally { urlEdit.setIsSaving(false);
setIsSavingUrl(false);
} }
}; };
const cancelEditingUrl = () => { const handleDeleteUrl = async () => {
setIsEditingUrl(false); urlEdit.setIsSaving(true);
setEditingUrlValue('');
try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' });
setCustomUrl(undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), '');
}
showSuccess('Guest URL removed');
urlEdit.finishEditing();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to remove guest URL';
logger.error('Failed to remove guest URL:', err);
showError(message);
urlEdit.setIsSaving(false);
}
}; };
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []); const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
@ -776,6 +783,7 @@ export function GuestRow(props: GuestRowProps) {
}; };
return ( return (
<>
<tr <tr
class={rowClass()} class={rowClass()}
style={rowStyle()} style={rowStyle()}
@ -792,9 +800,6 @@ export function GuestRow(props: GuestRowProps) {
ariaLabel={guestStatus().label} ariaLabel={guestStatus().label}
size="xs" size="xs"
/> />
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 min-w-0 group/name"> <div class="flex items-center gap-1.5 min-w-0 group/name">
<span <span
class="text-xs font-medium text-gray-900 dark:text-gray-100 select-none whitespace-nowrap" class="text-xs font-medium text-gray-900 dark:text-gray-100 select-none whitespace-nowrap"
@ -829,7 +834,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Edit URL button - shows on hover */} {/* Edit URL button - shows on hover */}
<button <button
type="button" type="button"
onClick={startEditingUrl} onClick={handleStartEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all" class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={customUrl() ? 'Edit URL' : 'Add URL'} title={customUrl() ? 'Edit URL' : 'Add URL'}
> >
@ -850,55 +855,6 @@ export function GuestRow(props: GuestRowProps) {
</span> </span>
</Show> </Show>
</div> </div>
}
>
{/* URL editing mode */}
<div class="flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
onInput={(e) => setEditingUrlValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8080"
class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isSavingUrl()}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
title="Save (Enter)"
>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
cancelEditingUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Cancel (Esc)"
>
</button>
</div>
</Show>
</div> </div>
@ -1194,5 +1150,21 @@ export function GuestRow(props: GuestRowProps) {
</td> </td>
</Show> </Show>
</tr> </tr>
{/* URL editing popover - using shared component */}
<UrlEditPopover
isOpen={urlEdit.isEditing() && urlEdit.editingId() === guestId()}
value={urlEdit.editingValue()}
position={urlEdit.position()}
isSaving={urlEdit.isSaving()}
hasExistingUrl={!!customUrl()}
placeholder="https://192.168.1.100:8080"
helpText="Add a URL to quickly access this guest's web interface"
onValueChange={urlEdit.setEditingValue}
onSave={handleSaveUrl}
onCancel={urlEdit.cancelEditing}
onDelete={handleDeleteUrl}
/>
</>
); );
} }

View file

@ -13,6 +13,10 @@ import { useBreakpoint } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive'; import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion'; import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion';
import { DockerHostMetadataAPI, type DockerHostMetadata } from '@/api/dockerHostMetadata';
import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
export interface DockerHostSummary { export interface DockerHostSummary {
host: DockerHost; host: DockerHost;
@ -35,6 +39,8 @@ interface DockerHostSummaryTableProps {
summaries: () => DockerHostSummary[]; summaries: () => DockerHostSummary[];
selectedHostId: () => string | null; selectedHostId: () => string | null;
onSelect: (hostId: string) => void; onSelect: (hostId: string) => void;
dockerHostMetadata?: Record<string, DockerHostMetadata>;
onHostCustomUrlUpdate?: (hostId: string, url: string) => void;
} }
type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent'; type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent';
@ -55,6 +61,9 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc'); const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
const { isMobile } = useBreakpoint(); const { isMobile } = useBreakpoint();
// URL editing state using shared hook
const urlEdit = createUrlEditState();
const handleSort = (key: SortKey) => { const handleSort = (key: SortKey) => {
if (sortKey() === key) { if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
@ -125,6 +134,68 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return list; return list;
}); });
// URL editing functions
const getHostCustomUrl = (hostId: string) => {
return props.dockerHostMetadata?.[hostId]?.customUrl;
};
const handleStartEditingUrl = (hostId: string, event: MouseEvent) => {
const currentUrl = getHostCustomUrl(hostId) || '';
urlEdit.startEditing(hostId, currentUrl, event);
};
const handleSaveUrl = async () => {
const hostId = urlEdit.editingId();
if (!hostId) return;
const newUrl = urlEdit.editingValue().trim();
urlEdit.setIsSaving(true);
try {
await DockerHostMetadataAPI.updateMetadata(hostId, { customUrl: newUrl });
if (props.onHostCustomUrlUpdate) {
props.onHostCustomUrlUpdate(hostId, newUrl);
}
if (newUrl) {
showSuccess('Host URL saved');
} else {
showSuccess('Host URL cleared');
}
urlEdit.finishEditing();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save host URL';
logger.error('Failed to save host URL:', err);
showError(message);
urlEdit.setIsSaving(false);
}
};
const handleDeleteUrl = async () => {
const hostId = urlEdit.editingId();
if (!hostId) return;
urlEdit.setIsSaving(true);
try {
await DockerHostMetadataAPI.updateMetadata(hostId, { customUrl: '' });
if (props.onHostCustomUrlUpdate) {
props.onHostCustomUrlUpdate(hostId, '');
}
showSuccess('Host URL removed');
urlEdit.finishEditing();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to remove host URL';
logger.error('Failed to remove host URL:', err);
showError(message);
urlEdit.setIsSaving(false);
}
};
const renderSortIndicator = (key: SortKey) => { const renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null; if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼'; return sortDirection() === 'asc' ? '▲' : '▼';
@ -133,7 +204,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
// Agent version checking is now done via the shared utility that compares against server version // Agent version checking is now done via the shared utility that compares against server version
return ( return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden"> <>
<Card padding="none" tone="glass" class={`mb-4 ${urlEdit.isEditing() ? 'overflow-visible' : 'overflow-hidden'}`}>
<ScrollableTable persistKey="docker-host-summary"> <ScrollableTable persistKey="docker-host-summary">
<table class="w-full border-collapse whitespace-nowrap"> <table class="w-full border-collapse whitespace-nowrap">
<thead> <thead>
@ -218,10 +290,10 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
}; };
const rowClass = () => { const rowClass = () => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; const baseHover = 'group cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
if (selected) { if (selected) {
return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10'; return 'group cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';
} }
let className = baseHover; let className = baseHover;
@ -245,7 +317,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
style={rowStyle()} style={rowStyle()}
onClick={() => props.onSelect(summary.host.id)} onClick={() => props.onSelect(summary.host.id)}
> >
<td class="pr-2 py-1 pl-3 align-middle"> <td class="pr-2 py-1 pl-3 align-middle relative">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<StatusDot <StatusDot
variant={hostStatus().variant} variant={hostStatus().variant}
@ -256,6 +328,36 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 truncate" title={getDisplayName(summary.host)}> <span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 truncate" title={getDisplayName(summary.host)}>
{getDisplayName(summary.host)} {getDisplayName(summary.host)}
</span> </span>
{/* URL icon and link */}
<Show when={getHostCustomUrl(summary.host.id)}>
<a
href={getHostCustomUrl(summary.host.id)}
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
title={`Open ${getHostCustomUrl(summary.host.id)}`}
onClick={(e) => e.stopPropagation()}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</a>
</Show>
<button
type="button"
class="flex-shrink-0 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
classList={{ 'opacity-100': urlEdit.editingId() === summary.host.id }}
title={getHostCustomUrl(summary.host.id) ? 'Edit URL' : 'Add URL'}
onClick={(e) => handleStartEditingUrl(summary.host.id, e)}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<Show when={getHostCustomUrl(summary.host.id)} fallback={
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
}>
<path stroke-linecap="round" stroke-linejoin="round" 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" />
</Show>
</svg>
</button>
<Show when={getDisplayName(summary.host) !== 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 whitespace-nowrap"> <span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
({summary.host.hostname}) ({summary.host.hostname})
@ -389,5 +491,21 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</table> </table>
</ScrollableTable> </ScrollableTable>
</Card> </Card>
{/* URL editing popover - using shared component */}
<UrlEditPopover
isOpen={urlEdit.isEditing()}
value={urlEdit.editingValue()}
position={urlEdit.position()}
isSaving={urlEdit.isSaving()}
hasExistingUrl={!!getHostCustomUrl(urlEdit.editingId() || '')}
placeholder="https://portainer.local:9000"
helpText="Add a URL to quickly access this host's management interface (e.g., Portainer)"
onValueChange={urlEdit.setEditingValue}
onSave={handleSaveUrl}
onCancel={urlEdit.cancelEditing}
onDelete={handleDeleteUrl}
/>
</>
); );
}; };

View file

@ -12,11 +12,13 @@ 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'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata';
import { DockerHostMetadataAPI, type DockerHostMetadata } from '@/api/dockerHostMetadata';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage'; import { STORAGE_KEYS } from '@/utils/localStorage';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status'; import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
type DockerMetadataRecord = Record<string, DockerMetadata>; type DockerMetadataRecord = Record<string, DockerMetadata>;
type DockerHostMetadataRecord = Record<string, DockerHostMetadata>;
interface DockerHostsProps { interface DockerHostsProps {
hosts: DockerHost[]; hosts: DockerHost[];
@ -40,10 +42,27 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
return {}; return {};
}; };
// Load docker host metadata from localStorage
const loadInitialDockerHostMetadata = (): DockerHostMetadataRecord => {
try {
const cached = localStorage.getItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts');
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
logger.warn('Failed to parse cached docker host metadata', err);
}
return {};
};
const [dockerMetadata, setDockerMetadata] = createSignal<DockerMetadataRecord>( const [dockerMetadata, setDockerMetadata] = createSignal<DockerMetadataRecord>(
loadInitialDockerMetadata(), loadInitialDockerMetadata(),
); );
const [dockerHostMetadata, setDockerHostMetadata] = createSignal<DockerHostMetadataRecord>(
loadInitialDockerHostMetadata(),
);
const sortedHosts = createMemo(() => { const sortedHosts = createMemo(() => {
const hosts = props.hosts || []; const hosts = props.hosts || [];
return [...hosts].sort((a, b) => { return [...hosts].sort((a, b) => {
@ -207,9 +226,58 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
.catch((err) => { .catch((err) => {
logger.debug('Failed to load docker metadata', err); logger.debug('Failed to load docker metadata', err);
}); });
// Load docker host metadata from API
DockerHostMetadataAPI.getAllMetadata()
.then((metadata) => {
setDockerHostMetadata(metadata || {});
try {
localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts', JSON.stringify(metadata || {}));
} catch (err) {
logger.warn('Failed to cache docker host metadata', err);
}
})
.catch((err) => {
logger.debug('Failed to load docker host metadata', err);
});
}); });
onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
// Handler to update docker host custom URL
const handleHostCustomUrlUpdate = (hostId: string, url: string) => {
const trimmedUrl = url.trim();
const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl;
setDockerHostMetadata((prev) => {
const updated = { ...prev };
if (nextUrl === undefined) {
// Remove URL but keep other metadata fields
if (updated[hostId]) {
const { customUrl: _removed, ...rest } = updated[hostId];
if (Object.keys(rest).length === 0 || (Object.keys(rest).length === 1 && !rest.customDisplayName && !rest.notes?.length)) {
delete updated[hostId];
} else {
updated[hostId] = rest;
}
}
} else {
updated[hostId] = {
...(prev[hostId] || {}),
customUrl: nextUrl,
};
}
// Cache to localStorage
try {
localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts', JSON.stringify(updated));
} catch (err) {
logger.warn('Failed to cache docker host metadata', err);
}
return updated;
});
};
// Handler to update docker resource custom URL // Handler to update docker resource custom URL
const handleCustomUrlUpdate = (resourceId: string, url: string) => { const handleCustomUrlUpdate = (resourceId: string, url: string) => {
const trimmedUrl = url.trim(); const trimmedUrl = url.trim();
@ -407,6 +475,8 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
summaries={filteredHostSummaries} summaries={filteredHostSummaries}
selectedHostId={selectedHostId} selectedHostId={selectedHostId}
onSelect={handleHostSelect} onSelect={handleHostSelect}
dockerHostMetadata={dockerHostMetadata()}
onHostCustomUrlUpdate={handleHostCustomUrlUpdate}
/> />
</Show> </Show>

View file

@ -26,6 +26,7 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { useBreakpoint } from '@/hooks/useBreakpoint'; import { useBreakpoint } from '@/hooks/useBreakpoint';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { UrlEditPopover } from '@/components/shared/UrlEditPopover';
import type { ColumnConfig } from '@/types/responsive'; import type { ColumnConfig } from '@/types/responsive';
const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
@ -145,6 +146,7 @@ const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal<string
const [currentlyEditingDockerResourceId, setCurrentlyEditingDockerResourceId] = createSignal<string | null>(null); const [currentlyEditingDockerResourceId, setCurrentlyEditingDockerResourceId] = createSignal<string | null>(null);
const dockerEditingValues = new Map<string, string>(); const dockerEditingValues = new Map<string, string>();
const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0); const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0);
const [dockerPopoverPosition, setDockerPopoverPosition] = createSignal<{ top: number; left: number } | null>(null);
const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; const toLower = (value?: string | null) => value?.toLowerCase() ?? '';
@ -994,6 +996,12 @@ const DockerContainerRow: Component<{
const startEditingUrl = (event: MouseEvent) => { const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault();
// Calculate popover position from the button
const button = event.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
setDockerPopoverPosition({ top: rect.bottom + 4, left: Math.max(8, rect.left - 100) });
// If another resource is being edited, save it first // If another resource is being edited, save it first
const currentEditing = currentlyEditingDockerResourceId(); const currentEditing = currentlyEditingDockerResourceId();
@ -1057,6 +1065,7 @@ const DockerContainerRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
// If URL hasn't changed, don't save // If URL hasn't changed, don't save
if (newUrl === (customUrl() || '')) return; if (newUrl === (customUrl() || '')) return;
@ -1100,6 +1109,7 @@ const DockerContainerRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
// If there was a URL set, delete it // If there was a URL set, delete it
if (customUrl()) { if (customUrl()) {
@ -1127,6 +1137,7 @@ const DockerContainerRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
}; };
const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0));
@ -1189,9 +1200,6 @@ const DockerContainerRow: Component<{
size="xs" size="xs"
/> />
<div class="flex-1 min-w-0 truncate"> <div class="flex-1 min-w-0 truncate">
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name"> <div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span <span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate" class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate"
@ -1254,24 +1262,6 @@ const DockerContainerRow: Component<{
</span> </span>
</Show> </Show>
</div> </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>
</div> </div>
</div> </div>
@ -1414,6 +1404,21 @@ const DockerContainerRow: Component<{
</For> </For>
</tr> </tr>
{/* URL editing popover - using shared component */}
<UrlEditPopover
isOpen={isEditingUrl()}
value={editingUrlValue()}
position={dockerPopoverPosition()}
isSaving={false}
hasExistingUrl={!!customUrl()}
placeholder="https://example.com:8080"
helpText="Add a URL to quickly access this container's web interface"
onValueChange={(value) => { dockerEditingValues.set(resourceId(), value); setDockerEditingValuesVersion(v => v + 1); }}
onSave={saveUrl}
onCancel={cancelEditingUrl}
onDelete={deleteUrl}
/>
<Show when={expanded() && hasDrawerContent()}> <Show when={expanded() && hasDrawerContent()}>
<tr> <tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0"> <td colspan={DOCKER_COLUMNS.length} class="p-0">
@ -1921,6 +1926,12 @@ const DockerServiceRow: Component<{
const startEditingUrl = (event: MouseEvent) => { const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault();
// Calculate popover position from the button
const button = event.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
setDockerPopoverPosition({ top: rect.bottom + 4, left: Math.max(8, rect.left - 100) });
// If another resource is being edited, save it first // If another resource is being edited, save it first
const currentEditing = currentlyEditingDockerResourceId(); const currentEditing = currentlyEditingDockerResourceId();
@ -1984,6 +1995,7 @@ const DockerServiceRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
// If URL hasn't changed, don't save // If URL hasn't changed, don't save
if (newUrl === (customUrl() || '')) return; if (newUrl === (customUrl() || '')) return;
@ -2027,6 +2039,7 @@ const DockerServiceRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
// If there was a URL set, delete it // If there was a URL set, delete it
if (customUrl()) { if (customUrl()) {
@ -2054,6 +2067,7 @@ const DockerServiceRow: Component<{
dockerEditingValues.delete(resourceId()); dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1); setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null); setCurrentlyEditingDockerResourceId(null);
setDockerPopoverPosition(null);
}; };
const badge = serviceHealthBadge(service); const badge = serviceHealthBadge(service);
@ -2085,9 +2099,6 @@ const DockerServiceRow: Component<{
size="xs" size="xs"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name"> <div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span <span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none" class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"
@ -2120,7 +2131,7 @@ const DockerServiceRow: Component<{
<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" /> <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> </svg>
</button> </button>
<Show when={service.stack && !isEditingUrl()}> <Show when={service.stack}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}> <span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
Stack: {service.stack} Stack: {service.stack}
</span> </span>
@ -2143,24 +2154,6 @@ const DockerServiceRow: Component<{
</span> </span>
</Show> </Show>
</div> </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>
</div> </div>
</div> </div>
@ -2241,6 +2234,21 @@ const DockerServiceRow: Component<{
</For> </For>
</tr> </tr>
{/* URL editing popover - using shared component */}
<UrlEditPopover
isOpen={isEditingUrl()}
value={editingUrlValue()}
position={dockerPopoverPosition()}
isSaving={false}
hasExistingUrl={!!customUrl()}
placeholder="https://example.com:8080"
helpText="Add a URL to quickly access this service's web interface"
onValueChange={(value) => { dockerEditingValues.set(resourceId(), value); setDockerEditingValuesVersion(v => v + 1); }}
onSave={saveUrl}
onCancel={cancelEditingUrl}
onDelete={deleteUrl}
/>
<Show when={expanded() && hasTasks()}> <Show when={expanded() && hasTasks()}>
<tr> <tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0"> <td colspan={DOCKER_COLUMNS.length} class="p-0">

View file

@ -20,6 +20,8 @@ import { aiChatStore } from '@/stores/aiChat';
import { STORAGE_KEYS } from '@/utils/localStorage'; import { STORAGE_KEYS } from '@/utils/localStorage';
import { useResourcesAsLegacy } from '@/hooks/useResources'; import { useResourcesAsLegacy } from '@/hooks/useResources';
import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata'; import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata';
import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
// Column definition for hosts table // Column definition for hosts table
@ -914,41 +916,43 @@ const HostRow: Component<HostRowProps> = (props) => {
// Check if this host is in AI context // Check if this host is in AI context
const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(host.id)); const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(host.id));
// URL editing state // URL editing using shared hook
const [isEditingUrl, setIsEditingUrl] = createSignal(false); const urlEdit = createUrlEditState();
const [editingUrlValue, setEditingUrlValue] = createSignal('');
const [isSavingUrl, setIsSavingUrl] = createSignal(false);
let urlInputRef: HTMLInputElement | undefined;
// Start editing URL const handleStartEditingUrl = (e: MouseEvent) => {
const startEditingUrl = (e: MouseEvent) => { urlEdit.startEditing(host.id, props.customUrl || '', e);
e.stopPropagation();
setEditingUrlValue(props.customUrl || '');
setIsEditingUrl(true);
// Focus input after render
setTimeout(() => urlInputRef?.focus(), 0);
}; };
// Save URL const handleSaveUrl = async () => {
const saveUrl = async () => { const url = urlEdit.editingValue().trim();
const url = editingUrlValue().trim(); urlEdit.setIsSaving(true);
setIsSavingUrl(true);
try { try {
if (url) { if (url) {
await props.onUpdateCustomUrl(host.id, url); await props.onUpdateCustomUrl(host.id, url);
showSuccess('Host URL saved');
} else { } else {
await props.onDeleteCustomUrl(host.id); await props.onDeleteCustomUrl(host.id);
showSuccess('Host URL removed');
} }
setIsEditingUrl(false); urlEdit.finishEditing();
} finally { } catch (err) {
setIsSavingUrl(false); const message = err instanceof Error ? err.message : 'Failed to save URL';
showError(message);
urlEdit.setIsSaving(false);
} }
}; };
// Cancel editing const handleDeleteUrl = async () => {
const cancelEditingUrl = () => { urlEdit.setIsSaving(true);
setIsEditingUrl(false); try {
setEditingUrlValue(''); await props.onDeleteCustomUrl(host.id);
showSuccess('Host URL removed');
urlEdit.finishEditing();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove URL';
showError(message);
urlEdit.setIsSaving(false);
}
}; };
// Build context for AI - includes routing fields // Build context for AI - includes routing fields
@ -999,6 +1003,7 @@ const HostRow: Component<HostRowProps> = (props) => {
}; };
return ( return (
<>
<tr class={rowClass()} onClick={handleRowClick}> <tr class={rowClass()} onClick={handleRowClick}>
{/* Host Name - always visible */} {/* Host Name - always visible */}
<td class="pl-4 pr-2 py-1 align-middle"> <td class="pl-4 pr-2 py-1 align-middle">
@ -1009,9 +1014,6 @@ const HostRow: Component<HostRowProps> = (props) => {
ariaLabel={hostStatus().label} ariaLabel={hostStatus().label}
size="xs" size="xs"
/> />
<Show
when={isEditingUrl()}
fallback={
<div class="min-w-0 flex items-center gap-1.5 group/name"> <div class="min-w-0 flex items-center gap-1.5 group/name">
<div> <div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
@ -1056,7 +1058,7 @@ const HostRow: Component<HostRowProps> = (props) => {
{/* Edit URL button - shows on hover */} {/* Edit URL button - shows on hover */}
<button <button
type="button" type="button"
onClick={startEditingUrl} onClick={handleStartEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all" class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={props.customUrl ? 'Edit URL' : 'Add URL'} title={props.customUrl ? 'Edit URL' : 'Add URL'}
> >
@ -1073,55 +1075,6 @@ const HostRow: Component<HostRowProps> = (props) => {
</span> </span>
</Show> </Show>
</div> </div>
}
>
{/* URL editing mode */}
<div class="flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
onInput={(e) => setEditingUrlValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8080"
class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isSavingUrl()}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
title="Save (Enter)"
>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
cancelEditingUrl();
}}
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Cancel (Esc)"
>
</button>
</div>
</Show>
</div> </div>
</td> </td>
@ -1281,5 +1234,21 @@ const HostRow: Component<HostRowProps> = (props) => {
</td> </td>
</Show> </Show>
</tr> </tr>
{/* URL editing popover - using shared component */}
<UrlEditPopover
isOpen={urlEdit.isEditing() && urlEdit.editingId() === host.id}
value={urlEdit.editingValue()}
position={urlEdit.position()}
isSaving={urlEdit.isSaving()}
hasExistingUrl={!!props.customUrl}
placeholder="https://192.168.1.100:8080"
helpText="Add a URL to quickly access this host's web interface"
onValueChange={urlEdit.setEditingValue}
onSave={handleSaveUrl}
onCancel={urlEdit.cancelEditing}
onDelete={handleDeleteUrl}
/>
</>
); );
}; };

View file

@ -0,0 +1,186 @@
import { Component, Show, createSignal, createEffect } from 'solid-js';
export interface UrlEditPopoverProps {
/** Whether the popover is visible */
isOpen: boolean;
/** Current URL value being edited */
value: string;
/** Position of the popover (fixed positioning) */
position: { top: number; left: number } | null;
/** Whether the URL is currently being saved */
isSaving?: boolean;
/** Whether there's an existing URL that can be deleted */
hasExistingUrl?: boolean;
/** Placeholder text for the input */
placeholder?: string;
/** Help text shown below the input */
helpText?: string;
/** Called when the input value changes */
onValueChange: (value: string) => void;
/** Called when save is requested */
onSave: () => void;
/** Called when cancel is requested */
onCancel: () => void;
/** Called when delete is requested (only if hasExistingUrl is true) */
onDelete?: () => void;
}
/**
* A reusable URL editing popover component that provides a consistent
* experience across all tables for adding/editing custom URLs.
*
* Uses fixed positioning to escape overflow clipping from parent containers.
*/
export const UrlEditPopover: Component<UrlEditPopoverProps> = (props) => {
let inputRef: HTMLInputElement | undefined;
// Focus input when popover opens
createEffect(() => {
if (props.isOpen && inputRef) {
// Use setTimeout to ensure the element is in the DOM
setTimeout(() => {
inputRef?.focus();
inputRef?.select();
}, 0);
}
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
props.onSave();
} else if (e.key === 'Escape') {
e.preventDefault();
props.onCancel();
}
};
return (
<Show when={props.isOpen && props.position}>
<div
data-url-editor
class="fixed z-[9999] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 min-w-[300px]"
style={{ top: `${props.position!.top}px`, left: `${props.position!.left}px` }}
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-2">
<input
ref={inputRef}
type="url"
class="flex-1 text-sm px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder={props.placeholder ?? 'https://example.com'}
value={props.value}
onInput={(e) => props.onValueChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={props.isSaving}
/>
{/* Save button */}
<button
type="button"
class="p-2 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50"
title="Save (Enter)"
disabled={props.isSaving}
onClick={props.onSave}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
{/* Delete button - only show if there's an existing URL */}
<Show when={props.hasExistingUrl && props.onDelete}>
<button
type="button"
class="p-2 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
title="Remove URL"
disabled={props.isSaving}
onClick={props.onDelete}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</Show>
{/* Cancel button */}
<button
type="button"
class="p-2 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Cancel (Esc)"
onClick={props.onCancel}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Help text */}
<Show when={props.helpText}>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{props.helpText}
</p>
</Show>
</div>
</Show>
);
};
/**
* Hook to manage URL editing state.
* Provides all the state and handlers needed for the UrlEditPopover.
*/
export function createUrlEditState() {
const [isEditing, setIsEditing] = createSignal(false);
const [editingValue, setEditingValue] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const startEditing = (id: string, currentValue: string, event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
const button = event.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
// Position below the button, slightly to the left for better visibility
setPosition({
top: rect.bottom + 4,
left: Math.max(8, rect.left - 100)
});
setEditingValue(currentValue);
setEditingId(id);
setIsEditing(true);
};
const cancelEditing = () => {
setIsEditing(false);
setEditingValue('');
setEditingId(null);
setPosition(null);
};
const finishEditing = () => {
setIsEditing(false);
setEditingValue('');
setEditingId(null);
setPosition(null);
setIsSaving(false);
};
return {
isEditing,
editingValue,
setEditingValue,
isSaving,
setIsSaving,
position,
editingId,
startEditing,
cancelEditing,
finishEditing,
};
}

View file

@ -162,3 +162,154 @@ func (h *DockerMetadataHandler) HandleDeleteMetadata(w http.ResponseWriter, r *h
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// HandleGetHostMetadata retrieves metadata for a Docker host or all hosts
func (h *DockerMetadataHandler) HandleGetHostMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if requesting specific host
path := r.URL.Path
// Handle both /api/docker/hosts/metadata and /api/docker/hosts/metadata/
if path == "/api/docker/hosts/metadata" || path == "/api/docker/hosts/metadata/" {
// Get all host metadata
w.Header().Set("Content-Type", "application/json")
allMeta := h.store.GetAllHostMetadata()
if allMeta == nil {
// Return empty object instead of null
json.NewEncoder(w).Encode(make(map[string]*config.DockerHostMetadata))
} else {
json.NewEncoder(w).Encode(allMeta)
}
return
}
// Get specific host ID from path
hostID := strings.TrimPrefix(path, "/api/docker/hosts/metadata/")
w.Header().Set("Content-Type", "application/json")
if hostID != "" {
// Get specific Docker host metadata
meta := h.store.GetHostMetadata(hostID)
if meta == nil {
// Return empty metadata instead of 404
json.NewEncoder(w).Encode(&config.DockerHostMetadata{})
} else {
json.NewEncoder(w).Encode(meta)
}
} else {
// This shouldn't happen with current routing, but handle it anyway
http.Error(w, "Invalid request path", http.StatusBadRequest)
}
}
// HandleUpdateHostMetadata updates metadata for a Docker host
func (h *DockerMetadataHandler) HandleUpdateHostMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
hostID := strings.TrimPrefix(r.URL.Path, "/api/docker/hosts/metadata/")
if hostID == "" || hostID == "metadata" {
http.Error(w, "Host ID required", http.StatusBadRequest)
return
}
// Limit request body to 16KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var meta config.DockerHostMetadata
if err := json.NewDecoder(r.Body).Decode(&meta); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate URL if provided
if meta.CustomURL != "" {
// Parse and validate the URL
parsedURL, err := url.Parse(meta.CustomURL)
if err != nil {
http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest)
return
}
// Check scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest)
return
}
// Check host is present and valid
if parsedURL.Host == "" {
http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:9000 or https://portainer.local)", http.StatusBadRequest)
return
}
// Check for incomplete URLs like "https://portainer."
if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") {
http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest)
return
}
}
// Get existing metadata to merge with new data
existing := h.store.GetHostMetadata(hostID)
if existing != nil {
// Merge: only update fields that are provided
if meta.CustomDisplayName != "" || existing.CustomDisplayName != "" {
if meta.CustomDisplayName == "" {
meta.CustomDisplayName = existing.CustomDisplayName
}
}
// CustomURL can be explicitly cleared, so we don't merge it unless updating
if meta.Notes == nil && existing.Notes != nil {
meta.Notes = existing.Notes
}
}
if err := h.store.SetHostMetadata(hostID, &meta); err != nil {
log.Error().Err(err).Str("hostID", hostID).Msg("Failed to save Docker host metadata")
// Provide more specific error message
errMsg := "Failed to save metadata"
if strings.Contains(err.Error(), "permission") {
errMsg = "Permission denied - check file permissions"
} else if strings.Contains(err.Error(), "no space") {
errMsg = "Disk full - cannot save metadata"
}
http.Error(w, errMsg, http.StatusInternalServerError)
return
}
log.Info().Str("hostID", hostID).Str("url", meta.CustomURL).Msg("Updated Docker host metadata")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(&meta)
}
// HandleDeleteHostMetadata removes metadata for a Docker host
func (h *DockerMetadataHandler) HandleDeleteHostMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
hostID := strings.TrimPrefix(r.URL.Path, "/api/docker/hosts/metadata/")
if hostID == "" || hostID == "metadata" {
http.Error(w, "Host ID required", http.StatusBadRequest)
return
}
if err := h.store.SetHostMetadata(hostID, nil); err != nil {
log.Error().Err(err).Str("hostID", hostID).Msg("Failed to delete Docker host metadata")
http.Error(w, "Failed to delete metadata", http.StatusInternalServerError)
return
}
log.Info().Str("hostID", hostID).Msg("Deleted Docker host metadata")
w.WriteHeader(http.StatusNoContent)
}

View file

@ -22,6 +22,8 @@ type DockerMetadata struct {
// DockerHostMetadata holds additional metadata for a Docker host // DockerHostMetadata holds additional metadata for a Docker host
type DockerHostMetadata struct { type DockerHostMetadata struct {
CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name
CustomURL string `json:"customUrl,omitempty"` // Custom URL for administration (e.g., Portainer)
Notes []string `json:"notes,omitempty"` // User annotations for AI context
} }
// dockerMetadataFile represents the on-disk format for Docker metadata // dockerMetadataFile represents the on-disk format for Docker metadata
@ -107,8 +109,8 @@ func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMet
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if meta == nil || meta.CustomDisplayName == "" { // If metadata is nil or all fields are empty, delete the entry
// If metadata is nil or custom display name is empty, delete the entry if meta == nil || (meta.CustomDisplayName == "" && meta.CustomURL == "" && len(meta.Notes) == 0) {
delete(s.hostMetadata, hostID) delete(s.hostMetadata, hostID)
} else { } else {
s.hostMetadata[hostID] = meta s.hostMetadata[hostID] = meta
@ -116,6 +118,7 @@ func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMet
// Save to disk // Save to disk
return s.save() return s.save()
} }
// Set updates or creates metadata for a Docker resource // Set updates or creates metadata for a Docker resource