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:
parent
337987cb56
commit
bd1f4682be
9 changed files with 1635 additions and 1118 deletions
40
frontend-modern/src/api/dockerHostMetadata.ts
Normal file
40
frontend-modern/src/api/dockerHostMetadata.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,423 +783,388 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<>
|
||||||
class={rowClass()}
|
<tr
|
||||||
style={rowStyle()}
|
class={rowClass()}
|
||||||
onClick={handleRowClick}
|
style={rowStyle()}
|
||||||
data-guest-id={guestId()}
|
onClick={handleRowClick}
|
||||||
>
|
data-guest-id={guestId()}
|
||||||
{/* Name - always visible */}
|
>
|
||||||
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
{/* Name - always visible */}
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<StatusDot
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
variant={guestStatus().variant}
|
<StatusDot
|
||||||
title={guestStatus().label}
|
variant={guestStatus().variant}
|
||||||
ariaLabel={guestStatus().label}
|
title={guestStatus().label}
|
||||||
size="xs"
|
ariaLabel={guestStatus().label}
|
||||||
/>
|
size="xs"
|
||||||
<Show
|
/>
|
||||||
when={isEditingUrl()}
|
<div class="flex items-center gap-1.5 min-w-0 group/name">
|
||||||
fallback={
|
<span
|
||||||
<div class="flex items-center gap-1.5 min-w-0 group/name">
|
class="text-xs font-medium text-gray-900 dark:text-gray-100 select-none whitespace-nowrap"
|
||||||
<span
|
title={props.guest.name}
|
||||||
class="text-xs font-medium text-gray-900 dark:text-gray-100 select-none whitespace-nowrap"
|
>
|
||||||
title={props.guest.name}
|
{props.guest.name}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl() && 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 ${customUrl()}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{props.guest.name}
|
<svg
|
||||||
</span>
|
class="w-3.5 h-3.5"
|
||||||
<Show when={customUrl() && customUrl() !== ''}>
|
fill="none"
|
||||||
<a
|
stroke="currentColor"
|
||||||
href={customUrl()}
|
viewBox="0 0 24 24"
|
||||||
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 ${customUrl()}`}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
class="w-3.5 h-3.5"
|
stroke-linecap="round"
|
||||||
fill="none"
|
stroke-linejoin="round"
|
||||||
stroke="currentColor"
|
stroke-width="2"
|
||||||
viewBox="0 0 24 24"
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
{/* Edit URL button - shows on hover */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startEditingUrl}
|
|
||||||
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'}
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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>
|
</a>
|
||||||
{/* Show backup indicator in name cell only if backup column is hidden */}
|
</Show>
|
||||||
<Show when={!isColVisible('backup')}>
|
{/* Edit URL button - shows on hover */}
|
||||||
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
|
<button
|
||||||
</Show>
|
type="button"
|
||||||
{/* AI context indicator - shows when row is selected for AI */}
|
onClick={handleStartEditingUrl}
|
||||||
<Show when={isInAIContext()}>
|
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"
|
||||||
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
title={customUrl() ? 'Edit URL' : 'Add URL'}
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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" />
|
||||||
</span>
|
</svg>
|
||||||
</Show>
|
</button>
|
||||||
|
{/* Show backup indicator in name cell only if backup column is hidden */}
|
||||||
|
<Show when={!isColVisible('backup')}>
|
||||||
|
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
|
||||||
|
</Show>
|
||||||
|
{/* AI context indicator - shows when row is selected for AI */}
|
||||||
|
<Show when={isInAIContext()}>
|
||||||
|
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Show when={lockLabel()}>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap"
|
||||||
|
title={`Guest is locked (${lockLabel()})`}
|
||||||
|
>
|
||||||
|
Lock: {lockLabel()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<Show when={isColVisible('type')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span
|
||||||
|
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
|
: isOCIContainer()
|
||||||
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
||||||
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isVM(props.guest)
|
||||||
|
? 'Virtual Machine'
|
||||||
|
: isOCIContainer()
|
||||||
|
? `OCI Container${ociImage() ? ` • ${ociImage()}` : ''}`
|
||||||
|
: 'LXC Container'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* VMID */}
|
||||||
|
<Show when={isColVisible('vmid')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{props.guest.vmid}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* CPU */}
|
||||||
|
<Show when={isColVisible('cpu')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
||||||
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden h-4 flex items-center justify-center">
|
||||||
|
<MetricText value={cpuPercent()} type="cpu" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block h-4">
|
||||||
|
<EnhancedCPUBar
|
||||||
|
usage={cpuPercent()}
|
||||||
|
cores={props.guest.cpus}
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
<Show when={isColVisible('memory')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
||||||
|
<div title={memoryTooltip() ?? undefined}>
|
||||||
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={memPercent()}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
sublabel={memoryUsageLabel()}
|
||||||
|
isRunning={isRunning()}
|
||||||
|
showMobile={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<Show
|
||||||
|
when={viewMode() === 'sparklines'}
|
||||||
|
fallback={
|
||||||
|
<StackedMemoryBar
|
||||||
|
used={props.guest.memory?.used || 0}
|
||||||
|
total={props.guest.memory?.total || 0}
|
||||||
|
balloon={props.guest.memory?.balloon || 0}
|
||||||
|
swapUsed={props.guest.memory?.swapUsed || 0}
|
||||||
|
swapTotal={props.guest.memory?.swapTotal || 0}
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={memPercent()}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
sublabel={memoryUsageLabel()}
|
||||||
|
isRunning={isRunning()}
|
||||||
|
showMobile={false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Disk */}
|
||||||
|
<Show when={isColVisible('disk')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
||||||
|
<Show
|
||||||
|
when={hasDiskUsage()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class="text-xs text-gray-400" title={getDiskStatusTooltip()}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* URL editing mode */}
|
<Show when={isMobile()}>
|
||||||
<div class="flex items-center gap-1 min-w-0" data-url-editor>
|
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
<input
|
{formatPercent(diskPercent())}
|
||||||
ref={urlInputRef}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Show when={lockLabel()}>
|
|
||||||
<span
|
|
||||||
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap"
|
|
||||||
title={`Guest is locked (${lockLabel()})`}
|
|
||||||
>
|
|
||||||
Lock: {lockLabel()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<Show when={isColVisible('type')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span
|
|
||||||
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
|
||||||
: isOCIContainer()
|
|
||||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
|
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
isVM(props.guest)
|
|
||||||
? 'Virtual Machine'
|
|
||||||
: isOCIContainer()
|
|
||||||
? `OCI Container${ociImage() ? ` • ${ociImage()}` : ''}`
|
|
||||||
: 'LXC Container'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* VMID */}
|
|
||||||
<Show when={isColVisible('vmid')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
{props.guest.vmid}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* CPU */}
|
|
||||||
<Show when={isColVisible('cpu')}>
|
|
||||||
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden h-4 flex items-center justify-center">
|
|
||||||
<MetricText value={cpuPercent()} type="cpu" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block h-4">
|
|
||||||
<EnhancedCPUBar
|
|
||||||
usage={cpuPercent()}
|
|
||||||
cores={props.guest.cpus}
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Memory */}
|
|
||||||
<Show when={isColVisible('memory')}>
|
|
||||||
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
|
||||||
<div title={memoryTooltip() ?? undefined}>
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden flex justify-center">
|
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={memPercent()}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
sublabel={memoryUsageLabel()}
|
|
||||||
isRunning={isRunning()}
|
|
||||||
showMobile={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<Show
|
|
||||||
when={viewMode() === 'sparklines'}
|
|
||||||
fallback={
|
|
||||||
<StackedMemoryBar
|
|
||||||
used={props.guest.memory?.used || 0}
|
|
||||||
total={props.guest.memory?.total || 0}
|
|
||||||
balloon={props.guest.memory?.balloon || 0}
|
|
||||||
swapUsed={props.guest.memory?.swapUsed || 0}
|
|
||||||
swapTotal={props.guest.memory?.swapTotal || 0}
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={memPercent()}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
sublabel={memoryUsageLabel()}
|
|
||||||
isRunning={isRunning()}
|
|
||||||
showMobile={false}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
<div class={isMobile() ? 'hidden md:block' : ''}>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Disk */}
|
|
||||||
<Show when={isColVisible('disk')}>
|
|
||||||
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
|
|
||||||
<Show
|
|
||||||
when={hasDiskUsage()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span class="text-xs text-gray-400" title={getDiskStatusTooltip()}>
|
|
||||||
-
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{formatPercent(diskPercent())}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class={isMobile() ? 'hidden md:block' : ''}>
|
|
||||||
<Show
|
|
||||||
when={viewMode() === 'sparklines'}
|
|
||||||
fallback={
|
|
||||||
<StackedDiskBar
|
|
||||||
disks={props.guest.disks}
|
|
||||||
aggregateDisk={props.guest.disk}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={diskPercent()}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
isRunning={isRunning()}
|
|
||||||
showMobile={false}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* IP Address with Network Tooltip */}
|
|
||||||
<Show when={isColVisible('ip')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Show when={ipAddresses().length > 0 || hasNetworkInterfaces()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
|
||||||
<NetworkInfoCell
|
|
||||||
ipAddresses={ipAddresses()}
|
|
||||||
networkInterfaces={networkInterfaces()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Uptime */}
|
|
||||||
<Show when={isColVisible('uptime')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
<Show when={isRunning()} fallback="-">
|
|
||||||
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
|
|
||||||
{formatUptime(props.guest.uptime, true)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Node - NEW */}
|
|
||||||
<Show when={isColVisible('node')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[80px]" title={props.guest.node}>
|
|
||||||
{props.guest.node}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Backup Status */}
|
|
||||||
<Show when={isColVisible('backup')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Show when={!props.guest.template}>
|
|
||||||
<BackupStatusCell lastBackup={props.guest.lastBackup} />
|
|
||||||
</Show>
|
|
||||||
<Show when={props.guest.template}>
|
|
||||||
<span class="text-xs text-gray-400">-</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<Show when={isColVisible('tags')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<TagBadges
|
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
|
||||||
maxVisible={0}
|
|
||||||
onTagClick={props.onTagClick}
|
|
||||||
activeSearch={props.activeSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* OS */}
|
|
||||||
<Show when={isColVisible('os')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Show
|
|
||||||
when={hasOsInfo()}
|
|
||||||
fallback={
|
|
||||||
<Show
|
<Show
|
||||||
when={ociImage()}
|
when={viewMode() === 'sparklines'}
|
||||||
fallback={<span class="text-xs text-gray-400">-</span>}
|
fallback={
|
||||||
|
<StackedDiskBar
|
||||||
|
disks={props.guest.disks}
|
||||||
|
aggregateDisk={props.guest.disk}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* For OCI containers without guest agent, show image name in OS column */}
|
<ResponsiveMetricCell
|
||||||
<span
|
value={diskPercent()}
|
||||||
class="text-xs text-purple-600 dark:text-purple-400 truncate max-w-[100px]"
|
type="disk"
|
||||||
title={`OCI Image: ${ociImage()}`}
|
resourceId={metricsKey()}
|
||||||
>
|
isRunning={isRunning()}
|
||||||
{ociImage()}
|
showMobile={false}
|
||||||
</span>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
</div>
|
||||||
>
|
</Show>
|
||||||
<OSInfoCell
|
</td>
|
||||||
osName={osName()}
|
</Show>
|
||||||
osVersion={osVersion()}
|
|
||||||
agentVersion={agentVersion()}
|
{/* IP Address with Network Tooltip */}
|
||||||
|
<Show when={isColVisible('ip')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Show when={ipAddresses().length > 0 || hasNetworkInterfaces()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
|
<NetworkInfoCell
|
||||||
|
ipAddresses={ipAddresses()}
|
||||||
|
networkInterfaces={networkInterfaces()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<Show when={isColVisible('uptime')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
<Show when={isRunning()} fallback="-">
|
||||||
|
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
|
||||||
|
{formatUptime(props.guest.uptime, true)}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Node - NEW */}
|
||||||
|
<Show when={isColVisible('node')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[80px]" title={props.guest.node}>
|
||||||
|
{props.guest.node}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Backup Status */}
|
||||||
|
<Show when={isColVisible('backup')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Show when={!props.guest.template}>
|
||||||
|
<BackupStatusCell lastBackup={props.guest.lastBackup} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.guest.template}>
|
||||||
|
<span class="text-xs text-gray-400">-</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Show when={isColVisible('tags')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<TagBadges
|
||||||
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
|
maxVisible={0}
|
||||||
|
onTagClick={props.onTagClick}
|
||||||
|
activeSearch={props.activeSearch}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</Show>
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Disk Read */}
|
{/* OS */}
|
||||||
<Show when={isColVisible('diskRead')}>
|
<Show when={isColVisible('os')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<div class="flex justify-center">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show
|
||||||
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
when={hasOsInfo()}
|
||||||
</Show>
|
fallback={
|
||||||
</div>
|
<Show
|
||||||
</td>
|
when={ociImage()}
|
||||||
</Show>
|
fallback={<span class="text-xs text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
{/* For OCI containers without guest agent, show image name in OS column */}
|
||||||
|
<span
|
||||||
|
class="text-xs text-purple-600 dark:text-purple-400 truncate max-w-[100px]"
|
||||||
|
title={`OCI Image: ${ociImage()}`}
|
||||||
|
>
|
||||||
|
{ociImage()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OSInfoCell
|
||||||
|
osName={osName()}
|
||||||
|
osVersion={osVersion()}
|
||||||
|
agentVersion={agentVersion()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Disk Write */}
|
{/* Disk Read */}
|
||||||
<Show when={isColVisible('diskWrite')}>
|
<Show when={isColVisible('diskRead')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Net In */}
|
{/* Disk Write */}
|
||||||
<Show when={isColVisible('netIn')}>
|
<Show when={isColVisible('diskWrite')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Net Out */}
|
{/* Net In */}
|
||||||
<Show when={isColVisible('netOut')}>
|
<Show when={isColVisible('netIn')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
</tr>
|
|
||||||
|
{/* Net Out */}
|
||||||
|
<Show when={isColVisible('netOut')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
|
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,261 +204,308 @@ 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">
|
<>
|
||||||
<ScrollableTable persistKey="docker-host-summary">
|
<Card padding="none" tone="glass" class={`mb-4 ${urlEdit.isEditing() ? 'overflow-visible' : 'overflow-hidden'}`}>
|
||||||
<table class="w-full border-collapse whitespace-nowrap">
|
<ScrollableTable persistKey="docker-host-summary">
|
||||||
<thead>
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
<thead>
|
||||||
<th
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||||
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('name')}
|
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
tabIndex={0}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
role="button"
|
tabIndex={0}
|
||||||
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
role="button"
|
||||||
>
|
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
||||||
Host {renderSortIndicator('name')}
|
>
|
||||||
</th>
|
Host {renderSortIndicator('name')}
|
||||||
<th class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
|
</th>
|
||||||
Status
|
<th class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
|
||||||
</th>
|
Status
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('cpu')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('cpu')}
|
||||||
CPU {renderSortIndicator('cpu')}
|
>
|
||||||
</th>
|
CPU {renderSortIndicator('cpu')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('memory')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('memory')}
|
||||||
Memory {renderSortIndicator('memory')}
|
>
|
||||||
</th>
|
Memory {renderSortIndicator('memory')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('disk')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('disk')}
|
||||||
Disk {renderSortIndicator('disk')}
|
>
|
||||||
</th>
|
Disk {renderSortIndicator('disk')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('running')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('running')}
|
||||||
Containers {renderSortIndicator('running')}
|
>
|
||||||
</th>
|
Containers {renderSortIndicator('running')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('uptime')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('uptime')}
|
||||||
Uptime {renderSortIndicator('uptime')}
|
>
|
||||||
</th>
|
Uptime {renderSortIndicator('uptime')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('lastSeen')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('lastSeen')}
|
||||||
Last Update {renderSortIndicator('lastSeen')}
|
>
|
||||||
</th>
|
Last Update {renderSortIndicator('lastSeen')}
|
||||||
<th
|
</th>
|
||||||
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<th
|
||||||
onClick={() => handleSort('agent')}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('agent')}
|
||||||
Agent {renderSortIndicator('agent')}
|
>
|
||||||
</th>
|
Agent {renderSortIndicator('agent')}
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
</thead>
|
||||||
<For each={sortedSummaries()}>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{(summary) => {
|
<For each={sortedSummaries()}>
|
||||||
const selected = props.selectedHostId() === summary.host.id;
|
{(summary) => {
|
||||||
const online = isHostOnline(summary.host);
|
const selected = props.selectedHostId() === summary.host.id;
|
||||||
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
const online = isHostOnline(summary.host);
|
||||||
|
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
||||||
|
|
||||||
const rowStyle = () => {
|
const rowStyle = () => {
|
||||||
const styles: Record<string, string> = {};
|
const styles: Record<string, string> = {};
|
||||||
const shadows: string[] = [];
|
const shadows: string[] = [];
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
||||||
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shadows.length > 0) {
|
if (shadows.length > 0) {
|
||||||
styles['box-shadow'] = shadows.join(', ');
|
styles['box-shadow'] = shadows.join(', ');
|
||||||
}
|
}
|
||||||
return styles;
|
return styles;
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
if (!online) {
|
if (!online) {
|
||||||
className += ' opacity-60';
|
className += ' opacity-60';
|
||||||
}
|
}
|
||||||
|
|
||||||
return className;
|
return className;
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
|
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
|
||||||
const runtimeInfo = resolveHostRuntime(summary.host);
|
const runtimeInfo = resolveHostRuntime(summary.host);
|
||||||
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
|
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
|
||||||
const metricsKey = buildMetricKey('dockerHost', summary.host.id);
|
const metricsKey = buildMetricKey('dockerHost', summary.host.id);
|
||||||
const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host));
|
const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
class={rowClass()}
|
class={rowClass()}
|
||||||
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}
|
||||||
title={hostStatus().label}
|
title={hostStatus().label}
|
||||||
ariaLabel={hostStatus().label}
|
ariaLabel={hostStatus().label}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
|
|
||||||
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
({summary.host.hostname})
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
{/* URL icon and link */}
|
||||||
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
<Show when={getHostCustomUrl(summary.host.id)}>
|
||||||
<span
|
<a
|
||||||
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
href={getHostCustomUrl(summary.host.id)}
|
||||||
title={runtimeInfo.raw || runtimeInfo.label}
|
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)}
|
||||||
>
|
>
|
||||||
{runtimeInfo.label}
|
<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}>
|
||||||
|
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
({summary.host.hostname})
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
||||||
|
<span
|
||||||
|
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
||||||
|
title={runtimeInfo.raw || runtimeInfo.label}
|
||||||
|
>
|
||||||
|
{runtimeInfo.label}
|
||||||
|
</span>
|
||||||
|
<Show when={runtimeVersion}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
v{runtimeVersion}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center items-center h-full w-full whitespace-nowrap">
|
||||||
|
{renderDockerStatusBadge(summary.host.status)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<MetricText value={summary.cpuPercent} type="cpu" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
|
<EnhancedCPUBar
|
||||||
|
usage={summary.cpuPercent}
|
||||||
|
loadAverage={summary.host.loadAverage?.[0]}
|
||||||
|
cores={summary.host.cpus}
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={summary.memoryPercent}
|
||||||
|
type="memory"
|
||||||
|
sublabel={summary.memoryLabel}
|
||||||
|
resourceId={metricsKey}
|
||||||
|
isRunning={online}
|
||||||
|
showMobile={isMobile()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={summary.diskPercent}
|
||||||
|
type="disk"
|
||||||
|
sublabel={summary.diskLabel}
|
||||||
|
resourceId={metricsKey}
|
||||||
|
isRunning={!!summary.diskLabel}
|
||||||
|
showMobile={isMobile()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
|
||||||
|
<Show
|
||||||
|
when={summary.totalCount > 0}
|
||||||
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
||||||
|
>
|
||||||
|
<StackedContainerBar
|
||||||
|
running={summary.runningCount}
|
||||||
|
stopped={summary.stoppedCount}
|
||||||
|
error={summary.errorCount}
|
||||||
|
total={summary.totalCount}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle whitespace-nowrap">
|
||||||
|
<div class="flex justify-center items-center h-full">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{uptimeLabel}
|
||||||
</span>
|
</span>
|
||||||
<Show when={runtimeVersion}>
|
</div>
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
</td>
|
||||||
v{runtimeVersion}
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center items-center h-full">
|
||||||
|
<Show
|
||||||
|
when={summary.lastSeenRelative}
|
||||||
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
||||||
|
>
|
||||||
|
{(relative) => (
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
|
||||||
|
title={summary.lastSeenAbsolute || undefined}
|
||||||
|
>
|
||||||
|
{relative()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
|
||||||
|
<Show
|
||||||
|
when={summary.host.agentVersion}
|
||||||
|
fallback={<span class="text-gray-400 dark:text-gray-500 text-xs">—</span>}
|
||||||
|
>
|
||||||
|
{(version) => (
|
||||||
|
<span
|
||||||
|
class={
|
||||||
|
agentOutdated
|
||||||
|
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
||||||
|
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
||||||
|
}
|
||||||
|
title={`${getAgentVersionTooltip(summary.host.agentVersion)}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
||||||
|
>
|
||||||
|
{version()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={agentOutdated}>
|
||||||
|
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
|
||||||
|
⚠
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td class="px-2 py-1 align-middle">
|
);
|
||||||
<div class="flex justify-center items-center h-full w-full whitespace-nowrap">
|
}}
|
||||||
{renderDockerStatusBadge(summary.host.status)}
|
</For>
|
||||||
</div>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
</ScrollableTable>
|
||||||
<Show when={isMobile()}>
|
</Card>
|
||||||
<div class="md:hidden flex justify-center">
|
|
||||||
<MetricText value={summary.cpuPercent} type="cpu" />
|
{/* URL editing popover - using shared component */}
|
||||||
</div>
|
<UrlEditPopover
|
||||||
</Show>
|
isOpen={urlEdit.isEditing()}
|
||||||
<div class="hidden md:block w-full">
|
value={urlEdit.editingValue()}
|
||||||
<EnhancedCPUBar
|
position={urlEdit.position()}
|
||||||
usage={summary.cpuPercent}
|
isSaving={urlEdit.isSaving()}
|
||||||
loadAverage={summary.host.loadAverage?.[0]}
|
hasExistingUrl={!!getHostCustomUrl(urlEdit.editingId() || '')}
|
||||||
cores={summary.host.cpus}
|
placeholder="https://portainer.local:9000"
|
||||||
resourceId={metricsKey}
|
helpText="Add a URL to quickly access this host's management interface (e.g., Portainer)"
|
||||||
/>
|
onValueChange={urlEdit.setEditingValue}
|
||||||
</div>
|
onSave={handleSaveUrl}
|
||||||
</td>
|
onCancel={urlEdit.cancelEditing}
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
onDelete={handleDeleteUrl}
|
||||||
<ResponsiveMetricCell
|
/>
|
||||||
value={summary.memoryPercent}
|
</>
|
||||||
type="memory"
|
|
||||||
sublabel={summary.memoryLabel}
|
|
||||||
resourceId={metricsKey}
|
|
||||||
isRunning={online}
|
|
||||||
showMobile={isMobile()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={summary.diskPercent}
|
|
||||||
type="disk"
|
|
||||||
sublabel={summary.diskLabel}
|
|
||||||
resourceId={metricsKey}
|
|
||||||
isRunning={!!summary.diskLabel}
|
|
||||||
showMobile={isMobile()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
|
|
||||||
<Show
|
|
||||||
when={summary.totalCount > 0}
|
|
||||||
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
|
||||||
>
|
|
||||||
<StackedContainerBar
|
|
||||||
running={summary.runningCount}
|
|
||||||
stopped={summary.stoppedCount}
|
|
||||||
error={summary.errorCount}
|
|
||||||
total={summary.totalCount}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 align-middle whitespace-nowrap">
|
|
||||||
<div class="flex justify-center items-center h-full">
|
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{uptimeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center items-center h-full">
|
|
||||||
<Show
|
|
||||||
when={summary.lastSeenRelative}
|
|
||||||
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
|
||||||
>
|
|
||||||
{(relative) => (
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
|
|
||||||
title={summary.lastSeenAbsolute || undefined}
|
|
||||||
>
|
|
||||||
{relative()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
|
|
||||||
<Show
|
|
||||||
when={summary.host.agentVersion}
|
|
||||||
fallback={<span class="text-gray-400 dark:text-gray-500 text-xs">—</span>}
|
|
||||||
>
|
|
||||||
{(version) => (
|
|
||||||
<span
|
|
||||||
class={
|
|
||||||
agentOutdated
|
|
||||||
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
|
||||||
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
|
||||||
}
|
|
||||||
title={`${getAgentVersionTooltip(summary.host.agentVersion)}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
|
||||||
>
|
|
||||||
{version()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={agentOutdated}>
|
|
||||||
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
|
|
||||||
⚠
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</ScrollableTable>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,89 +1200,68 @@ const DockerContainerRow: Component<{
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0 truncate">
|
<div class="flex-1 min-w-0 truncate">
|
||||||
<Show
|
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
|
||||||
when={isEditingUrl()}
|
<span
|
||||||
fallback={
|
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate"
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
|
title={containerTitle()}
|
||||||
<span
|
>
|
||||||
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate"
|
{container.name || container.id}
|
||||||
title={containerTitle()}
|
</span>
|
||||||
>
|
<Show when={podName()}>
|
||||||
{container.name || container.id}
|
{(name) => (
|
||||||
</span>
|
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200 flex-shrink-0">
|
||||||
<Show when={podName()}>
|
Pod: {name()}
|
||||||
{(name) => (
|
<Show when={isPodInfra()}>
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200 flex-shrink-0">
|
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200 ml-1">
|
||||||
Pod: {name()}
|
infra
|
||||||
<Show when={isPodInfra()}>
|
|
||||||
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200 ml-1">
|
|
||||||
infra
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
</span>
|
||||||
<Show when={customUrl()}>
|
)}
|
||||||
<a
|
</Show>
|
||||||
href={customUrl()}
|
<Show when={customUrl()}>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href={customUrl()}
|
||||||
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' : ''}`}
|
target="_blank"
|
||||||
title="Open in new tab"
|
rel="noopener noreferrer"
|
||||||
onClick={(event) => event.stopPropagation()}
|
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"
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onClick={(event) => event.stopPropagation()}
|
||||||
<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>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</a>
|
<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" />
|
||||||
</Show>
|
</svg>
|
||||||
{/* Edit URL button - shows on hover */}
|
</a>
|
||||||
<button
|
</Show>
|
||||||
type="button"
|
{/* Edit URL button - shows on hover */}
|
||||||
onClick={startEditingUrl}
|
<button
|
||||||
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"
|
type="button"
|
||||||
title={customUrl() ? 'Edit URL' : 'Add URL'}
|
onClick={startEditingUrl}
|
||||||
>
|
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"
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
title={customUrl() ? 'Edit URL' : 'Add URL'}
|
||||||
<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 class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<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" />
|
||||||
<Show when={props.showHostContext}>
|
</svg>
|
||||||
<span
|
</button>
|
||||||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0 max-w-[120px]"
|
<Show when={props.showHostContext}>
|
||||||
title={`Host: ${hostDisplayName()}`}
|
<span
|
||||||
>
|
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0 max-w-[120px]"
|
||||||
<StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
|
title={`Host: ${hostDisplayName()}`}
|
||||||
<span class="truncate">{hostDisplayName()}</span>
|
>
|
||||||
</span>
|
<StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
|
||||||
</Show>
|
<span class="truncate">{hostDisplayName()}</span>
|
||||||
{/* AI context indicator - shows when container is selected for AI */}
|
</span>
|
||||||
<Show when={isInAIContext()}>
|
</Show>
|
||||||
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
{/* AI context indicator - shows when container is selected for AI */}
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
<Show when={isInAIContext()}>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
||||||
</svg>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
</span>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||||
</Show>
|
</svg>
|
||||||
</div>
|
</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>
|
|
||||||
</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,82 +2099,61 @@ const DockerServiceRow: Component<{
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Show
|
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
|
||||||
when={isEditingUrl()}
|
<span
|
||||||
fallback={
|
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
|
title={serviceTitle()}
|
||||||
<span
|
>
|
||||||
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"
|
{service.name || service.id || 'Service'}
|
||||||
title={serviceTitle()}
|
</span>
|
||||||
>
|
<Show when={customUrl()}>
|
||||||
{service.name || service.id || 'Service'}
|
<a
|
||||||
</span>
|
href={customUrl()}
|
||||||
<Show when={customUrl()}>
|
target="_blank"
|
||||||
<a
|
rel="noopener noreferrer"
|
||||||
href={customUrl()}
|
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' : ''}`}
|
||||||
target="_blank"
|
title="Open in new tab"
|
||||||
rel="noopener noreferrer"
|
onClick={(event) => event.stopPropagation()}
|
||||||
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"
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
onClick={(event) => event.stopPropagation()}
|
<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>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</a>
|
||||||
<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" />
|
</Show>
|
||||||
</svg>
|
{/* Edit URL button - shows on hover */}
|
||||||
</a>
|
<button
|
||||||
</Show>
|
type="button"
|
||||||
{/* Edit URL button - shows on hover */}
|
onClick={startEditingUrl}
|
||||||
<button
|
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"
|
||||||
type="button"
|
title={customUrl() ? 'Edit URL' : 'Add URL'}
|
||||||
onClick={startEditingUrl}
|
>
|
||||||
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"
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
title={customUrl() ? 'Edit URL' : 'Add URL'}
|
<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 class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</button>
|
||||||
<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" />
|
<Show when={service.stack}>
|
||||||
</svg>
|
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
|
||||||
</button>
|
Stack: {service.stack}
|
||||||
<Show when={service.stack && !isEditingUrl()}>
|
</span>
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
|
</Show>
|
||||||
Stack: {service.stack}
|
<Show when={props.showHostContext}>
|
||||||
</span>
|
<span
|
||||||
</Show>
|
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||||
<Show when={props.showHostContext}>
|
title={`Host: ${hostDisplayName()}`}
|
||||||
<span
|
>
|
||||||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
<StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
|
||||||
title={`Host: ${hostDisplayName()}`}
|
<span class="max-w-[160px] truncate">{hostDisplayName()}</span>
|
||||||
>
|
</span>
|
||||||
<StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
|
</Show>
|
||||||
<span class="max-w-[160px] truncate">{hostDisplayName()}</span>
|
{/* AI context indicator - shows when service is selected for AI */}
|
||||||
</span>
|
<Show when={isInAIContext()}>
|
||||||
</Show>
|
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
||||||
{/* AI context indicator - shows when service is selected for AI */}
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
<Show when={isInAIContext()}>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||||
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
</svg>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
</Show>
|
||||||
</svg>
|
</div>
|
||||||
</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>
|
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -43,13 +45,13 @@ export const HOST_COLUMNS: HostColumnDef[] = [
|
||||||
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
|
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
|
||||||
|
|
||||||
// Secondary - visible on md+, toggleable
|
// Secondary - visible on md+, toggleable
|
||||||
{ id: 'temp', label: 'Temp', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>, priority: 'secondary', width: '50px', toggleable: true },
|
{ id: 'temp', label: 'Temp', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>, priority: 'secondary', width: '50px', toggleable: true },
|
||||||
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>, priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' },
|
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>, priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' },
|
||||||
{ id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true },
|
{ id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true },
|
||||||
|
|
||||||
// Supplementary - visible on lg+, toggleable
|
// Supplementary - visible on lg+, toggleable
|
||||||
// Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip
|
// Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip
|
||||||
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>, priority: 'supplementary', width: '50px', toggleable: true },
|
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, priority: 'supplementary', width: '50px', toggleable: true },
|
||||||
|
|
||||||
// Detailed - visible on xl+, toggleable
|
// Detailed - visible on xl+, toggleable
|
||||||
{ id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true },
|
{ id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true },
|
||||||
|
|
@ -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,287 +1003,252 @@ const HostRow: Component<HostRowProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr class={rowClass()} onClick={handleRowClick}>
|
<>
|
||||||
{/* Host Name - always visible */}
|
<tr class={rowClass()} onClick={handleRowClick}>
|
||||||
<td class="pl-4 pr-2 py-1 align-middle">
|
{/* Host Name - always visible */}
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<td class="pl-4 pr-2 py-1 align-middle">
|
||||||
<StatusDot
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
variant={hostStatus().variant}
|
<StatusDot
|
||||||
title={hostStatus().label}
|
variant={hostStatus().variant}
|
||||||
ariaLabel={hostStatus().label}
|
title={hostStatus().label}
|
||||||
size="xs"
|
ariaLabel={hostStatus().label}
|
||||||
/>
|
size="xs"
|
||||||
<Show
|
/>
|
||||||
when={isEditingUrl()}
|
<div class="min-w-0 flex items-center gap-1.5 group/name">
|
||||||
fallback={
|
<div>
|
||||||
<div class="min-w-0 flex items-center gap-1.5 group/name">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||||
<div>
|
{host.displayName || host.hostname || host.id}
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
</p>
|
||||||
{host.displayName || host.hostname || host.id}
|
<Show when={host.displayName && host.displayName !== host.hostname}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
|
{host.hostname}
|
||||||
</p>
|
</p>
|
||||||
<Show when={host.displayName && host.displayName !== host.hostname}>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
|
||||||
{host.hostname}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.lastSeen}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
|
||||||
Updated {formatRelativeTime(host.lastSeen!)}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
{/* Custom URL link */}
|
|
||||||
<Show when={props.customUrl}>
|
|
||||||
<a
|
|
||||||
href={props.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"
|
|
||||||
title={`Open ${props.customUrl}`}
|
|
||||||
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>
|
||||||
{/* Edit URL button - shows on hover */}
|
<Show when={host.lastSeen}>
|
||||||
<button
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
type="button"
|
Updated {formatRelativeTime(host.lastSeen!)}
|
||||||
onClick={startEditingUrl}
|
</p>
|
||||||
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'}
|
|
||||||
>
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/* AI context indicator */}
|
|
||||||
<Show when={isInAIContext()}>
|
|
||||||
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
{/* Custom URL link */}
|
||||||
>
|
<Show when={props.customUrl}>
|
||||||
{/* URL editing mode */}
|
<a
|
||||||
<div class="flex items-center gap-1 min-w-0" data-url-editor>
|
href={props.customUrl}
|
||||||
<input
|
target="_blank"
|
||||||
ref={urlInputRef}
|
rel="noopener noreferrer"
|
||||||
type="text"
|
class="flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||||
value={editingUrlValue()}
|
title={`Open ${props.customUrl}`}
|
||||||
onInput={(e) => setEditingUrlValue(e.currentTarget.value)}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === 'Enter') {
|
<svg
|
||||||
e.preventDefault();
|
class="w-3.5 h-3.5"
|
||||||
saveUrl();
|
fill="none"
|
||||||
} else if (e.key === 'Escape') {
|
stroke="currentColor"
|
||||||
e.preventDefault();
|
viewBox="0 0 24 24"
|
||||||
cancelEditingUrl();
|
>
|
||||||
}
|
<path
|
||||||
}}
|
stroke-linecap="round"
|
||||||
onClick={(e) => e.stopPropagation()}
|
stroke-linejoin="round"
|
||||||
placeholder="https://192.168.1.100:8080"
|
stroke-width="2"
|
||||||
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"
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
disabled={isSavingUrl()}
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
{/* Edit URL button - shows on hover */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
title={props.customUrl ? 'Edit URL' : 'Add URL'}
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* AI context indicator */}
|
||||||
|
<Show when={isInAIContext()}>
|
||||||
|
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Platform */}
|
||||||
|
<Show when={props.isColVisible('platform')}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
<p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
|
||||||
|
<Show when={host.osName}>
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
|
{host.osName} {host.osVersion}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* CPU */}
|
||||||
|
<Show when={props.isColVisible('cpu')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<Show when={props.isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<MetricText value={cpuPercent} type="cpu" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<EnhancedCPUBar
|
||||||
|
usage={cpuPercent}
|
||||||
|
loadAverage={host.loadAverage?.[0]}
|
||||||
|
cores={host.cpuCount}
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</td>
|
||||||
onClick={(e) => {
|
</Show>
|
||||||
e.stopPropagation();
|
|
||||||
saveUrl();
|
{/* Memory */}
|
||||||
|
<Show when={props.isColVisible('memory')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<Show when={props.isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<MetricText value={memPercent} type="memory" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<StackedMemoryBar
|
||||||
|
used={host.memory?.used || 0}
|
||||||
|
total={host.memory?.total || 0}
|
||||||
|
balloon={host.memory?.balloon || 0}
|
||||||
|
swapUsed={host.memory?.swapUsed || 0}
|
||||||
|
swapTotal={host.memory?.swapTotal || 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Disk */}
|
||||||
|
<Show when={props.isColVisible('disk')}>
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<Show when={props.isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<MetricText value={diskStats.percent} type="disk" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<StackedDiskBar
|
||||||
|
disks={host.disks}
|
||||||
|
aggregateDisk={{
|
||||||
|
total: diskStats.total,
|
||||||
|
used: diskStats.used,
|
||||||
|
free: diskStats.total - diskStats.used,
|
||||||
|
usage: diskStats.percent / 100
|
||||||
}}
|
}}
|
||||||
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>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Platform */}
|
{/* Temperature - shows primary temp with all sensors in tooltip */}
|
||||||
<Show when={props.isColVisible('platform')}>
|
<Show when={props.isColVisible('temp')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="text-xs text-gray-700 dark:text-gray-300">
|
<div class="flex justify-center">
|
||||||
<p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
|
<HostTemperatureCell sensors={host.sensors?.temperatureCelsius} />
|
||||||
<Show when={host.osName}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
|
||||||
{host.osName} {host.osVersion}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* CPU */}
|
|
||||||
<Show when={props.isColVisible('cpu')}>
|
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
|
||||||
<Show when={props.isMobile()}>
|
|
||||||
<div class="md:hidden flex justify-center">
|
|
||||||
<MetricText value={cpuPercent} type="cpu" />
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
<div class="hidden md:block">
|
</Show>
|
||||||
<EnhancedCPUBar
|
|
||||||
usage={cpuPercent}
|
|
||||||
loadAverage={host.loadAverage?.[0]}
|
|
||||||
cores={host.cpuCount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Memory */}
|
{/* Uptime */}
|
||||||
<Show when={props.isColVisible('memory')}>
|
<Show when={props.isColVisible('uptime')}>
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={props.isMobile()}>
|
<div class="flex justify-center">
|
||||||
<div class="md:hidden flex justify-center">
|
<Show when={host.uptimeSeconds} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
<MetricText value={memPercent} type="memory" />
|
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{formatUptime(host.uptimeSeconds!)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
<div class="hidden md:block">
|
</Show>
|
||||||
<StackedMemoryBar
|
|
||||||
used={host.memory?.used || 0}
|
|
||||||
total={host.memory?.total || 0}
|
|
||||||
balloon={host.memory?.balloon || 0}
|
|
||||||
swapUsed={host.memory?.swapUsed || 0}
|
|
||||||
swapTotal={host.memory?.swapTotal || 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Disk */}
|
{/* Agent Version */}
|
||||||
<Show when={props.isColVisible('disk')}>
|
<Show when={props.isColVisible('agent')}>
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={props.isMobile()}>
|
<div class="flex justify-center">
|
||||||
<div class="md:hidden flex justify-center">
|
<Show when={host.agentVersion} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
<MetricText value={diskStats.percent} type="disk" />
|
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{host.agentVersion}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
<div class="hidden md:block">
|
</Show>
|
||||||
<StackedDiskBar
|
|
||||||
disks={host.disks}
|
|
||||||
aggregateDisk={{
|
|
||||||
total: diskStats.total,
|
|
||||||
used: diskStats.used,
|
|
||||||
free: diskStats.total - diskStats.used,
|
|
||||||
usage: diskStats.percent / 100
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Temperature - shows primary temp with all sensors in tooltip */}
|
{/* IP Address - uses icon + count with tooltip */}
|
||||||
<Show when={props.isColVisible('temp')}>
|
<Show when={props.isColVisible('ip')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<HostTemperatureCell sensors={host.sensors?.temperatureCelsius} />
|
<HostNetworkInfoCell networkInterfaces={host.networkInterfaces || []} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Uptime */}
|
{/* Architecture */}
|
||||||
<Show when={props.isColVisible('uptime')}>
|
<Show when={props.isColVisible('arch')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<Show when={host.uptimeSeconds} fallback={<span class="text-xs text-gray-400">—</span>}>
|
<Show when={host.architecture} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<span class="text-[10px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
{formatUptime(host.uptimeSeconds!)}
|
{host.architecture}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Agent Version */}
|
{/* Kernel */}
|
||||||
<Show when={props.isColVisible('agent')}>
|
<Show when={props.isColVisible('kernel')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<Show when={host.agentVersion} fallback={<span class="text-xs text-gray-400">—</span>}>
|
<Show when={host.kernelVersion} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<span
|
||||||
{host.agentVersion}
|
class="text-[10px] text-gray-700 dark:text-gray-300 max-w-[100px] truncate"
|
||||||
</span>
|
title={host.kernelVersion}
|
||||||
</Show>
|
>
|
||||||
</div>
|
{host.kernelVersion}
|
||||||
</td>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* IP Address - uses icon + count with tooltip */}
|
{/* RAID Status */}
|
||||||
<Show when={props.isColVisible('ip')}>
|
<Show when={props.isColVisible('raid')}>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<HostNetworkInfoCell networkInterfaces={host.networkInterfaces || []} />
|
<HostRAIDStatusCell raid={host.raid} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</Show>
|
</Show>
|
||||||
|
</tr>
|
||||||
|
|
||||||
{/* Architecture */}
|
{/* URL editing popover - using shared component */}
|
||||||
<Show when={props.isColVisible('arch')}>
|
<UrlEditPopover
|
||||||
<td class="px-2 py-1 align-middle">
|
isOpen={urlEdit.isEditing() && urlEdit.editingId() === host.id}
|
||||||
<div class="flex justify-center">
|
value={urlEdit.editingValue()}
|
||||||
<Show when={host.architecture} fallback={<span class="text-xs text-gray-400">—</span>}>
|
position={urlEdit.position()}
|
||||||
<span class="text-[10px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
isSaving={urlEdit.isSaving()}
|
||||||
{host.architecture}
|
hasExistingUrl={!!props.customUrl}
|
||||||
</span>
|
placeholder="https://192.168.1.100:8080"
|
||||||
</Show>
|
helpText="Add a URL to quickly access this host's web interface"
|
||||||
</div>
|
onValueChange={urlEdit.setEditingValue}
|
||||||
</td>
|
onSave={handleSaveUrl}
|
||||||
</Show>
|
onCancel={urlEdit.cancelEditing}
|
||||||
|
onDelete={handleDeleteUrl}
|
||||||
{/* Kernel */}
|
/>
|
||||||
<Show when={props.isColVisible('kernel')}>
|
</>
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Show when={host.kernelVersion} fallback={<span class="text-xs text-gray-400">—</span>}>
|
|
||||||
<span
|
|
||||||
class="text-[10px] text-gray-700 dark:text-gray-300 max-w-[100px] truncate"
|
|
||||||
title={host.kernelVersion}
|
|
||||||
>
|
|
||||||
{host.kernelVersion}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* RAID Status */}
|
|
||||||
<Show when={props.isColVisible('raid')}>
|
|
||||||
<td class="px-2 py-1 align-middle">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<HostRAIDStatusCell raid={host.raid} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</Show>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
186
frontend-modern/src/components/shared/UrlEditPopover.tsx
Normal file
186
frontend-modern/src/components/shared/UrlEditPopover.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue