feat: unify agent deployments and scoped token flows
This commit is contained in:
parent
cee24ff7e0
commit
e565b292bf
7 changed files with 1017 additions and 995 deletions
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 securely—this 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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 securely—this 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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
174
frontend-modern/src/hooks/useScopedTokenManager.ts
Normal file
174
frontend-modern/src/hooks/useScopedTokenManager.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue