feat: unify agent deployments and scoped token flows

This commit is contained in:
rcourtman 2025-10-23 17:14:34 +00:00
parent cee24ff7e0
commit e565b292bf
7 changed files with 1017 additions and 995 deletions

View file

@ -85,6 +85,17 @@ export function Dashboard(props: DashboardProps) {
}
});
// Callback to update a guest's custom URL in metadata
const handleCustomUrlUpdate = (guestId: string, url: string) => {
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: url || undefined,
},
}));
};
// Create a mapping from node ID to node object
const nodeByInstance = createMemo(() => {
const map: Record<string, Node> = {};
@ -230,8 +241,7 @@ export function Dashboard(props: DashboardProps) {
const allGuests = createMemo(() => {
const vms = props.vms || [];
const containers = props.containers || [];
const guests: (VM | Container)[] = [...vms, ...containers];
return guests;
return [...vms, ...containers];
});
// Filter guests based on current settings
@ -782,33 +792,32 @@ export function Dashboard(props: DashboardProps) {
<NodeGroupHeader node={node!} colspan={11} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => (
<ComponentErrorBoundary name="GuestRow">
{(() => {
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
const guestId =
guest.id ||
(guest.instance === guest.node
? `${guest.node}-${guest.vmid}`
: `${guest.instance}-${guest.node}-${guest.vmid}`);
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
/>
);
})()}
</ComponentErrorBoundary>
)}
{(guest) => {
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
const guestId =
guest.id ||
(guest.instance === guest.node
? `${guest.node}-${guest.vmid}`
: `${guest.instance}-${guest.node}-${guest.vmid}`);
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);

View file

@ -6,10 +6,18 @@ import { IOMetric } from './IOMetric';
import { TagBadges } from './TagBadges';
import { DiskList } from './DiskList';
import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { showSuccess, showError } from '@/utils/toast';
type Guest = VM | Container;
const drawerState = new Map<string, boolean>();
// Global editing state - use a signal so all components react
const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal<string | null>(null);
// Store the editing value globally so it survives re-renders
const editingValues = new Map<string, string>();
// Signal to trigger reactivity when editing values change
const [editingValuesVersion, setEditingValuesVersion] = createSignal(0);
const buildGuestId = (guest: Guest) => {
if (guest.id) return guest.id;
@ -44,13 +52,21 @@ interface GuestRowProps {
onTagClick?: (tag: string) => void;
activeSearch?: string;
parentNodeOnline?: boolean;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
}
export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const initialGuestId = buildGuestId(props.guest);
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false);
const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false);
const editingUrlValue = createMemo(() => {
editingValuesVersion(); // Subscribe to changes
return editingValues.get(guestId()) || '';
});
let urlInputRef: HTMLInputElement | undefined;
const hasFilesystemDetails = createMemo(() => (props.guest.disks?.length ?? 0) > 0);
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
@ -62,12 +78,16 @@ export function GuestRow(props: GuestRowProps) {
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
const hasAgentInfo = createMemo(() => agentVersion().length > 0);
// Update custom URL when prop changes
// Update custom URL when prop changes, but only if we're not currently editing
createEffect(() => {
setCustomUrl(props.customUrl);
// Don't update customUrl from props if this guest is currently being edited
if (currentlyEditingGuestId() !== guestId()) {
setCustomUrl(props.customUrl);
}
});
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
@ -135,11 +155,80 @@ export function GuestRow(props: GuestRowProps) {
const toggleDrawer = (event: MouseEvent) => {
if (!canShowDrawer()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
if (target.closest('a, button, input, [data-prevent-toggle]')) {
return;
}
setDrawerOpen((prev) => !prev);
};
const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation();
// If another guest is being edited, save it first
const currentEditing = currentlyEditingGuestId();
if (currentEditing !== null && currentEditing !== guestId()) {
// Find the input for the currently editing guest and blur it
const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement;
if (currentInput) {
currentInput.blur();
}
}
editingValues.set(guestId(), customUrl() || '');
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(guestId());
// Focus the input after it renders
queueMicrotask(() => {
if (urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
};
const saveUrl = async () => {
// Only save if this guest is the one being edited
if (currentlyEditingGuestId() !== guestId()) return;
const newUrl = (editingValues.get(guestId()) || '').trim();
// Clear global editing state
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
// If URL hasn't changed, don't save
if (newUrl === (customUrl() || '')) return;
try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl });
setCustomUrl(newUrl || undefined);
// Notify parent to update metadata
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), newUrl);
}
if (newUrl) {
showSuccess('Guest URL saved');
} else {
showSuccess('Guest URL cleared');
}
} catch (err: any) {
console.error('Failed to save guest URL:', err);
showError(err.message || 'Failed to save guest URL');
}
};
const cancelEditingUrl = () => {
// Only cancel if this guest is the one being edited
if (currentlyEditingGuestId() !== guestId()) return;
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
};
const diskPercent = createMemo(() => {
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
// Check if usage is -1 (unknown/no guest agent)
@ -246,28 +335,92 @@ export function GuestRow(props: GuestRowProps) {
{/* Name - Sticky column */}
<td class={firstCellClass()}>
<div class="flex items-center gap-2">
{/* Name - clickable if custom URL is set */}
{/* Name - show input when editing, otherwise show name with optional link */}
<Show
when={customUrl()}
when={isEditingUrl()}
fallback={
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"
title={props.guest.name}
>
{props.guest.name}
</span>
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
>
{props.guest.name}
</span>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
</div>
}
>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer truncate"
title={`${props.guest.name} - Click to open custom URL`}
onClick={(event) => event.stopPropagation()}
>
{props.guest.name}
</a>
<div class="flex-1 flex items-center gap-1">
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-guest-id={guestId()}
onInput={(e) => {
editingValues.set(guestId(), e.currentTarget.value);
setEditingValuesVersion(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://192.168.1.100:8006"
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"
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="px-2 py-0.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
cancelEditingUrl();
}}
class="px-2 py-0.5 text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
title="Cancel (or press Escape)"
>
</button>
</div>
</Show>
{/* Tag badges */}

View file

@ -1,4 +1,4 @@
import { Component, createSignal, Show, For, onCleanup, onMount, createEffect, on, createMemo } from 'solid-js';
import { Component, createSignal, Show, For, onMount, createEffect, createMemo } from 'solid-js';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
@ -6,19 +6,17 @@ import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
import { MonitoringAPI } from '@/api/monitoring';
import { notificationStore } from '@/stores/notifications';
import type { SecurityStatus } from '@/types/config';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import type { DockerHost } from '@/types/api';
import type { APITokenRecord } from '@/api/security';
import { showTokenReveal } from '@/stores/tokenReveal';
import { DOCKER_MANAGE_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
import { useScopedTokenManager } from '@/hooks/useScopedTokenManager';
export const DockerAgents: Component = () => {
const { state } = useWebSocket();
const [showInstructions, setShowInstructions] = createSignal(true);
let hasLoggedSecurityStatusError = false;
let hasLoggedTokenAuthWarning = false;
let hasNotifiedTokenLoadError = false;
let hasNotifiedTokenAuthFailure = false;
let previousTokenStrategy: 'existing' | 'generate' | null = null;
const [showHidden, setShowHidden] = createSignal(false);
@ -42,17 +40,24 @@ export const DockerAgents: Component = () => {
const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false);
const [removeActionLoading, setRemoveActionLoading] = createSignal<'queue' | 'force' | 'hide' | null>(null);
const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false);
const [apiToken, setApiToken] = createSignal<string | null>(null);
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
const [availableTokens, setAvailableTokens] = createSignal<APITokenRecord[]>([]);
const [loadingTokens, setLoadingTokens] = createSignal(false);
const [tokensLoaded, setTokensLoaded] = createSignal(false);
const [tokensError, setTokensError] = createSignal(false);
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false);
const [selectedTokenStrategy, setSelectedTokenStrategy] = createSignal<'existing' | 'generate' | null>(null);
const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false);
const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const tokenStepLabel = 'Step 1 · Generate API token';
const commandStepLabel = 'Step 2 · Install command';
const {
token: apiToken,
isGeneratingToken,
generateToken,
} = useScopedTokenManager({
scope: DOCKER_REPORT_SCOPE,
storageKey: 'dockerAgentToken',
legacyKeys: ['apiToken'],
});
const pulseUrl = () => {
if (typeof window !== 'undefined') {
@ -145,21 +150,6 @@ const modalCommandProgress = createMemo(() => {
return;
}
const readToken = () => window.localStorage.getItem('apiToken');
const currentToken = readToken();
if (currentToken) {
setApiToken(currentToken);
}
const handleStorage = (event: StorageEvent) => {
if (event.key === 'apiToken') {
setApiToken(event.newValue);
}
};
window.addEventListener('storage', handleStorage);
onCleanup(() => window.removeEventListener('storage', handleStorage));
const fetchSecurityStatus = async () => {
try {
const response = await fetch('/api/security/status', { credentials: 'include' });
@ -177,89 +167,9 @@ const modalCommandProgress = createMemo(() => {
fetchSecurityStatus();
});
const loadTokens = async () => {
if (loadingTokens()) return;
setLoadingTokens(true);
setTokensError(false);
try {
const tokens = await SecurityAPI.listTokens();
const sorted = [...tokens].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
setAvailableTokens(sorted);
setTokenAccessDenied(false);
hasNotifiedTokenAuthFailure = false;
hasNotifiedTokenLoadError = false;
setTokensLoaded(true);
} catch (err) {
setTokensError(true);
if (err instanceof Error && /authentication required/i.test(err.message)) {
setTokenAccessDenied(true);
if (!hasLoggedTokenAuthWarning) {
hasLoggedTokenAuthWarning = true;
console.debug('API token listing requires authentication.');
}
if (!hasNotifiedTokenAuthFailure) {
hasNotifiedTokenAuthFailure = true;
notificationStore.error('Authentication required to list API tokens', 6000);
}
} else {
if (!hasNotifiedTokenLoadError) {
hasNotifiedTokenLoadError = true;
console.error('Failed to load API tokens', err);
notificationStore.error('Failed to load API tokens', 6000);
}
}
} finally {
setLoadingTokens(false);
}
};
const retryTokenLoad = () => {
if (loadingTokens()) return;
notificationStore.info('Retrying API token load…', 4000);
setTokensLoaded(false);
void loadTokens();
};
createEffect(on(
() => showInstructions(),
(isShowing) => {
if (isShowing && !tokensLoaded() && !loadingTokens()) {
void loadTokens();
}
}
));
const hasStoredToken = () => Boolean(apiToken());
const canGenerateToken = () => !tokenAccessDenied();
const commandReady = () => !requiresToken() || (!!apiToken() && selectedTokenStrategy() !== null);
const commandReady = () => !requiresToken() || Boolean(apiToken());
// Find the token record that matches the currently stored token
const storedTokenRecord = () => {
const hint = securityStatus()?.apiTokenHint;
if (!hint) return null;
return availableTokens().find(token => {
const tokenHint = `${token.prefix}...${token.suffix}`;
return tokenHint === hint;
});
};
createEffect(on(
() => [requiresToken(), apiToken(), selectedTokenStrategy()],
([requiresToken, hasToken, strategy]) => {
if (!requiresToken) {
setSelectedTokenStrategy('existing');
return;
}
if (!hasToken && strategy === 'existing') {
setSelectedTokenStrategy(null);
}
}
));
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
@ -291,79 +201,34 @@ const modalCommandProgress = createMemo(() => {
}
};
const handleSelectExistingToken = () => {
if (!hasStoredToken()) {
if (typeof window !== 'undefined' && window.showToast) {
window.showToast('warning', 'No saved token found in this browser. Generate a new token instead.');
}
return;
}
setSelectedTokenStrategy('existing');
if (typeof window !== 'undefined' && window.showToast) {
window.showToast('success', 'Install command updated with your saved token.');
}
};
const openGenerateTokenFlow = () => {
if (!canGenerateToken()) {
if (typeof window !== 'undefined' && window.showToast) {
window.showToast('error', 'Sign in with an administrator account to generate tokens here.');
}
return;
}
const currentTokens = availableTokens();
const defaultName = `Docker host ${currentTokens.length + 1}`;
previousTokenStrategy = selectedTokenStrategy();
setSelectedTokenStrategy('generate');
const openGenerateTokenModal = () => {
setGenerateError(null);
const defaultName = `Docker host ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName);
setShowGenerateTokenModal(true);
};
const handleCreateToken = async () => {
if (isGeneratingToken()) return;
if (!canGenerateToken()) {
notificationStore.error('You need administrator access to create API tokens from here.', 6000);
return;
}
setIsGeneratingToken(true);
setGenerateError(null);
try {
const currentTokens = availableTokens();
const defaultName = `Docker host ${currentTokens.length + 1}`;
const desiredName = newTokenName().trim() || defaultName;
const { token, record } = await SecurityAPI.createToken(desiredName);
const desiredName = newTokenName().trim() || `Docker host ${new Date().toISOString().slice(0, 10)}`;
const { token, record } = await generateToken(desiredName);
// Update the tokens list with the new token
const filtered = currentTokens.filter((t) => t.id !== record.id);
setAvailableTokens([record, ...filtered]);
setApiToken(token);
setSelectedTokenStrategy('generate');
previousTokenStrategy = 'generate';
setShowGenerateTokenModal(false);
setNewTokenName('');
setLatestRecord(record);
showTokenReveal({
token,
record,
source: 'docker',
note: 'Copy this token into the install command for your Docker agent or other automation.',
note: `Copy this token into the install command for your Docker agent. Scope: ${DOCKER_REPORT_SCOPE}.`,
});
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem('apiToken', token);
window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: token }));
} catch (err) {
console.warn('Unable to persist API token in localStorage', err);
}
}
notificationStore.success('New API token generated and added to the install command.', 6000);
notificationStore.success('New Docker reporting token generated and added to the install command.', 6000);
} catch (err) {
console.error('Failed to generate API token', err);
setGenerateError('Failed to generate API token. Confirm you are signed in as an administrator.');
notificationStore.error('Failed to generate API token', 6000);
} finally {
setIsGeneratingToken(false);
}
};
@ -573,156 +438,47 @@ WantedBy=multi-user.target`;
<Show when={requiresToken()}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1 · Choose an API token</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{tokenStepLabel}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Use the token saved in this browser or create a new credential just for this Docker host. The choice will populate the install command automatically.
Generate a fresh credential for this Docker host. Each token created here is limited to the <code>{DOCKER_REPORT_SCOPE}</code> scope.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Need lifecycle control or bespoke scopes? Visit <a href="/settings/security" class="text-blue-600 dark:text-blue-300 underline hover:no-underline font-medium">Security API tokens</a> to craft a custom token and add <code>{DOCKER_MANAGE_SCOPE}</code> if you plan to issue lifecycle commands.
</p>
</div>
<Show when={loadingTokens()}>
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
Loading API tokens
<Show when={generateError()}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-xs text-red-800 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200">
{generateError()}
</div>
</Show>
<Show when={tokensError()}>
<div class="space-y-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200">
<p>
{tokenAccessDenied()
? 'Authentication required to list API tokens. Sign in with an administrator account, then try again.'
: 'Failed to load API tokens. Please try again.'}
</p>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={retryTokenLoad}
class="inline-flex items-center rounded bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={loadingTokens()}
>
Retry
</button>
<Show when={!tokenAccessDenied()}>
<span class="text-xs text-red-700 dark:text-red-300">
Still failing? Check network connectivity and server logs.
</span>
</Show>
</div>
</div>
</Show>
<div class="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={handleSelectExistingToken}
disabled={!hasStoredToken()}
class={`relative flex flex-col gap-2 p-4 text-left rounded-lg border transition shadow-sm ${
selectedTokenStrategy() === 'existing'
? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-400/40 bg-blue-50/40 dark:bg-blue-900/10'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50/20 dark:hover:bg-blue-900/10'
} disabled:opacity-60 disabled:cursor-not-allowed`}
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Option 1</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Use saved token</h4>
</div>
<svg class="w-5 h-5 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Fill the install command with the API token already stored in this browser.
</p>
<Show when={hasStoredToken()}>
<div class="mt-2 space-y-1.5">
<div class="flex items-center gap-2 text-xs text-green-700 dark:text-green-300">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Saved token detected</span>
</div>
<Show when={storedTokenRecord()}>
<div class="pl-6 text-xs text-gray-700 dark:text-gray-300">
<span class="font-medium">{storedTokenRecord()?.name}</span>
<Show when={securityStatus()?.apiTokenHint}>
{' '}·{' '}
<code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] text-gray-600 dark:text-gray-400">
{securityStatus()?.apiTokenHint}
</code>
</Show>
</div>
</Show>
</div>
</Show>
<Show when={!hasStoredToken()}>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
No saved token found on this device.
</p>
</Show>
</button>
<button
type="button"
onClick={openGenerateTokenFlow}
disabled={!canGenerateToken()}
class={`relative flex flex-col gap-2 p-4 text-left rounded-lg border transition shadow-sm ${
selectedTokenStrategy() === 'generate'
? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-400/40 bg-blue-50/40 dark:bg-blue-900/10'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50/20 dark:hover:bg-blue-900/10'
} disabled:opacity-60 disabled:cursor-not-allowed`}
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Option 2</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">Generate new token</h4>
</div>
<span class="inline-flex items-center px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Recommended
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh API token for this host and insert it into the install command automatically.
</p>
<Show when={!canGenerateToken()}>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
Sign in with an administrator account to create tokens in the browser.
</p>
</Show>
</button>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<div class="flex-1 text-sm text-gray-700 dark:text-gray-300">
<strong class="text-gray-900 dark:text-gray-100">Best practice:</strong> Give each Docker host its own API token. You can audit or revoke tokens anytime from{' '}
<a href="/settings/security" class="text-blue-600 dark:text-blue-400 underline hover:no-underline font-medium">
Security Settings
</a>
.
</div>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securelythis is the only time it is shown.
</span>
</div>
</div>
</Show>
<Show when={!commandReady()}>
<div class="rounded-lg border border-dashed border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
Pick an option above to unlock the install command.
</div>
</Show>
<Show when={!hasStoredToken()}>
<div class="rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 text-xs text-yellow-800 dark:border-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-200">
Need a reusable token? Generate one here or visit <a href="/settings/security" class="underline hover:no-underline font-medium">Security Settings</a> to manage tokens across users.
</div>
</Show>
<button
type="button"
onClick={openGenerateTokenModal}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
</Show>
<Show when={commandReady()}>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Step 2 · Install command</label>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{commandStepLabel}</label>
<button
type="button"
onClick={async () => {
@ -865,8 +621,7 @@ WantedBy=multi-user.target`;
onClick={() => {
setShowGenerateTokenModal(false);
setNewTokenName('');
setSelectedTokenStrategy(previousTokenStrategy);
previousTokenStrategy = null;
setGenerateError(null);
}}
class="rounded px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>

View file

@ -1,453 +0,0 @@
import { createSignal, createMemo, onMount, For, Show } from 'solid-js';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import type { VM, Container } from '@/types/api';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface GuestURLsProps {
hasUnsavedChanges: () => boolean;
setHasUnsavedChanges: (value: boolean) => void;
}
export function GuestURLs(props: GuestURLsProps) {
const { state } = useWebSocket();
const [guestMetadata, setGuestMetadata] = createSignal<Record<string, GuestMetadata>>({});
const [searchTerm, setSearchTerm] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [initialLoad, setInitialLoad] = createSignal(true);
const [urlErrors, setUrlErrors] = createSignal<Record<string, string>>({});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
const vms = (state.vms || []) as VM[];
const containers = (state.containers || []) as Container[];
return [...vms, ...containers];
});
// Filter and group guests by node instance (not hostname, to handle duplicate names)
const groupedGuests = createMemo(() => {
const search = searchTerm().toLowerCase();
let guests = allGuests();
// Apply search filter
if (search) {
guests = guests.filter(
(guest) =>
guest.name.toLowerCase().includes(search) ||
guest.vmid.toString().includes(search) ||
guest.node.toLowerCase().includes(search),
);
}
// Group by instance ID (not hostname) to handle nodes with duplicate names
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach((guest) => {
const key = guest.instance; // Use unique instance ID
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(guest);
});
// Sort guests within each node by VMID
Object.keys(groups).forEach((instanceId) => {
groups[instanceId] = groups[instanceId].sort((a, b) => a.vmid - b.vmid);
});
return groups;
});
// Load saved URLs from backend on mount
onMount(async () => {
setLoading(true);
try {
const metadata = await GuestMetadataAPI.getAllMetadata();
setGuestMetadata(metadata || {});
} catch (err) {
console.error('Failed to load guest metadata:', err);
showError('Failed to load guest URLs');
} finally {
setLoading(false);
setInitialLoad(false);
}
});
// Save URLs to backend
const saveURLs = async () => {
setLoading(true);
try {
const metadata = guestMetadata();
const errors: string[] = [];
// Update each guest that has changes
for (const [guestId, meta] of Object.entries(metadata)) {
if (meta.customUrl !== undefined) {
try {
await GuestMetadataAPI.updateMetadata(guestId, { customUrl: meta.customUrl });
} catch (err: any) {
// Extract error message from response
const errorMsg = err.message || err.toString();
errors.push(`${guestId}: ${errorMsg}`);
console.error(`Failed to save URL for ${guestId}:`, err);
}
}
}
if (errors.length > 0) {
// Show specific validation errors
showError(errors.join('\n'));
} else {
showSuccess('Guest URLs saved');
props.setHasUnsavedChanges(false);
}
} catch (err) {
console.error('Failed to save guest URLs:', err);
showError('Failed to save guest URLs');
} finally {
setLoading(false);
}
};
// Validate URL format
const validateURL = (url: string): string | null => {
if (!url) return null; // Empty is valid
// Check for incomplete URLs like "https://emby."
if (url.endsWith('.') && !url.includes('..')) {
return 'URL appears incomplete - please enter a complete domain or IP address';
}
// Check for missing protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
try {
const parsed = new URL(url);
// Check for valid host
if (!parsed.hostname) {
return 'URL must include a valid hostname or IP address';
}
// Check for incomplete hostnames
if (parsed.hostname.endsWith('.') && !parsed.hostname.includes('..')) {
return 'Hostname appears incomplete';
}
return null; // Valid
} catch (_err) {
return 'Invalid URL format';
}
};
// Resolve the metadata key for a guest, supporting legacy identifiers
const resolveMetadataKey = (guestId: string, fallbackId: string) => {
const metadata = guestMetadata();
if (metadata[guestId]) return guestId;
if (metadata[fallbackId]) return fallbackId;
return guestId;
};
// Update a guest's URL configuration
const updateGuestURL = (guestId: string, url: string) => {
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: url,
},
}));
const error = validateURL(url);
setUrlErrors((prev) => {
const next = { ...prev };
if (error) {
next[guestId] = error;
} else {
delete next[guestId];
}
return next;
});
props.setHasUnsavedChanges(true);
};
// Clear a guest's URL configuration
const clearGuestURL = (guestId: string) => {
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: '',
},
}));
setUrlErrors((prev) => {
const next = { ...prev };
delete next[guestId];
return next;
});
props.setHasUnsavedChanges(true);
};
return (
<div class="space-y-6 w-full">
{/* Header */}
<SectionHeader
title="Guest URL management"
description="Configure custom URLs for accessing guest web interfaces. These URLs appear as shortcuts from the dashboard."
size="md"
/>
{/* Search */}
<div class="relative">
<input
type="text"
placeholder="Search guests..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full px-4 py-2 pl-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<svg
class="absolute left-3 top-2.5 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Save Button */}
<Show when={props.hasUnsavedChanges() && Object.keys(urlErrors()).length === 0}>
<div class="flex justify-end">
<button
type="button"
onClick={saveURLs}
disabled={loading()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading() ? 'Saving...' : 'Save Changes'}
</button>
</div>
</Show>
{/* Guest URLs Table */}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden w-full">
<Show
when={!initialLoad()}
fallback={
<div class="flex items-center justify-center py-12">
<div class="text-gray-500 dark:text-gray-400">Loading guest URLs...</div>
</div>
}
>
<div
class="overflow-x-auto w-full"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
<table class="min-w-full w-full table-auto">
<thead>
<tr class="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
Name
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
Type
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
VMID
</th>
<th
class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
style="min-width: 350px;"
>
Custom URL
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<Show
when={Object.keys(groupedGuests()).length === 0}
fallback={
<For
each={Object.entries(groupedGuests()).sort(([, guestsA], [, guestsB]) => {
// Sort by node hostname for display, not instance ID
const nodeA = guestsA[0]?.node || '';
const nodeB = guestsB[0]?.node || '';
return nodeA.localeCompare(nodeB);
})}
>
{([instanceId, guests]) => (
<>
{/* Node header row - show hostname not instance ID */}
<tr class="node-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td
colspan="5"
class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{guests[0]?.node || instanceId}
</td>
</tr>
{/* Guest rows for this node */}
<For each={guests}>
{(guest) => {
const guestId =
guest.id || `${guest.instance}-${guest.node}-${guest.vmid}`;
const fallbackId = `${guest.node}-${guest.vmid}`;
const metadataKey = createMemo(() =>
resolveMetadataKey(guestId, fallbackId),
);
const meta = createMemo(() => guestMetadata()[metadataKey()]);
const url = createMemo(() => meta()?.customUrl || '');
const hasUrl = createMemo(() => url().trim().length > 0);
const urlError = createMemo(() => urlErrors()[metadataKey()]);
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
<td class="p-1 px-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{guest.name}
</div>
</td>
<td class="p-1 px-2">
<span
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
>
{guest.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400">
{guest.vmid}
</td>
<td class="p-1 px-2">
<div>
<input
type="text"
placeholder="https://192.168.1.100:8006"
value={url()}
onInput={(e) =>
updateGuestURL(metadataKey(), e.currentTarget.value)
}
class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:border-transparent ${
urlError()
? 'border-red-500 dark:border-red-400 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500'
}`}
style="min-width: 300px;"
/>
<Show when={urlError()}>
<div class="text-xs text-red-600 dark:text-red-400 mt-1">
{urlError()}
</div>
</Show>
</div>
</td>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (hasUrl() && url()) {
window.open(url(), '_blank', 'noopener,noreferrer');
}
}}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-blue-600 border-blue-500 hover:bg-blue-50 dark:text-blue-300 dark:border-blue-400 dark:hover:bg-blue-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg
class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`}
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>
Test
</button>
<button
type="button"
onClick={() => clearGuestURL(metadataKey())}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-red-600 border-red-500 hover:bg-red-50 dark:text-red-400 dark:border-red-500 dark:hover:bg-red-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg
class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Clear
</button>
</div>
</td>
</tr>
);
}}
</For>
</>
)}
</For>
}
>
<tr>
<td
colspan="5"
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No guests found
</td>
</tr>
</Show>
</tbody>
</table>
</div>
</Show>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js';
import type { JSX } from 'solid-js';
import { useWebSocket } from '@/App';
import type { Host } from '@/types/api';
@ -6,10 +6,12 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { Card } from '@/components/shared/Card';
import CopyButton from '@/components/shared/CopyButton';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { SecurityAPI } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
import type { SecurityStatus } from '@/types/config';
import type { APITokenRecord } from '@/api/security';
import { useScopedTokenManager } from '@/hooks/useScopedTokenManager';
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows';
@ -17,10 +19,29 @@ interface HostAgentsProps {
variant?: HostAgentVariant;
}
type HostPlatform = 'linux' | 'macos' | 'windows';
const hostPlatformOptions: { id: HostPlatform; label: string; description: string }[] = [
{
id: 'linux',
label: 'Linux',
description: 'Download the static binary and enable the systemd service on Debian, Ubuntu, RHEL, Arch, and more.',
},
{
id: 'macos',
label: 'macOS',
description: 'Use the universal binary with launchd to keep desktops and servers reporting in the background.',
},
{
id: 'windows',
label: 'Windows',
description: 'Compile the agent for Windows or run it under WSL until native builds ship. Service template included.',
},
];
const RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download';
const TOKEN_PLACEHOLDER = '<api-token>';
const pulseUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:7655';
const { protocol, hostname, port } = window.location;
@ -138,9 +159,26 @@ const platformFilters: Record<HostAgentVariant, string[] | null> = {
export const HostAgents: Component<HostAgentsProps> = (props) => {
const variant: HostAgentVariant = props.variant ?? 'all';
const { state } = useWebSocket();
const [apiToken, setApiToken] = createSignal('');
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false);
let hasLoggedSecurityStatusError = false;
const [showInstructions, setShowInstructions] = createSignal(true);
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false);
const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const {
token: apiToken,
setToken: setApiToken,
isGeneratingToken,
generateToken,
} = useScopedTokenManager({
scope: HOST_AGENT_SCOPE,
storageKey: 'hostAgentToken',
legacyKeys: ['apiToken'],
});
const hosts = createMemo(() => {
const list = state.hosts ?? [];
@ -155,68 +193,121 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return tags.join(', ');
};
const installMeta = commandsByVariant[variant];
const [selectedPlatform, setSelectedPlatform] = createSignal<HostPlatform>('linux');
const effectiveVariant = createMemo<HostAgentVariant>(() =>
variant === 'all' ? selectedPlatform() : variant,
);
const installMeta = createMemo(() => commandsByVariant[effectiveVariant()]);
const tokenStepLabel = () => `${variant === 'all' ? 'Step 2' : 'Step 1'} · Choose an API token`;
const commandStepLabel = () => `${variant === 'all' ? 'Step 3' : 'Step 2'} · Installation commands`;
onMount(() => {
if (typeof window === 'undefined') return;
try {
const stored = window.localStorage.getItem('apiToken');
if (stored) {
setApiToken(stored);
}
} catch (err) {
console.warn('Unable to read API token from localStorage', err);
}
});
createEffect(() => {
if (typeof window === 'undefined') return;
const token = apiToken();
try {
if (token) {
window.localStorage.setItem('apiToken', token);
} else {
window.localStorage.removeItem('apiToken');
}
} catch (err) {
console.warn('Unable to persist API token in localStorage', err);
}
});
const generateToken = async () => {
if (isGeneratingToken()) return;
if (tokenAccessDenied()) {
notificationStore.error('Administrator access required to generate host agent tokens.', 6000);
if (typeof window === 'undefined') {
return;
}
setIsGeneratingToken(true);
const fetchSecurityStatus = async () => {
try {
const response = await fetch('/api/security/status', { credentials: 'include' });
if (response.ok) {
const data = (await response.json()) as SecurityStatus;
setSecurityStatus(data);
}
} catch (err) {
if (!hasLoggedSecurityStatusError) {
hasLoggedSecurityStatusError = true;
console.error('Failed to load security status', err);
}
}
};
fetchSecurityStatus();
});
const requiresToken = () => {
const status = securityStatus();
if (status) {
return status.requiresAuth || status.apiTokenConfigured;
}
return true;
};
const commandReady = () => !requiresToken() || Boolean(apiToken());
const openGenerateTokenModal = () => {
setGenerateError(null);
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName);
setShowGenerateTokenModal(true);
};
const handleCreateToken = async () => {
if (isGeneratingToken()) return;
setGenerateError(null);
try {
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
const { token, record } = await SecurityAPI.createToken(defaultName, [HOST_AGENT_SCOPE]);
setApiToken(token);
const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`;
const { token, record } = await generateToken(desiredName);
setShowGenerateTokenModal(false);
setNewTokenName('');
setLatestRecord(record);
showTokenReveal({
token,
record,
source: 'host-agent',
note: 'Copy this token into the host agent install command or store it securely for automation.',
note: `Copy this token into the host agent install command. Scope: ${HOST_AGENT_SCOPE}.`,
});
notificationStore.success('Created host agent API token with reporting scope.', 6000);
} catch (err) {
console.error('Failed to create host agent token', err);
if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) {
setTokenAccessDenied(true);
notificationStore.error('Sign in with an administrator account to generate tokens here.', 6000);
} else {
notificationStore.error('Failed to generate API token', 6000);
}
} finally {
setIsGeneratingToken(false);
console.error('Failed to generate host agent token', err);
setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.');
notificationStore.error('Failed to generate API token', 6000);
}
};
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
} catch (err) {
console.error('Failed to copy to clipboard', err);
return false;
}
};
const resolvedToken = () => {
if (!requiresToken()) {
return 'disabled';
}
return apiToken() || TOKEN_PLACEHOLDER;
};
const cardTitle = () => {
switch (variant) {
if (variant === 'all') {
return 'Pulse host agent';
}
switch (effectiveVariant()) {
case 'linux':
return 'Linux servers';
case 'macos':
@ -229,13 +320,17 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
};
const cardDescription = () => {
switch (variant) {
if (variant === 'all') {
return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.';
}
const platform = effectiveVariant();
switch (platform) {
case 'linux':
return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.';
case 'macos':
return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.';
case 'windows':
return 'Track Windows Server hosts through a native Pulse agent. A first-party build is on the roadmap—compile from source today or watch this space.';
return 'Track Windows Server hosts with the Pulse agent. Native builds are on the roadmap—compile today or run it under WSL.';
default:
return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.';
}
@ -245,74 +340,212 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
<div class="space-y-6">
<SectionHeader title={cardTitle()} description={cardDescription()} />
<Card padding="lg" class="space-y-4">
<Card padding="lg" class="space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">API token</h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
Manage tokens via <strong>Settings Security</strong>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta().title}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta().description}</p>
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="text"
value={apiToken()}
onInput={(e) => setApiToken(e.currentTarget.value.trim())}
placeholder="Paste API token (leave blank to keep <api-token> placeholder)"
class="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={generateToken}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setShowInstructions(!showInstructions())}
class="px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
{showInstructions() ? 'Hide' : 'Show'} instructions
</button>
<Show when={apiToken()}>
<span class="text-xs text-gray-500 dark:text-gray-400">
Token will be embedded in the commands below.
</span>
</Show>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Tokens generated here automatically include the host agent reporting scope (<code>host-agent:report</code>).
</p>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta.title}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta.description}</p>
<div class="space-y-3">
<For each={installMeta.snippets}>
{(snippet) => (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h4>
<CopyButton
text={snippet.command.replace(
TOKEN_PLACEHOLDER,
apiToken() || TOKEN_PLACEHOLDER,
)}
>
Copy command
</CopyButton>
<Show when={showInstructions()}>
<div class="space-y-5">
<Show when={variant === 'all'}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1 · Choose the operating system</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pick the platform you are onboarding. The install commands adapt automatically.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={hostPlatformOptions}>
{(option) => {
const isActive = () => selectedPlatform() === option.id;
return (
<button
type="button"
class={`flex flex-col items-start gap-2 rounded-xl border transition-colors p-4 text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
}`}
onClick={() => {
setSelectedPlatform(option.id);
setGenerateError(null);
setLatestRecord(null);
setApiToken(null);
}}
>
<p class="font-semibold text-gray-900 dark:text-gray-100">{option.label}</p>
<p class="text-xs text-gray-600 dark:text-gray-400">{option.description}</p>
</button>
);
}}
</For>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>
{snippet.command.replace(
TOKEN_PLACEHOLDER,
apiToken() || TOKEN_PLACEHOLDER,
)}
</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={requiresToken()}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{tokenStepLabel()}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate a scoped token for this host. Tokens minted here grant the <code>{HOST_AGENT_SCOPE}</code> permission only.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Need additional scopes? Visit <a href="/settings/security" class="text-blue-600 dark:text-blue-300 underline hover:no-underline font-medium">Security API tokens</a> to create a bespoke credential.
</p>
</div>
<Show when={generateError()}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-xs text-red-800 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200">
{generateError()}
</div>
</Show>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securelythis is the only time it is shown.
</span>
</div>
</Show>
<button
type="button"
onClick={openGenerateTokenModal}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
</Show>
<Show when={commandReady()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{commandStepLabel()}</h4>
<button
type="button"
onClick={async () => {
const firstSnippet = installMeta().snippets[0];
if (!firstSnippet) return;
const command = firstSnippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
const success = await copyToClipboard(command);
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy first command
</button>
</div>
<div class="space-y-3">
<For each={installMeta().snippets}>
{(snippet) => (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5>
<CopyButton
text={snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
)}
>
Copy command
</CopyButton>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>
{snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
)}
</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={requiresToken() && !commandReady()}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select or generate an API token to embed it in the install commands.
</p>
</Show>
</div>
</Show>
</Card>
<Show when={showGenerateTokenModal()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="space-y-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Generate a new host agent token</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pulse will create a scoped token for this host and automatically insert it into the install commands. You can manage or revoke tokens anytime from Security settings.
</p>
</div>
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300" for="host-agent-new-token-name">
Token name
</label>
<input
id="host-agent-new-token-name"
type="text"
value={newTokenName()}
onInput={(event) => setNewTokenName(event.currentTarget.value)}
class="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
placeholder="Host agent token"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Friendly names make it easier to audit tokens later (e.g. <code class="font-mono text-xs">host-lab-01</code>).
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowGenerateTokenModal(false);
setNewTokenName('');
setGenerateError(null);
}}
class="rounded px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={handleCreateToken}
disabled={isGeneratingToken()}
class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
</div>
</div>
</Show>
<Card padding="lg" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>

View file

@ -6,7 +6,6 @@ import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { NodeModal } from './NodeModal';
import { ChangePasswordModal } from './ChangePasswordModal';
import { GuestURLs } from './GuestURLs';
import { DockerAgents } from './DockerAgents';
import { HostAgents } from './HostAgents';
import APITokenManager from './APITokenManager';
@ -32,9 +31,7 @@ import Activity from 'lucide-solid/icons/activity';
import Loader from 'lucide-solid/icons/loader';
import Boxes from 'lucide-solid/icons/boxes';
import Network from 'lucide-solid/icons/network';
import Terminal from 'lucide-solid/icons/terminal';
import Monitor from 'lucide-solid/icons/monitor';
import Laptop from 'lucide-solid/icons/laptop';
import Sliders from 'lucide-solid/icons/sliders-horizontal';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import Clock from 'lucide-solid/icons/clock';
@ -228,15 +225,9 @@ interface DiscoveryScanStatus {
}
type SettingsTab =
| 'pve'
| 'pbs'
| 'pmg'
| 'docker'
| 'agent-hub'
| 'podman'
| 'kubernetes'
| 'linuxServers'
| 'windowsServers'
| 'macServers'
| 'system-general'
| 'system-network'
| 'system-updates'
@ -248,22 +239,12 @@ type SettingsTab =
| 'diagnostics'
| 'updates';
type AgentKey = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host' | 'podman' | 'kubernetes';
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
pve: {
title: 'Proxmox VE',
description: 'Connect clusters, manage nodes, and control discovery for Proxmox VE.',
},
pbs: {
title: 'Proxmox Backup Server',
description: 'Add and maintain Proxmox Backup Server endpoints used for snapshot and archive monitoring.',
},
pmg: {
title: 'Proxmox Mail Gateway',
description: 'Monitor mail flow, spam trends, and quarantine health from your PMG nodes.',
},
docker: {
title: 'Docker Agents',
description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.',
'agent-hub': {
title: 'Agent Deployments',
description: 'Select a platform to generate tokens, deploy agents, or add Proxmox endpoints.',
},
podman: {
title: 'Podman (container runtime)',
@ -273,18 +254,6 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
title: 'Kubernetes',
description: 'Cluster-wide monitoring via Pulse is coming soon. Watch this space for Helm charts and operators.',
},
linuxServers: {
title: 'Linux Servers',
description: 'Install the host agent on Debian, Ubuntu, RHEL, or other Linux distributions to surface capacity metrics.',
},
windowsServers: {
title: 'Windows Servers',
description: 'Native Windows support is planned; compile from source or track development updates here.',
},
macServers: {
title: 'macOS Devices',
description: 'Monitor macOS hosts via launchd-backed agents for uptime and temperature insights.',
},
'system-general': {
title: 'General Settings',
description: 'Configure appearance preferences and UI behavior.',
@ -357,35 +326,168 @@ const Settings: Component<SettingsProps> = (props) => {
const location = useLocation();
const deriveTabFromPath = (path: string): SettingsTab => {
if (path.includes('/settings/pbs')) return 'pbs';
if (path.includes('/settings/pmg')) return 'pmg';
if (path.includes('/settings/docker')) return 'docker';
if (path.includes('/settings/agent-hub')) return 'agent-hub';
if (path.includes('/settings/podman')) return 'podman';
if (path.includes('/settings/kubernetes')) return 'kubernetes';
if (path.includes('/settings/linuxServers')) return 'linuxServers';
if (path.includes('/settings/windowsServers')) return 'windowsServers';
if (path.includes('/settings/macServers')) return 'macServers';
if (path.includes('/settings/system-general')) return 'system-general';
if (path.includes('/settings/system-network')) return 'system-network';
if (path.includes('/settings/system-updates')) return 'system-updates';
if (path.includes('/settings/system-backups')) return 'system-backups';
// Legacy redirect: old /settings/system goes to general
if (path.includes('/settings/system')) return 'system-general';
if (path.includes('/settings/api')) return 'api';
if (path.includes('/settings/security-overview')) return 'security-overview';
if (path.includes('/settings/security-auth')) return 'security-auth';
if (path.includes('/settings/security-sso')) return 'security-sso';
// Legacy redirect: old /settings/security goes to overview
if (path.includes('/settings/security')) return 'security-overview';
if (path.includes('/settings/diagnostics')) return 'diagnostics';
if (path.includes('/settings/updates')) return 'updates';
return 'pve';
// Legacy platform paths map to the agent hub
if (
path.includes('/settings/pve') ||
path.includes('/settings/pbs') ||
path.includes('/settings/pmg') ||
path.includes('/settings/docker') ||
path.includes('/settings/linuxServers') ||
path.includes('/settings/windowsServers') ||
path.includes('/settings/macServers')
) {
return 'agent-hub';
}
return 'agent-hub';
};
const deriveAgentFromPath = (path: string): AgentKey | null => {
if (path.includes('/settings/pve')) return 'pve';
if (path.includes('/settings/pbs')) return 'pbs';
if (path.includes('/settings/pmg')) return 'pmg';
if (path.includes('/settings/docker')) return 'docker';
if (
path.includes('/settings/host') ||
path.includes('/settings/host-agents') ||
path.includes('/settings/linuxServers') ||
path.includes('/settings/windowsServers') ||
path.includes('/settings/macServers')
) {
return 'host';
}
if (path.includes('/settings/podman')) return 'podman';
if (path.includes('/settings/kubernetes')) return 'kubernetes';
return null;
};
const [currentTab, setCurrentTab] = createSignal<SettingsTab>(deriveTabFromPath(location.pathname));
const activeTab = () => currentTab();
const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve');
const agentGroups: {
title: string;
description?: string;
agents: Array<{ id: AgentKey; label: string; description: string; icon: JSX.Element; disabled?: boolean }>;
}[] = [
{
title: 'Proxmox API integrations',
description: 'Connect Pulse directly to your Proxmox estate using scoped API tokens.',
agents: [
{
id: 'pve',
label: 'Proxmox VE',
description: 'Connect VE clusters and manage discovery.',
icon: <Server class="w-6 h-6" strokeWidth={2} />,
},
{
id: 'pbs',
label: 'Proxmox Backup Server',
description: 'Monitor backup jobs and datastore health.',
icon: <HardDrive class="w-6 h-6" strokeWidth={2} />,
},
{
id: 'pmg',
label: 'Proxmox Mail Gateway',
description: 'Capture mail flow, spam trends, and queues.',
icon: <Mail class="w-6 h-6" strokeWidth={2} />,
},
],
},
{
title: 'Agent deployments',
description: 'Install lightweight Pulse agents to report telemetry back to this hub.',
agents: [
{
id: 'docker',
label: 'Docker hosts',
description: 'Deploy the pulse-docker-agent for container telemetry.',
icon: <Container class="w-6 h-6" strokeWidth={2} />,
},
{
id: 'host',
label: 'Pulse host agent',
description: 'Install on Linux, macOS, or Windows servers.',
icon: <Monitor class="w-6 h-6" strokeWidth={2} />,
},
],
},
{
title: 'Coming soon',
description: 'Roadmap integrations that are currently in development.',
agents: [
{
id: 'podman',
label: 'Podman hosts (coming soon)',
description: 'Podman agent support is under active development.',
icon: <Boxes class="w-6 h-6" strokeWidth={2} />,
disabled: true,
},
{
id: 'kubernetes',
label: 'Kubernetes (coming soon)',
description: 'Native Kubernetes monitoring is on the roadmap.',
icon: <Network class="w-6 h-6" strokeWidth={2} />,
disabled: true,
},
],
},
];
const agentCards = agentGroups.flatMap((group) => group.agents);
const agentPaths: Record<AgentKey, string> = {
pve: '/settings/pve',
pbs: '/settings/pbs',
pmg: '/settings/pmg',
docker: '/settings/docker',
host: '/settings/host-agents',
podman: '/settings/podman',
kubernetes: '/settings/kubernetes',
} as Record<AgentKey, string>;
const handleSelectAgent = (agent: AgentKey) => {
setSelectedAgent(agent);
if (currentTab() !== 'agent-hub') {
setCurrentTab('agent-hub');
}
const target = agentPaths[agent];
if (target && location.pathname !== target) {
navigate(target, { scroll: false });
}
};
const setActiveTab = (tab: SettingsTab) => {
if (tab === 'agent-hub' && selectedAgent() === 'podman') {
setSelectedAgent('pve');
} else if (tab === 'agent-hub' && selectedAgent() === 'kubernetes') {
setSelectedAgent('pve');
}
if (tab === 'agent-hub' && !['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) {
setSelectedAgent('pve');
}
if (tab === 'podman') {
setSelectedAgent('podman');
} else if (tab === 'kubernetes') {
setSelectedAgent('kubernetes');
}
const targetPath = `/settings/${tab}`;
if (location.pathname !== targetPath) {
navigate(targetPath, { scroll: false });
@ -411,9 +513,10 @@ const Settings: Component<SettingsProps> = (props) => {
() => location.pathname,
(path) => {
if (path === '/settings' || path === '/settings/') {
if (currentTab() !== 'pve') {
setCurrentTab('pve');
if (currentTab() !== 'agent-hub') {
setCurrentTab('agent-hub');
}
setSelectedAgent('pve');
return;
}
@ -421,6 +524,19 @@ const Settings: Component<SettingsProps> = (props) => {
if (resolved !== currentTab()) {
setCurrentTab(resolved);
}
if (resolved === 'agent-hub') {
const agentFromPath = deriveAgentFromPath(path);
if (agentFromPath) {
setSelectedAgent(agentFromPath);
} else if (!['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) {
setSelectedAgent('pve');
}
} else if (resolved === 'podman') {
setSelectedAgent('podman');
} else if (resolved === 'kubernetes') {
setSelectedAgent('kubernetes');
}
},
),
);
@ -776,15 +892,9 @@ const Settings: Component<SettingsProps> = (props) => {
id: 'platforms',
label: 'Platforms',
items: [
{ id: 'pve', label: 'Proxmox VE nodes', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pbs', label: 'Proxmox Backup Server', icon: <HardDrive class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pmg', label: 'Proxmox Mail Gateway', icon: <Mail class="w-4 h-4" strokeWidth={2} /> },
{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> },
{ id: 'agent-hub', label: 'Agent deployments', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'podman', label: 'Podman hosts', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'kubernetes', label: 'Kubernetes', icon: <Network class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'linuxServers', label: 'Linux servers', icon: <Terminal class="w-4 h-4" strokeWidth={2} /> },
{ id: 'windowsServers', label: 'Windows servers', icon: <Monitor class="w-4 h-4" strokeWidth={2} /> },
{ id: 'macServers', label: 'macOS devices', icon: <Laptop class="w-4 h-4" strokeWidth={2} /> },
],
},
{
@ -1854,7 +1964,7 @@ const Settings: Component<SettingsProps> = (props) => {
<Show
when={
hasUnsavedChanges() &&
(activeTab() === 'pve' || activeTab() === 'pbs' ||
(activeTab() === 'agent-hub' ||
activeTab() === 'system-general' || activeTab() === 'system-network' ||
activeTab() === 'system-updates' || activeTab() === 'system-backups')
}
@ -1995,8 +2105,66 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
<div class="p-3 sm:p-6 overflow-x-auto">
<Show when={activeTab() === 'agent-hub'}>
<Card padding="lg" class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Step 1 · Choose a platform
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Select the integration you want to deploy or manage.
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={agentCards}>
{(card) => {
const isActive = () => selectedAgent() === card.id;
return (
<button
type="button"
disabled={card.disabled}
class={`flex flex-col items-start gap-3 rounded-xl border transition-colors p-4 text-left shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
card.disabled
? 'cursor-not-allowed opacity-60 border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'
: isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
}`}
onClick={() => {
if (card.disabled) return;
handleSelectAgent(card.id);
}}
>
<div class="flex items-center gap-3">
<div
class={`rounded-md p-2 ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{card.icon}
</div>
<div class="flex-1">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{card.label}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{card.description}
</p>
</div>
</div>
</button>
);
}}
</For>
</div>
</Card>
</Show>
{/* PVE Nodes Tab */}
<Show when={activeTab() === 'pve'}>
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pve'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
@ -2511,20 +2679,13 @@ const Settings: Component<SettingsProps> = (props) => {
</For>
</div>
</Show>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<GuestURLs
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
</div>
</div>
</Show>
</div>
</Show>
{/* PBS Nodes Tab */}
<Show when={activeTab() === 'pbs'}>
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pbs'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
@ -2934,7 +3095,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
{/* PMG Nodes Tab */}
<Show when={activeTab() === 'pmg'}>
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pmg'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
@ -3334,7 +3495,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
{/* Docker Tab */}
<Show when={activeTab() === 'docker'}>
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'docker'}>
<DockerAgents />
</Show>
@ -3354,19 +3515,9 @@ const Settings: Component<SettingsProps> = (props) => {
/>
</Show>
{/* Linux Host Agents */}
<Show when={activeTab() === 'linuxServers'}>
<HostAgents variant="linux" />
</Show>
{/* Windows Host Agents */}
<Show when={activeTab() === 'windowsServers'}>
<HostAgents variant="windows" />
</Show>
{/* macOS Host Agents */}
<Show when={activeTab() === 'macServers'}>
<HostAgents variant="macos" />
{/* Host Agents */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'host'}>
<HostAgents />
</Show>
{/* System General Tab */}

View file

@ -0,0 +1,174 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { APITokenRecord, CreateAPITokenResponse } from '@/api/security';
import { SecurityAPI } from '@/api/security';
export interface UseScopedTokenManagerOptions {
scope: string | readonly string[];
storageKey: string;
legacyKeys?: readonly string[];
}
export interface ScopedTokenManager {
token: () => string | null;
setToken: (value: string | null) => void;
hasStoredToken: () => boolean;
availableTokens: () => APITokenRecord[];
loadingTokens: () => boolean;
tokensLoaded: () => boolean;
tokensError: () => boolean;
tokenAccessDenied: () => boolean;
loadTokens: () => Promise<void>;
retryLoadTokens: () => Promise<void>;
isGeneratingToken: () => boolean;
generateToken: (name: string) => Promise<CreateAPITokenResponse>;
}
const tokenMatchesScopes = (token: APITokenRecord, requiredScopes: string[]): boolean => {
const scopes = token.scopes ?? [];
if (scopes.length === 0) {
// Legacy tokens without scopes behave as wildcard.
return true;
}
if (scopes.includes('*')) {
return true;
}
return requiredScopes.every((scope) => scopes.includes(scope));
};
const readStoredToken = (primaryKey: string, legacyKeys: readonly string[] = []) => {
if (typeof window === 'undefined') {
return null;
}
const primary = window.localStorage.getItem(primaryKey);
if (primary) {
return primary;
}
for (const key of legacyKeys) {
const value = window.localStorage.getItem(key);
if (value) {
return value;
}
}
return null;
};
export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): ScopedTokenManager => {
const requiredScopes = Array.isArray(options.scope) ? [...options.scope] : [options.scope];
const { storageKey, legacyKeys = [] } = options;
const [token, setTokenState] = createSignal<string | null>(readStoredToken(storageKey, legacyKeys));
const [availableTokens, setAvailableTokens] = createSignal<APITokenRecord[]>([]);
const [loadingTokens, setLoadingTokens] = createSignal(false);
const [tokensLoaded, setTokensLoaded] = createSignal(false);
const [tokensError, setTokensError] = createSignal(false);
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(false);
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
createEffect(() => {
const current = token();
if (typeof window === 'undefined') {
return;
}
try {
if (current) {
window.localStorage.setItem(storageKey, current);
try {
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current }));
} catch (eventErr) {
console.debug('Unable to dispatch storage event for token update', eventErr);
}
} else {
window.localStorage.removeItem(storageKey);
try {
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null }));
} catch (eventErr) {
console.debug('Unable to dispatch storage event for token removal', eventErr);
}
}
} catch (err) {
console.warn('Unable to persist API token in localStorage', err);
}
});
if (typeof window !== 'undefined') {
const handleStorage = (event: StorageEvent) => {
if (!event.key) return;
if (event.key === storageKey || legacyKeys.includes(event.key)) {
const updated = readStoredToken(storageKey, legacyKeys);
setTokenState(updated);
}
};
window.addEventListener('storage', handleStorage);
onCleanup(() => window.removeEventListener('storage', handleStorage));
}
const loadTokens = async () => {
if (loadingTokens()) return;
setLoadingTokens(true);
setTokensError(false);
setTokensLoaded(false);
try {
const tokens = await SecurityAPI.listTokens();
const filtered = tokens.filter((candidate) => tokenMatchesScopes(candidate, requiredScopes));
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setAvailableTokens(filtered);
setTokenAccessDenied(false);
setTokensLoaded(true);
} catch (err) {
setTokensError(true);
if (err instanceof Error && /authentication required/i.test(err.message)) {
setTokenAccessDenied(true);
}
throw err;
} finally {
setLoadingTokens(false);
}
};
const retryLoadTokens = async () => {
setTokensLoaded(false);
await loadTokens();
};
const generateToken = async (name: string) => {
if (isGeneratingToken()) {
throw new Error('Token generation already in progress');
}
setIsGeneratingToken(true);
try {
const response = await SecurityAPI.createToken(name, requiredScopes);
const { token: newToken, record } = response;
setTokenState(newToken);
setAvailableTokens((current) => {
const filtered = current.filter((item) => item.id !== record.id);
return [record, ...filtered];
});
setTokenAccessDenied(false);
return response;
} catch (err) {
if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) {
setTokenAccessDenied(true);
}
throw err;
} finally {
setIsGeneratingToken(false);
}
};
const hasStoredToken = createMemo(() => Boolean(token()));
return {
token,
setToken: setTokenState,
hasStoredToken,
availableTokens,
loadingTokens,
tokensLoaded,
tokensError,
tokenAccessDenied,
loadTokens,
retryLoadTokens,
isGeneratingToken,
generateToken,
};
};