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
|
// Create a mapping from node ID to node object
|
||||||
const nodeByInstance = createMemo(() => {
|
const nodeByInstance = createMemo(() => {
|
||||||
const map: Record<string, Node> = {};
|
const map: Record<string, Node> = {};
|
||||||
|
|
@ -230,8 +241,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
const allGuests = createMemo(() => {
|
const allGuests = createMemo(() => {
|
||||||
const vms = props.vms || [];
|
const vms = props.vms || [];
|
||||||
const containers = props.containers || [];
|
const containers = props.containers || [];
|
||||||
const guests: (VM | Container)[] = [...vms, ...containers];
|
return [...vms, ...containers];
|
||||||
return guests;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter guests based on current settings
|
// Filter guests based on current settings
|
||||||
|
|
@ -782,33 +792,32 @@ export function Dashboard(props: DashboardProps) {
|
||||||
<NodeGroupHeader node={node!} colspan={11} />
|
<NodeGroupHeader node={node!} colspan={11} />
|
||||||
</Show>
|
</Show>
|
||||||
<For each={guests} fallback={<></>}>
|
<For each={guests} fallback={<></>}>
|
||||||
{(guest) => (
|
{(guest) => {
|
||||||
<ComponentErrorBoundary name="GuestRow">
|
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
|
||||||
{(() => {
|
const guestId =
|
||||||
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
|
guest.id ||
|
||||||
const guestId =
|
(guest.instance === guest.node
|
||||||
guest.id ||
|
? `${guest.node}-${guest.vmid}`
|
||||||
(guest.instance === guest.node
|
: `${guest.instance}-${guest.node}-${guest.vmid}`);
|
||||||
? `${guest.node}-${guest.vmid}`
|
const metadata =
|
||||||
: `${guest.instance}-${guest.node}-${guest.vmid}`);
|
guestMetadata()[guestId] ||
|
||||||
const metadata =
|
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
||||||
guestMetadata()[guestId] ||
|
const parentNode = node ?? resolveParentNode(guest);
|
||||||
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
||||||
const parentNode = node ?? resolveParentNode(guest);
|
return (
|
||||||
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
<ComponentErrorBoundary name="GuestRow">
|
||||||
return (
|
<GuestRow
|
||||||
<GuestRow
|
guest={guest}
|
||||||
guest={guest}
|
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
||||||
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
customUrl={metadata?.customUrl}
|
||||||
customUrl={metadata?.customUrl}
|
onTagClick={handleTagClick}
|
||||||
onTagClick={handleTagClick}
|
activeSearch={search()}
|
||||||
activeSearch={search()}
|
parentNodeOnline={parentNodeOnline}
|
||||||
parentNodeOnline={parentNodeOnline}
|
onCustomUrlUpdate={handleCustomUrlUpdate}
|
||||||
/>
|
/>
|
||||||
);
|
</ComponentErrorBoundary>
|
||||||
})()}
|
);
|
||||||
</ComponentErrorBoundary>
|
}}
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,18 @@ import { IOMetric } from './IOMetric';
|
||||||
import { TagBadges } from './TagBadges';
|
import { TagBadges } from './TagBadges';
|
||||||
import { DiskList } from './DiskList';
|
import { DiskList } from './DiskList';
|
||||||
import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status';
|
import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status';
|
||||||
|
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||||
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
|
|
||||||
type Guest = VM | Container;
|
type Guest = VM | Container;
|
||||||
|
|
||||||
const drawerState = new Map<string, boolean>();
|
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) => {
|
const buildGuestId = (guest: Guest) => {
|
||||||
if (guest.id) return guest.id;
|
if (guest.id) return guest.id;
|
||||||
|
|
@ -44,13 +52,21 @@ interface GuestRowProps {
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
activeSearch?: string;
|
activeSearch?: string;
|
||||||
parentNodeOnline?: boolean;
|
parentNodeOnline?: boolean;
|
||||||
|
onCustomUrlUpdate?: (guestId: string, url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GuestRow(props: GuestRowProps) {
|
export function GuestRow(props: GuestRowProps) {
|
||||||
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
|
||||||
const initialGuestId = buildGuestId(props.guest);
|
const initialGuestId = buildGuestId(props.guest);
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false);
|
|
||||||
const guestId = createMemo(() => buildGuestId(props.guest));
|
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 hasFilesystemDetails = createMemo(() => (props.guest.disks?.length ?? 0) > 0);
|
||||||
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
|
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 hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
|
||||||
const hasAgentInfo = createMemo(() => agentVersion().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(() => {
|
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 cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
|
||||||
const memPercent = createMemo(() => {
|
const memPercent = createMemo(() => {
|
||||||
if (!props.guest.memory) return 0;
|
if (!props.guest.memory) return 0;
|
||||||
|
|
@ -135,11 +155,80 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const toggleDrawer = (event: MouseEvent) => {
|
const toggleDrawer = (event: MouseEvent) => {
|
||||||
if (!canShowDrawer()) return;
|
if (!canShowDrawer()) return;
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('a, button, [data-prevent-toggle]')) {
|
if (target.closest('a, button, input, [data-prevent-toggle]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDrawerOpen((prev) => !prev);
|
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(() => {
|
const diskPercent = createMemo(() => {
|
||||||
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
|
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
|
||||||
// Check if usage is -1 (unknown/no guest agent)
|
// Check if usage is -1 (unknown/no guest agent)
|
||||||
|
|
@ -246,28 +335,92 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
{/* Name - Sticky column */}
|
{/* Name - Sticky column */}
|
||||||
<td class={firstCellClass()}>
|
<td class={firstCellClass()}>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<Show
|
||||||
when={customUrl()}
|
when={isEditingUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<span
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"
|
<span
|
||||||
title={props.guest.name}
|
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
|
||||||
>
|
style="cursor: text;"
|
||||||
{props.guest.name}
|
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
</span>
|
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
|
<div class="flex-1 flex items-center gap-1">
|
||||||
href={customUrl()}
|
<input
|
||||||
target="_blank"
|
ref={urlInputRef}
|
||||||
rel="noopener noreferrer"
|
type="text"
|
||||||
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"
|
value={editingUrlValue()}
|
||||||
title={`${props.guest.name} - Click to open custom URL`}
|
data-guest-id={guestId()}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onInput={(e) => {
|
||||||
>
|
editingValues.set(guestId(), e.currentTarget.value);
|
||||||
{props.guest.name}
|
setEditingValuesVersion(v => v + 1);
|
||||||
</a>
|
}}
|
||||||
|
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>
|
</Show>
|
||||||
|
|
||||||
{/* Tag badges */}
|
{/* 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 { useWebSocket } from '@/App';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
|
|
@ -6,19 +6,17 @@ import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
|
||||||
import { MonitoringAPI } from '@/api/monitoring';
|
import { MonitoringAPI } from '@/api/monitoring';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import type { SecurityStatus } from '@/types/config';
|
import type { SecurityStatus } from '@/types/config';
|
||||||
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
|
||||||
import type { DockerHost } from '@/types/api';
|
import type { DockerHost } from '@/types/api';
|
||||||
|
import type { APITokenRecord } from '@/api/security';
|
||||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||||
|
import { DOCKER_MANAGE_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
|
||||||
|
import { useScopedTokenManager } from '@/hooks/useScopedTokenManager';
|
||||||
|
|
||||||
export const DockerAgents: Component = () => {
|
export const DockerAgents: Component = () => {
|
||||||
const { state } = useWebSocket();
|
const { state } = useWebSocket();
|
||||||
const [showInstructions, setShowInstructions] = createSignal(true);
|
const [showInstructions, setShowInstructions] = createSignal(true);
|
||||||
|
|
||||||
let hasLoggedSecurityStatusError = false;
|
let hasLoggedSecurityStatusError = false;
|
||||||
let hasLoggedTokenAuthWarning = false;
|
|
||||||
let hasNotifiedTokenLoadError = false;
|
|
||||||
let hasNotifiedTokenAuthFailure = false;
|
|
||||||
let previousTokenStrategy: 'existing' | 'generate' | null = null;
|
|
||||||
|
|
||||||
const [showHidden, setShowHidden] = createSignal(false);
|
const [showHidden, setShowHidden] = createSignal(false);
|
||||||
|
|
||||||
|
|
@ -42,17 +40,24 @@ export const DockerAgents: Component = () => {
|
||||||
const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false);
|
const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false);
|
||||||
const [removeActionLoading, setRemoveActionLoading] = createSignal<'queue' | 'force' | 'hide' | null>(null);
|
const [removeActionLoading, setRemoveActionLoading] = createSignal<'queue' | 'force' | 'hide' | null>(null);
|
||||||
const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false);
|
const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false);
|
||||||
const [apiToken, setApiToken] = createSignal<string | null>(null);
|
|
||||||
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | 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 [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false);
|
||||||
const [newTokenName, setNewTokenName] = createSignal('');
|
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 = () => {
|
const pulseUrl = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -145,21 +150,6 @@ const modalCommandProgress = createMemo(() => {
|
||||||
return;
|
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 () => {
|
const fetchSecurityStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/security/status', { credentials: 'include' });
|
const response = await fetch('/api/security/status', { credentials: 'include' });
|
||||||
|
|
@ -177,89 +167,9 @@ const modalCommandProgress = createMemo(() => {
|
||||||
fetchSecurityStatus();
|
fetchSecurityStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadTokens = async () => {
|
const commandReady = () => !requiresToken() || Boolean(apiToken());
|
||||||
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);
|
|
||||||
|
|
||||||
// Find the token record that matches the currently stored token
|
// 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> => {
|
const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
|
@ -291,79 +201,34 @@ const modalCommandProgress = createMemo(() => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectExistingToken = () => {
|
const openGenerateTokenModal = () => {
|
||||||
if (!hasStoredToken()) {
|
setGenerateError(null);
|
||||||
if (typeof window !== 'undefined' && window.showToast) {
|
const defaultName = `Docker host ${new Date().toISOString().slice(0, 10)}`;
|
||||||
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');
|
|
||||||
setNewTokenName(defaultName);
|
setNewTokenName(defaultName);
|
||||||
setShowGenerateTokenModal(true);
|
setShowGenerateTokenModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateToken = async () => {
|
const handleCreateToken = async () => {
|
||||||
if (isGeneratingToken()) return;
|
if (isGeneratingToken()) return;
|
||||||
if (!canGenerateToken()) {
|
setGenerateError(null);
|
||||||
notificationStore.error('You need administrator access to create API tokens from here.', 6000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeneratingToken(true);
|
|
||||||
try {
|
try {
|
||||||
const currentTokens = availableTokens();
|
const desiredName = newTokenName().trim() || `Docker host ${new Date().toISOString().slice(0, 10)}`;
|
||||||
const defaultName = `Docker host ${currentTokens.length + 1}`;
|
const { token, record } = await generateToken(desiredName);
|
||||||
const desiredName = newTokenName().trim() || defaultName;
|
|
||||||
const { token, record } = await SecurityAPI.createToken(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);
|
setShowGenerateTokenModal(false);
|
||||||
setNewTokenName('');
|
setNewTokenName('');
|
||||||
|
setLatestRecord(record);
|
||||||
showTokenReveal({
|
showTokenReveal({
|
||||||
token,
|
token,
|
||||||
record,
|
record,
|
||||||
source: 'docker',
|
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') {
|
notificationStore.success('New Docker reporting token generated and added to the install command.', 6000);
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to generate API token', 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);
|
notificationStore.error('Failed to generate API token', 6000);
|
||||||
} finally {
|
|
||||||
setIsGeneratingToken(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -573,156 +438,47 @@ WantedBy=multi-user.target`;
|
||||||
<Show when={requiresToken()}>
|
<Show when={requiresToken()}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-1">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={loadingTokens()}>
|
<Show when={generateError()}>
|
||||||
<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">
|
<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">
|
||||||
Loading API tokens…
|
{generateError()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={tokensError()}>
|
<Show when={latestRecord()}>
|
||||||
<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">
|
<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">
|
||||||
<p>
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
{tokenAccessDenied()
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
? '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" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex-1 text-sm text-gray-700 dark:text-gray-300">
|
<span>
|
||||||
<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{' '}
|
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.
|
||||||
<a href="/settings/security" class="text-blue-600 dark:text-blue-400 underline hover:no-underline font-medium">
|
</span>
|
||||||
Security Settings
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
<Show when={!commandReady()}>
|
<button
|
||||||
<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">
|
type="button"
|
||||||
Pick an option above to unlock the install command.
|
onClick={openGenerateTokenModal}
|
||||||
</div>
|
disabled={isGeneratingToken()}
|
||||||
</Show>
|
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"
|
||||||
<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">
|
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
|
||||||
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.
|
</button>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={commandReady()}>
|
<Show when={commandReady()}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|
@ -865,8 +621,7 @@ WantedBy=multi-user.target`;
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowGenerateTokenModal(false);
|
setShowGenerateTokenModal(false);
|
||||||
setNewTokenName('');
|
setNewTokenName('');
|
||||||
setSelectedTokenStrategy(previousTokenStrategy);
|
setGenerateError(null);
|
||||||
previousTokenStrategy = 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"
|
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 type { JSX } from 'solid-js';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import type { Host } from '@/types/api';
|
import type { Host } from '@/types/api';
|
||||||
|
|
@ -6,10 +6,12 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import CopyButton from '@/components/shared/CopyButton';
|
import CopyButton from '@/components/shared/CopyButton';
|
||||||
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||||
import { SecurityAPI } from '@/api/security';
|
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||||
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
|
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';
|
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows';
|
||||||
|
|
||||||
|
|
@ -17,10 +19,29 @@ interface HostAgentsProps {
|
||||||
variant?: HostAgentVariant;
|
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 RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download';
|
||||||
|
|
||||||
const TOKEN_PLACEHOLDER = '<api-token>';
|
const TOKEN_PLACEHOLDER = '<api-token>';
|
||||||
|
|
||||||
const pulseUrl = () => {
|
const pulseUrl = () => {
|
||||||
if (typeof window === 'undefined') return 'http://localhost:7655';
|
if (typeof window === 'undefined') return 'http://localhost:7655';
|
||||||
const { protocol, hostname, port } = window.location;
|
const { protocol, hostname, port } = window.location;
|
||||||
|
|
@ -138,9 +159,26 @@ const platformFilters: Record<HostAgentVariant, string[] | null> = {
|
||||||
export const HostAgents: Component<HostAgentsProps> = (props) => {
|
export const HostAgents: Component<HostAgentsProps> = (props) => {
|
||||||
const variant: HostAgentVariant = props.variant ?? 'all';
|
const variant: HostAgentVariant = props.variant ?? 'all';
|
||||||
const { state } = useWebSocket();
|
const { state } = useWebSocket();
|
||||||
const [apiToken, setApiToken] = createSignal('');
|
|
||||||
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
|
let hasLoggedSecurityStatusError = false;
|
||||||
const [tokenAccessDenied, setTokenAccessDenied] = createSignal(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 hosts = createMemo(() => {
|
||||||
const list = state.hosts ?? [];
|
const list = state.hosts ?? [];
|
||||||
|
|
@ -155,68 +193,121 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
|
||||||
return tags.join(', ');
|
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(() => {
|
onMount(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') {
|
||||||
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);
|
|
||||||
return;
|
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 {
|
try {
|
||||||
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
|
const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`;
|
||||||
const { token, record } = await SecurityAPI.createToken(defaultName, [HOST_AGENT_SCOPE]);
|
const { token, record } = await generateToken(desiredName);
|
||||||
setApiToken(token);
|
|
||||||
|
setShowGenerateTokenModal(false);
|
||||||
|
setNewTokenName('');
|
||||||
|
setLatestRecord(record);
|
||||||
showTokenReveal({
|
showTokenReveal({
|
||||||
token,
|
token,
|
||||||
record,
|
record,
|
||||||
source: 'host-agent',
|
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);
|
notificationStore.success('Created host agent API token with reporting scope.', 6000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create host agent token', err);
|
console.error('Failed to generate host agent token', err);
|
||||||
if (err instanceof Error && /authentication required|forbidden/i.test(err.message)) {
|
setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.');
|
||||||
setTokenAccessDenied(true);
|
notificationStore.error('Failed to generate API token', 6000);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const cardTitle = () => {
|
||||||
switch (variant) {
|
if (variant === 'all') {
|
||||||
|
return 'Pulse host agent';
|
||||||
|
}
|
||||||
|
switch (effectiveVariant()) {
|
||||||
case 'linux':
|
case 'linux':
|
||||||
return 'Linux servers';
|
return 'Linux servers';
|
||||||
case 'macos':
|
case 'macos':
|
||||||
|
|
@ -229,13 +320,17 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardDescription = () => {
|
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':
|
case 'linux':
|
||||||
return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.';
|
return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.';
|
||||||
case 'macos':
|
case 'macos':
|
||||||
return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.';
|
return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.';
|
||||||
case 'windows':
|
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:
|
default:
|
||||||
return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.';
|
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">
|
<div class="space-y-6">
|
||||||
<SectionHeader title={cardTitle()} description={cardDescription()} />
|
<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">
|
<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>
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta().title}</h3>
|
||||||
Manage tokens via <strong>Settings → Security</strong>
|
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta().description}</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={generateToken}
|
onClick={() => setShowInstructions(!showInstructions())}
|
||||||
disabled={isGeneratingToken()}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
|
{showInstructions() ? 'Hide' : 'Show'} instructions
|
||||||
</button>
|
</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>
|
</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>
|
<Show when={showInstructions()}>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta.description}</p>
|
<div class="space-y-5">
|
||||||
|
<Show when={variant === 'all'}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<For each={installMeta.snippets}>
|
<div class="space-y-1">
|
||||||
{(snippet) => (
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1 · Choose the operating system</p>
|
||||||
<div class="space-y-2">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div class="flex items-center justify-between gap-3">
|
Pick the platform you are onboarding. The install commands adapt automatically.
|
||||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h4>
|
</p>
|
||||||
<CopyButton
|
</div>
|
||||||
text={snippet.command.replace(
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
TOKEN_PLACEHOLDER,
|
<For each={hostPlatformOptions}>
|
||||||
apiToken() || TOKEN_PLACEHOLDER,
|
{(option) => {
|
||||||
)}
|
const isActive = () => selectedPlatform() === option.id;
|
||||||
>
|
return (
|
||||||
Copy command
|
<button
|
||||||
</CopyButton>
|
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>
|
</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>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
|
||||||
</div>
|
<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>
|
</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">
|
<Card padding="lg" class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
|
<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 { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { NodeModal } from './NodeModal';
|
import { NodeModal } from './NodeModal';
|
||||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||||
import { GuestURLs } from './GuestURLs';
|
|
||||||
import { DockerAgents } from './DockerAgents';
|
import { DockerAgents } from './DockerAgents';
|
||||||
import { HostAgents } from './HostAgents';
|
import { HostAgents } from './HostAgents';
|
||||||
import APITokenManager from './APITokenManager';
|
import APITokenManager from './APITokenManager';
|
||||||
|
|
@ -32,9 +31,7 @@ import Activity from 'lucide-solid/icons/activity';
|
||||||
import Loader from 'lucide-solid/icons/loader';
|
import Loader from 'lucide-solid/icons/loader';
|
||||||
import Boxes from 'lucide-solid/icons/boxes';
|
import Boxes from 'lucide-solid/icons/boxes';
|
||||||
import Network from 'lucide-solid/icons/network';
|
import Network from 'lucide-solid/icons/network';
|
||||||
import Terminal from 'lucide-solid/icons/terminal';
|
|
||||||
import Monitor from 'lucide-solid/icons/monitor';
|
import Monitor from 'lucide-solid/icons/monitor';
|
||||||
import Laptop from 'lucide-solid/icons/laptop';
|
|
||||||
import Sliders from 'lucide-solid/icons/sliders-horizontal';
|
import Sliders from 'lucide-solid/icons/sliders-horizontal';
|
||||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||||
import Clock from 'lucide-solid/icons/clock';
|
import Clock from 'lucide-solid/icons/clock';
|
||||||
|
|
@ -228,15 +225,9 @@ interface DiscoveryScanStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| 'pve'
|
| 'agent-hub'
|
||||||
| 'pbs'
|
|
||||||
| 'pmg'
|
|
||||||
| 'docker'
|
|
||||||
| 'podman'
|
| 'podman'
|
||||||
| 'kubernetes'
|
| 'kubernetes'
|
||||||
| 'linuxServers'
|
|
||||||
| 'windowsServers'
|
|
||||||
| 'macServers'
|
|
||||||
| 'system-general'
|
| 'system-general'
|
||||||
| 'system-network'
|
| 'system-network'
|
||||||
| 'system-updates'
|
| 'system-updates'
|
||||||
|
|
@ -248,22 +239,12 @@ type SettingsTab =
|
||||||
| 'diagnostics'
|
| 'diagnostics'
|
||||||
| 'updates';
|
| 'updates';
|
||||||
|
|
||||||
|
type AgentKey = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host' | 'podman' | 'kubernetes';
|
||||||
|
|
||||||
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
|
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
|
||||||
pve: {
|
'agent-hub': {
|
||||||
title: 'Proxmox VE',
|
title: 'Agent Deployments',
|
||||||
description: 'Connect clusters, manage nodes, and control discovery for Proxmox VE.',
|
description: 'Select a platform to generate tokens, deploy agents, or add Proxmox endpoints.',
|
||||||
},
|
|
||||||
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.',
|
|
||||||
},
|
},
|
||||||
podman: {
|
podman: {
|
||||||
title: 'Podman (container runtime)',
|
title: 'Podman (container runtime)',
|
||||||
|
|
@ -273,18 +254,6 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
|
||||||
title: 'Kubernetes',
|
title: 'Kubernetes',
|
||||||
description: 'Cluster-wide monitoring via Pulse is coming soon. Watch this space for Helm charts and operators.',
|
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': {
|
'system-general': {
|
||||||
title: 'General Settings',
|
title: 'General Settings',
|
||||||
description: 'Configure appearance preferences and UI behavior.',
|
description: 'Configure appearance preferences and UI behavior.',
|
||||||
|
|
@ -357,35 +326,168 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const deriveTabFromPath = (path: string): SettingsTab => {
|
const deriveTabFromPath = (path: string): SettingsTab => {
|
||||||
if (path.includes('/settings/pbs')) return 'pbs';
|
if (path.includes('/settings/agent-hub')) return 'agent-hub';
|
||||||
if (path.includes('/settings/pmg')) return 'pmg';
|
|
||||||
if (path.includes('/settings/docker')) return 'docker';
|
|
||||||
if (path.includes('/settings/podman')) return 'podman';
|
if (path.includes('/settings/podman')) return 'podman';
|
||||||
if (path.includes('/settings/kubernetes')) return 'kubernetes';
|
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-general')) return 'system-general';
|
||||||
if (path.includes('/settings/system-network')) return 'system-network';
|
if (path.includes('/settings/system-network')) return 'system-network';
|
||||||
if (path.includes('/settings/system-updates')) return 'system-updates';
|
if (path.includes('/settings/system-updates')) return 'system-updates';
|
||||||
if (path.includes('/settings/system-backups')) return 'system-backups';
|
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/system')) return 'system-general';
|
||||||
if (path.includes('/settings/api')) return 'api';
|
if (path.includes('/settings/api')) return 'api';
|
||||||
if (path.includes('/settings/security-overview')) return 'security-overview';
|
if (path.includes('/settings/security-overview')) return 'security-overview';
|
||||||
if (path.includes('/settings/security-auth')) return 'security-auth';
|
if (path.includes('/settings/security-auth')) return 'security-auth';
|
||||||
if (path.includes('/settings/security-sso')) return 'security-sso';
|
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/security')) return 'security-overview';
|
||||||
if (path.includes('/settings/diagnostics')) return 'diagnostics';
|
if (path.includes('/settings/diagnostics')) return 'diagnostics';
|
||||||
if (path.includes('/settings/updates')) return 'updates';
|
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 [currentTab, setCurrentTab] = createSignal<SettingsTab>(deriveTabFromPath(location.pathname));
|
||||||
const activeTab = () => currentTab();
|
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) => {
|
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}`;
|
const targetPath = `/settings/${tab}`;
|
||||||
if (location.pathname !== targetPath) {
|
if (location.pathname !== targetPath) {
|
||||||
navigate(targetPath, { scroll: false });
|
navigate(targetPath, { scroll: false });
|
||||||
|
|
@ -411,9 +513,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
() => location.pathname,
|
() => location.pathname,
|
||||||
(path) => {
|
(path) => {
|
||||||
if (path === '/settings' || path === '/settings/') {
|
if (path === '/settings' || path === '/settings/') {
|
||||||
if (currentTab() !== 'pve') {
|
if (currentTab() !== 'agent-hub') {
|
||||||
setCurrentTab('pve');
|
setCurrentTab('agent-hub');
|
||||||
}
|
}
|
||||||
|
setSelectedAgent('pve');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -421,6 +524,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
if (resolved !== currentTab()) {
|
if (resolved !== currentTab()) {
|
||||||
setCurrentTab(resolved);
|
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',
|
id: 'platforms',
|
||||||
label: 'Platforms',
|
label: 'Platforms',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'pve', label: 'Proxmox VE nodes', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
|
{ id: 'agent-hub', label: 'Agent deployments', 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: 'podman', label: 'Podman hosts', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
|
{ 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: '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
|
<Show
|
||||||
when={
|
when={
|
||||||
hasUnsavedChanges() &&
|
hasUnsavedChanges() &&
|
||||||
(activeTab() === 'pve' || activeTab() === 'pbs' ||
|
(activeTab() === 'agent-hub' ||
|
||||||
activeTab() === 'system-general' || activeTab() === 'system-network' ||
|
activeTab() === 'system-general' || activeTab() === 'system-network' ||
|
||||||
activeTab() === 'system-updates' || activeTab() === 'system-backups')
|
activeTab() === 'system-updates' || activeTab() === 'system-backups')
|
||||||
}
|
}
|
||||||
|
|
@ -1995,8 +2105,66 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="p-3 sm:p-6 overflow-x-auto">
|
<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 */}
|
{/* PVE Nodes Tab */}
|
||||||
<Show when={activeTab() === 'pve'}>
|
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pve'}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Show when={!initialLoadComplete()}>
|
<Show when={!initialLoadComplete()}>
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-8">
|
||||||
|
|
@ -2511,20 +2679,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<GuestURLs
|
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
|
||||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* PBS Nodes Tab */}
|
{/* PBS Nodes Tab */}
|
||||||
<Show when={activeTab() === 'pbs'}>
|
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pbs'}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Show when={!initialLoadComplete()}>
|
<Show when={!initialLoadComplete()}>
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-8">
|
||||||
|
|
@ -2934,7 +3095,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* PMG Nodes Tab */}
|
{/* PMG Nodes Tab */}
|
||||||
<Show when={activeTab() === 'pmg'}>
|
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pmg'}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Show when={!initialLoadComplete()}>
|
<Show when={!initialLoadComplete()}>
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-8">
|
||||||
|
|
@ -3334,7 +3495,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Docker Tab */}
|
{/* Docker Tab */}
|
||||||
<Show when={activeTab() === 'docker'}>
|
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'docker'}>
|
||||||
<DockerAgents />
|
<DockerAgents />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -3354,19 +3515,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Linux Host Agents */}
|
{/* Host Agents */}
|
||||||
<Show when={activeTab() === 'linuxServers'}>
|
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'host'}>
|
||||||
<HostAgents variant="linux" />
|
<HostAgents />
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Windows Host Agents */}
|
|
||||||
<Show when={activeTab() === 'windowsServers'}>
|
|
||||||
<HostAgents variant="windows" />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* macOS Host Agents */}
|
|
||||||
<Show when={activeTab() === 'macServers'}>
|
|
||||||
<HostAgents variant="macos" />
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* System General Tab */}
|
{/* 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