feat: reveal agent install tokens separately

Open the existing token reveal dialog after manual agent-token creation and provide a responsive Show token only action so Docker and Compose users can copy PULSE_TOKEN without extracting it from a command.

Also normalize two pre-existing frontend formatting drifts required by the full formatting gate.

Refs #1775

Contract-Neutral: Token-only reveal reuses the existing security token result and dialog without changing API, persistence, or install command contracts.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-27 00:03:34 +01:00
parent ee35d178b5
commit 81963425fe
7 changed files with 119 additions and 50 deletions
+15 -24
View File
@@ -1,48 +1,39 @@
{
"version": 1,
"base_sha": "9ddfc69ec71d8db6b313522f54c2f68db88ec914",
"verified_at": "2026-08-26T22:30:00Z",
"base_sha": "ee35d178b5c7dad09877c20bf20acd2aba8f0b51",
"verified_at": "2026-08-26T22:53:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/diskListModel.ts",
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
"frontend-modern/src/components/shared/ProgressBar.tsx",
"frontend-modern/src/components/shared/detailSectionModel.ts"
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "21bf8fd35d7c79f9bceade2addf8a952a10dfac724a90d3f892769d1d3dbdde1",
"frontend-modern/src/components/Workloads/diskListModel.ts": "7e446cbc5b6b87abb12af69e4dc5a78ae26258ffbbc8f5f70235924d3d535084",
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "eff0236a3d524a041aefe18fa021cc619f61337fb9b80e74bbea07789a90e69e",
"frontend-modern/src/components/shared/ProgressBar.tsx": "8fda21a7dd22a1c00557686604dc07d7b69011d690a9618cf480eaf939e61103",
"frontend-modern/src/components/shared/detailSectionModel.ts": "42a6f8e70a03303d366efe8f8d490b1bf1bc8bfd6338d0510db35890d19ddeec"
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "b323df464d7d42fea5b38f553afce74cf3d56c4459199f95a45fc4d48ef17084",
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx": "e7fbdac65fe0ec225bdf898eac4bad112cdbd490322d37861677faf4be6ae74e"
},
"routes": [
"/proxmox/overview"
"/settings/infrastructure?add=docker"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 768,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"multi-filesystem Proxmox guest expanded to its Overview tab on desktop",
"same expanded filesystem state in the narrow table layout at tablet width",
"same expanded filesystem state at phone width with wrapped detail text"
"Docker agent installer immediately after manually generating an install token",
"token-only reveal reopened from the generated-token confirmation",
"token-only reveal at phone width"
],
"interactions": [
"expanded checkout-web-373 and confirmed both known filesystems retain percentage, used/total, and type text",
"confirmed each filesystem exposes an accessible progress value matching its rendered fill",
"resized through 1440x900, 768x900, and 390x844 and confirmed bar alignment, readable wrapping, and zero document-level horizontal overflow",
"reloaded the rebased current build and reconfirmed matching progress values plus zero document-level horizontal overflow at desktop and phone widths"
"generated a fresh Docker agent install token and confirmed the token-only dialog opened automatically",
"confirmed the dialog exposes the token value and Copy token action without an install command",
"dismissed the token dialog and reopened it with Show token only without minting another credential",
"confirmed the token wraps, the copy action spans the mobile card, and the page has no horizontal overflow at 390x844",
"confirmed the built frontend produced no browser console errors during the verified flows"
]
}
@@ -380,29 +380,39 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
</div>
<Show when={state.latestRecord()}>
<div class="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex flex-col gap-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<svg
class="h-4 w-4 shrink-0"
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>
{state.latestTokenSource() === 'setup_handoff' ? (
<>
First-host install token <strong>{state.latestRecord()?.name}</strong>{' '}
prepared automatically. Commands below already include this credential.
</>
) : (
<>
Install token <strong>{state.latestRecord()?.name}</strong> created. Commands
below now include this credential.
</>
)}
</span>
</div>
<Button
variant="secondary"
size="sm"
class="shrink-0 self-start sm:self-auto"
onClick={state.showCurrentTokenOnly}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
{state.latestTokenSource() === 'setup_handoff' ? (
<>
First-host install token <strong>{state.latestRecord()?.name}</strong> prepared
automatically. Commands below already include this credential.
</>
) : (
<>
Install token <strong>{state.latestRecord()?.name}</strong> created. Commands
below now include this credential.
</>
)}
</span>
Show token only
</Button>
</div>
</Show>
</div>
@@ -261,6 +261,7 @@ describe('infrastructure operations model', () => {
);
expect(infrastructureInstallerSectionSource).toContain('Generate install token');
expect(infrastructureInstallerSectionSource).toContain('Generate token');
expect(infrastructureInstallerSectionSource).toContain('Show token only');
expect(infrastructureInstallerSectionSource).toContain(
'This is the Pulse Agent handoff from first-run setup inside Add infrastructure.',
);
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
notificationSuccess: vi.fn(),
notificationError: vi.fn(),
notificationInfo: vi.fn(),
showTokenReveal: vi.fn(),
loggerError: vi.fn(),
loggerWarn: vi.fn(),
navigate: vi.fn(),
@@ -69,6 +70,10 @@ vi.mock('@/stores/notifications', () => ({
},
}));
vi.mock('@/stores/tokenReveal', () => ({
showTokenReveal: mocks.showTokenReveal,
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: mocks.loggerError,
@@ -194,6 +199,30 @@ describe('useInfrastructureOperationsState command-building closures', () => {
const cmd = state.getUninstallCommand();
expect(cmd).toContain("--token 'tok-1'");
expect(cmd).not.toContain('<api-token>');
expect(mocks.showTokenReveal).toHaveBeenCalledWith({
token: 'tok-1',
record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }),
source: 'agent',
note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'),
});
dispose();
});
it('reopens the token-only reveal without minting another credential', async () => {
const { state, dispose } = mountHook();
await flushAsync();
await state.handleGenerateToken();
mocks.showTokenReveal.mockClear();
state.showCurrentTokenOnly();
expect(mocks.createHostAgentInstallToken).toHaveBeenCalledTimes(1);
expect(mocks.showTokenReveal).toHaveBeenCalledWith({
token: 'tok-1',
record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }),
source: 'agent',
note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'),
});
dispose();
});
@@ -4,6 +4,7 @@ import { MonitoringAPI } from '@/api/monitoring';
import { NodesAPI } from '@/api/nodes';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import type { AgentLookupResponse, ConnectedInfrastructureItem } from '@/types/api';
import type { SecurityStatus } from '@/types/config';
import { copyToClipboard } from '@/utils/clipboard';
@@ -45,6 +46,8 @@ const SETUP_HANDOFF_INSTALL_STEPS = new Set<InfrastructurePanelStep>([
'docker',
'kubernetes',
]);
const INSTALL_TOKEN_REVEAL_NOTE =
'Copy just this token for PULSE_TOKEN or Compose environment configuration. The generated install commands remain available after you close this dialog.';
const isActiveInfrastructureItem = (item: ConnectedInfrastructureItem) => item.status === 'active';
@@ -245,6 +248,19 @@ Pulse prepares the first-host install token from setup so you can move straight
notificationStore.success('Confirmed install commands without an API token.', 3500);
};
const showCurrentTokenOnly = () => {
const token = currentToken();
const record = latestRecord();
if (!token || !record) return;
showTokenReveal({
token,
record,
source: 'agent',
note: INSTALL_TOKEN_REVEAL_NOTE,
});
};
const generateInstallToken = async (
source: 'manual' | 'setup_handoff',
options: { notifySuccess?: boolean } = {},
@@ -271,6 +287,14 @@ Pulse prepares the first-host install token from setup so you can move straight
setTokenName('');
setConfirmedNoToken(false);
setSetupHandoffAutoTokenFailed(false);
if (source === 'manual') {
showTokenReveal({
token,
record,
source: 'agent',
note: INSTALL_TOKEN_REVEAL_NOTE,
});
}
if (options.notifySuccess) {
notificationStore.success(
withCommands
@@ -606,6 +630,7 @@ Pulse prepares the first-host install token from setup so you can move straight
setupHandoff,
setupHandoffAutoTokenFailed,
setupHandoffAutoTokenPending,
showCurrentTokenOnly,
tokenName,
};
};
@@ -42,8 +42,16 @@ export const mergeDeliveryLogRows = (
heldEvents: AlertEvent[],
): DeliveryLogRow[] => {
const rows: DeliveryLogRow[] = [
...entries.map((entry): DeliveryLogRow => ({ kind: 'attempt', timestamp: entry.timestamp, entry })),
...heldEvents.map((event): DeliveryLogRow => ({ kind: 'held', timestamp: event.occurredAt, event })),
...entries.map((entry): DeliveryLogRow => ({
kind: 'attempt',
timestamp: entry.timestamp,
entry,
})),
...heldEvents.map((event): DeliveryLogRow => ({
kind: 'held',
timestamp: event.occurredAt,
event,
})),
];
return rows.sort((a, b) => {
const at = new Date(a.timestamp).getTime() || 0;
@@ -43,8 +43,13 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryNeedsAttention,
loadDeliveryHealth,
} = useNotificationDeliveryHealth();
const { deliveryLog, deliveryLogUnavailable, refreshingDeliveryLog, heldEvents, loadDeliveryLog } =
useNotificationDeliveryLog();
const {
deliveryLog,
deliveryLogUnavailable,
refreshingDeliveryLog,
heldEvents,
loadDeliveryLog,
} = useNotificationDeliveryLog();
const webhookState = useAlertWebhookDestinationsState();
const isLoading = createMemo(