Pulse/frontend-modern/src/components/Settings/ConnectionDetailPanel.tsx
rcourtman e6352afbef refactor(connections): drop drawer, render detail inline in workspace
Row click on the ConnectionsTable was still popping a right-side Dialog
drawer. That re-wrapped the per-type panels rather than replacing them —
"consolidation means shared fabric, not shared container."

Swap ConnectionDetailDrawer for ConnectionDetailPanel: same props, same
pause/remove handlers, but rendered as an inline bordered card instead
of a Dialog. The workspace Switch/Match gains a 'detail' mode that
shows the panel with a "Back to systems" header. Removing a connection
exits detail mode via onRemoved; editing routes through the existing
handleEditConnection.

Subsystem contracts updated to match: agent-lifecycle, frontend-primitives,
and api-contracts now name ConnectionDetailPanel.tsx (with a note that
it must not be re-wrapped as a floating Dialog); registry owned_files
and platform-connections-workspace-lifecycle-proof match_files renamed
to match the new filename.
2026-04-19 22:33:36 +01:00

284 lines
11 KiB
TypeScript

import { Component, For, Show, createEffect, createSignal, onCleanup } from 'solid-js';
import { ConnectionsAPI, type Connection } from '@/api/connections';
import { CONNECTION_TYPE_LABELS, surfaceLabel } from './useConnectionsLedger';
interface ConnectionDetailPanelProps {
connection: () => Connection | undefined;
onMutated?: () => void;
onEdit?: (connection: Connection) => void;
onRemoved?: () => void;
}
const EDITABLE_CONNECTION_TYPES: readonly Connection['type'][] = [
'pve',
'pbs',
'pmg',
'vmware',
'truenas',
];
const REMOVE_CONFIRM_TIMEOUT_MS = 4000;
const errorMessage = (err: unknown): string => {
if (err instanceof Error && err.message) return err.message;
if (typeof err === 'string' && err.trim()) return err;
return 'Something went wrong.';
};
const formatLastSeen = (value: string | null): string => {
if (!value) return 'No activity yet';
const ts = Date.parse(value);
if (Number.isNaN(ts)) return value;
return new Date(ts).toLocaleString();
};
const formatErrorAt = (value: string): string => {
const ts = Date.parse(value);
if (Number.isNaN(ts)) return value;
return new Date(ts).toLocaleString();
};
export const ConnectionDetailPanel: Component<ConnectionDetailPanelProps> = (props) => {
const [pendingAction, setPendingAction] = createSignal<'pause' | 'remove' | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null);
const [confirmingRemove, setConfirmingRemove] = createSignal(false);
let confirmTimer: number | undefined;
const clearConfirmTimer = () => {
if (confirmTimer !== undefined) {
window.clearTimeout(confirmTimer);
confirmTimer = undefined;
}
};
// Reset transient action state whenever the selected connection changes.
createEffect(() => {
props.connection();
setPendingAction(null);
setActionError(null);
setConfirmingRemove(false);
clearConfirmTimer();
});
onCleanup(clearConfirmTimer);
const handlePauseToggle = async (connection: Connection) => {
setActionError(null);
setPendingAction('pause');
try {
await ConnectionsAPI.setEnabled(connection.id, !connection.enabled);
props.onMutated?.();
} catch (err) {
setActionError(errorMessage(err));
} finally {
setPendingAction(null);
}
};
const handleRemoveClick = async (connection: Connection) => {
setActionError(null);
if (!confirmingRemove()) {
setConfirmingRemove(true);
clearConfirmTimer();
confirmTimer = window.setTimeout(() => {
setConfirmingRemove(false);
confirmTimer = undefined;
}, REMOVE_CONFIRM_TIMEOUT_MS);
return;
}
clearConfirmTimer();
setConfirmingRemove(false);
setPendingAction('remove');
try {
await ConnectionsAPI.remove(connection.id);
props.onMutated?.();
props.onRemoved?.();
} catch (err) {
setActionError(errorMessage(err));
} finally {
setPendingAction(null);
}
};
return (
<Show when={props.connection()}>
{(accessor) => {
const connection = accessor();
const typeLabel = CONNECTION_TYPE_LABELS[connection.type] ?? connection.type;
const activeScopeKeys = Object.keys(connection.scope ?? {}).filter(
(key) => connection.scope?.[key],
);
const inactiveScopeKeys = (connection.surfaces ?? []).filter(
(key) => !activeScopeKeys.includes(key),
);
const canPause = connection.capabilities.supportsPause;
const canRemove = connection.type !== 'docker' && connection.type !== 'kubernetes';
const canEdit =
Boolean(props.onEdit) && EDITABLE_CONNECTION_TYPES.includes(connection.type);
const pauseLabel = connection.enabled ? 'Pause' : 'Resume';
const pauseBusy = () => pendingAction() === 'pause';
const removeBusy = () => pendingAction() === 'remove';
const anyBusy = () => pendingAction() !== null;
return (
<div class="rounded-lg border border-border bg-surface">
<div class="px-5 py-4">
<dl class="grid gap-4 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Type</dt>
<dd class="mt-1 text-base-content">{typeLabel}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Address</dt>
<dd class="mt-1 break-all text-base-content">{connection.address || '—'}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">State</dt>
<dd class="mt-1 text-base-content">
<div class="font-medium capitalize">{connection.state}</div>
<Show when={connection.stateReason}>
<div class="mt-0.5 text-xs text-muted">{connection.stateReason}</div>
</Show>
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Enabled</dt>
<dd class="mt-1 text-base-content">
{connection.enabled ? 'Yes' : 'No (paused)'}
</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Surfaces collected
</dt>
<dd class="mt-1 flex flex-wrap gap-1.5">
<Show
when={activeScopeKeys.length > 0}
fallback={
<span class="text-xs text-muted">
No surfaces currently enabled for collection.
</span>
}
>
<For each={activeScopeKeys}>
{(key) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-base-content">
{surfaceLabel(key)}
</span>
)}
</For>
</Show>
</dd>
<Show when={inactiveScopeKeys.length > 0}>
<dd class="mt-2 flex flex-wrap gap-1.5">
<For each={inactiveScopeKeys}>
{(key) => (
<span class="inline-flex items-center rounded-full border border-dashed border-border bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted">
{surfaceLabel(key)} (paused)
</span>
)}
</For>
</dd>
</Show>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Last activity
</dt>
<dd class="mt-1 text-base-content">{formatLastSeen(connection.lastSeen)}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">Source</dt>
<dd class="mt-1 text-base-content capitalize">{connection.source}</dd>
</div>
<Show when={connection.lastError}>
{(errorAccessor) => {
const err = errorAccessor();
return (
<div class="sm:col-span-2">
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
Last error
</dt>
<dd class="mt-1 space-y-1">
<div class="break-words text-rose-700 dark:text-rose-300">
{err.message}
</div>
<div class="text-xs text-muted">{formatErrorAt(err.at)}</div>
</dd>
</div>
);
}}
</Show>
</dl>
</div>
<Show when={canEdit || canPause || canRemove}>
<div class="space-y-2 border-t border-border px-5 py-4">
<Show when={actionError()}>
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{actionError()}
</div>
</Show>
<div class="flex flex-wrap items-center justify-end gap-2">
<Show when={canEdit}>
<button
type="button"
disabled={anyBusy()}
onClick={() => props.onEdit?.(connection)}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
Edit
</button>
</Show>
<Show when={canPause}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void handlePauseToggle(connection)}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{pauseBusy() ? 'Working…' : pauseLabel}
</button>
</Show>
<Show when={canRemove}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void handleRemoveClick(connection)}
class={
confirmingRemove()
? 'inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'
: 'inline-flex items-center rounded-md border border-rose-300 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950'
}
>
{removeBusy()
? 'Removing…'
: confirmingRemove()
? 'Click again to confirm'
: 'Remove'}
</button>
</Show>
</div>
<Show when={connection.type === 'agent'}>
<p class="text-xs text-muted">
Removing stops recording this agent. Run the uninstall command on the host to
fully detach; history is retained.
</p>
</Show>
</div>
</Show>
</div>
);
}}
</Show>
);
};