diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f31c2cf832..001c3e4893 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -111,6 +111,13 @@ shared `/api/resources` payload carries a backend-authored Proxmox workload target linked to that node agent. Suppressing an inside-guest install cue for that case means governed actions have a node-agent path; it must not be presented as proof that a guest-local agent is installed. +Candidate-sourced PVE/PBS/PMG onboarding remains lifecycle-owned even when the +candidate starts in Discovery or a platform probe: the settings connection flow +may preview the monitored-system impact and credential path, but setup +commands, downloaded scripts, manual token command copies, and manual saves +must stay blocked until the operator explicitly approves the candidate import +plan. Type-first manual adds without a candidate must stay on the normal +credential-entry path and must not synthesize a discovery import plan. Agent-facing operations-loop status wiring in `internal/api/router.go` and `internal/api/agent_resource_context.go` is lifecycle-adjacent only because it shares agent route infrastructure. Other handlers in `internal/api/` such as @@ -3173,6 +3180,16 @@ That same node setup modal owner must also stay on the direct `NodeModalStatusFooter.tsx`, `nodeModalModel.ts`, and `useNodeModalState.ts`, rather than relying only on broad lane ownership or downstream command tests to catch lifecycle drift in the settings surface. +Candidate-sourced PVE/PBS/PMG node setup is part of that same node setup +owner. When a connection originates from Discovery or the API platform probe, +`InfrastructureWorkspace.tsx` must pass the candidate into +`NodeCredentialSlot.tsx`, `infrastructureImportPlanModel.ts` must derive the +endpoint, identity, credential path, collection scope, monitored-system preview +request, and reset signature, and the setup surface must require explicit +operator approval before any assisted setup command, downloaded setup script, +manual token command copy, or manual save can proceed. Direct type-first adds +with no candidate remain normal credential entry and must not fabricate an +import plan. That same Proxmox lifecycle transport now explicitly includes the shared `frontend-modern/src/api/nodes.ts` client boundary itself: changes to setup command or install-command request transport must carry both lifecycle proof diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7c726e4e59..2b51bcbd0c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -151,6 +151,23 @@ configured admin role may pass admin gates. Installations that intentionally make every proxy-authenticated user an admin must do that by leaving the role header unset and protecting Pulse at the proxy/IdP layer. +Local credential auth reads are also part of this shared boundary. Handlers in +`internal/api/auth.go` and `internal/api/router.go` that compare `AuthUser` and +`AuthPass` must snapshot those mutable config fields while holding +`config.Mu.RLock()` before running constant-time username or password-hash +checks. CSRF bootstrap checks may snapshot local auth, API-token, and proxy +secret presence under that same lock, then evaluate SSO provider state outside +the config lock. + +Candidate import plans for PVE/PBS/PMG onboarding are API-contract consumers, +not local UI estimates. Probe and Discovery candidates must build their +preview through `frontend-modern/src/api/monitoredSystemLedger.ts` and +`POST /api/license/monitored-system-ledger/preview`, preserving source, +resource type, name, hostname, host URL, and resource id in the request. The +frontend may render the preview and approval state, but it must not persist a +candidate or call node setup/save APIs until the operator has approved the +current endpoint, credential path, and collection scope. + Assistant chat `workflow_state` events are a shared AI/API payload boundary. The backend source of truth is `internal/ai/chat.WorkflowStateData`, generated to `frontend-modern/src/api/generated/aiChatEvents.ts` by diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c000bf5f5a..9610961950 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -193,6 +193,15 @@ deep-link flow. The self-hosted `Plans` page is not a deep-link-only flow: `system-billing` stays visible whenever commercial presentation is allowed, while demo or commercial-suppressed sessions may still hide it. +Candidate import-plan presentation inside the Infrastructure settings dialog is +a shared primitive composition boundary. `NodeCandidateImportPlan.tsx` may use +shared `Button`, checkbox styling, lucide icons, and +`MonitoredSystemImpactPreview`, while `InfrastructureWorkspace.tsx` owns the +route-backed dialog state that feeds probe or Discovery candidates into the +credential slot. That surface must keep the approval card readable inside the +existing dialog body and must not introduce a second nested modal, detached +wizard shell, or page-local preview renderer for monitored-system impact. + Frontend localization is a shared primitive boundary. Locale support must flow through typed message catalogs with an English fallback and explicit seed locale coverage rather than page-local string switches. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 95134b8a19..ffbb74c6e1 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -371,6 +371,14 @@ truth for live infrastructure data. ## Current State +The monitoring-owned storage metrics runtime must preserve store-backed storage +chart continuity during resolver warm-up. `syncUnifiedStorageMetrics` must +prefer the resolver's canonical storage metrics target when it exists, but must +fall back to the storage resource id instead of dropping the resource when the +resolver has not yet produced a storage target. The fallback is a metrics +continuity path for canonical storage resources, not a second storage identity +or recovery-source model. + That same reloadable multi-tenant monitor boundary also owns instance-wide notification settings fan-out. `ForEachMonitor` visits every live tenant monitor so callers can propagate the webhook security allowlist and public diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5ac2eac1d3..648a684bb8 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -204,6 +204,12 @@ regression protection. fetches, Discovery probes, or page-local external-link logic on the table hot path. 4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path. + Reading mutable auth configuration for CSRF bootstrap and login checks must + stay a short in-memory snapshot under `config.Mu.RLock()`: local + username/password presence, API-token presence, and proxy-auth secret + presence may be copied while locked, while SSO provider presence is checked + after the lock is released. This preserves the protected hot path and avoids + holding the config lock across provider/state work. The same rule applies to public setup-script lifecycle routes: `/api/auto-register` and `/api/auto-unregister` may bypass the global auth wall so their handlers can validate request-body setup tokens, but the router-level public-path check must diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 6fbbbc6855..0233a1579f 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -234,6 +234,12 @@ existing resource-policy redaction still governs any model-bound context. Pulse Mobile token scopes, bypass the server-minted credential requirement, or expose relay secrets beyond the existing public onboarding diagnostics. 4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary. + Local username/password verification in `internal/api/auth.go` and + `internal/api/router.go` must snapshot `AuthUser` and `AuthPass` under + `config.Mu.RLock()` before comparison. Security/privacy may consume that + shared auth result, but it must not read mutable credential fields outside + the shared config lock or hold the lock across password hashing, session + mutation, SSO provider checks, or response writing. Release metadata surfaced through `/api/version` remains outside token, auth, and privacy state. Adding or changing `agentUpdateTargetVersion` must stay limited to non-secret deployable release identity and must not diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 82f62248f9..4aa4ed7012 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -239,6 +239,11 @@ Storage/recovery auth-adjacent changes may consume SSO-authenticated sessions, but they must not reinterpret SAML or multi-provider SSO availability as a storage/recovery entitlement; that provider-route and license truth belongs to the shared API/security boundary. +Storage/recovery may consume authenticated chart and report routes through the +shared API boundary, but it must not own router-level auth configuration reads. +Those reads stay API/security-owned and must snapshot mutable auth credentials +under `config.Mu.RLock()` before storage-adjacent routes rely on the resulting +session or API-token decision. Storage/recovery may also consume org-scoped session identity from the shared API boundary, but durable user IDs remain the authorization principal. Contact email may support display or legacy lookup only; storage and recovery surfaces diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index c3d58f319d..edb7b1cf77 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -372,6 +372,19 @@ Persistent metrics history uses tiered retention windows. These values are store See [METRICS_HISTORY.md](METRICS_HISTORY.md) for details. +### Prometheus Metrics Endpoint + +The `/metrics` listener is separate from the main UI/API listener and binds to loopback by default. + +| Variable | Description | Default | +| ---------- | ------------- | --------- | +| `PULSE_METRICS_PORT` | Metrics listener port | `9091` | +| `PULSE_METRICS_BIND_ADDRESS` | Metrics listener bind address | `127.0.0.1` | +| `PULSE_METRICS_TOKEN` | Optional bearer token for `/metrics` | *(empty)* | +| `PULSE_METRICS_ALLOW_INSECURE_REMOTE` | Explicit opt-in to serve a metrics bearer token over non-loopback plaintext HTTP | `false` | + +For remote scraping with `PULSE_METRICS_TOKEN`, prefer a local scraper, tunnel, VPN-private path, or TLS/mTLS reverse proxy. Pulse refuses non-loopback plaintext token scraping unless `PULSE_METRICS_ALLOW_INSECURE_REMOTE=true` is set. + --- ## ๐Ÿ”” Alerts (`alerts.json`) @@ -402,6 +415,47 @@ Pulse uses a powerful alerting engine with hysteresis (separate trigger/clear th --- +## Availability Checks (`availability_targets.enc`) + +Availability checks are agentless probes for devices and services where Pulse +cannot install an agent or does not need full machine telemetry. Use them for +simple ping monitoring, TCP service checks, and HTTP/HTTPS status checks. + +**Managed via UI**: Settings -> Monitoring -> Availability checks + +Supported protocols: + +| Protocol | Use case | Required fields | +| ---------- | ---------- | ---------------- | +| `icmp` | Ping-only reachability for devices, computers, and appliances | `address` | +| `ping` | API input alias for `icmp`; saved targets return `icmp` | `address` | +| `tcp` | A reachable port such as MQTT, SSH, or a custom service | `address`, `port` | +| `http` / `https` | Web UI or health endpoint availability | `address`, optional `port`, optional `path` | + +Saved targets include `name`, `targetKind` (`machine`, `service`, or +`device`), `address`, `protocol`, `enabled`, polling interval, timeout, +failure threshold, and an optional `linkedResourceId`. ICMP ping is the +default probe for new targets. Availability targets publish +`network-endpoint` resources and can raise downtime alerts after the configured +failure threshold. + +Example API payload for simple ping monitoring: + +```json +{ + "name": "Garage temperature sensor", + "targetKind": "device", + "address": "garage-sensor.local", + "protocol": "ping", + "enabled": true +} +``` + +Pulse stores and returns that target as `protocol: "icmp"` so dashboards, +alerts, and resource projections keep one canonical protocol value. + +--- + ## ๐Ÿ”’ HTTPS / TLS Enable HTTPS by providing certificate files via environment variables. diff --git a/frontend-modern/public/docs/PROXY_AUTH.md b/frontend-modern/public/docs/PROXY_AUTH.md index b7a22064df..03cd415105 100644 --- a/frontend-modern/public/docs/PROXY_AUTH.md +++ b/frontend-modern/public/docs/PROXY_AUTH.md @@ -23,6 +23,10 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl | `PROXY_AUTH_ADMIN_ROLE` | Role name that grants admin access. | `admin` | | `PROXY_AUTH_LOGOUT_URL` | URL to redirect to after logout. | - | +If `PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured, admin access fails closed unless the role header is present and contains the configured admin role. A missing or blank role header still authenticates the user, but the user is treated as non-admin. + +If you intentionally want every proxy-authenticated user to be an admin, leave `PROXY_AUTH_ROLE_HEADER` unset and protect Pulse entirely at the proxy/IdP layer. + ## ๐Ÿ“ฆ Examples ### Authentik (with Traefik) diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCandidateImportPlan.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCandidateImportPlan.tsx new file mode 100644 index 0000000000..04fee9dcae --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCandidateImportPlan.tsx @@ -0,0 +1,118 @@ +import { For, Show, type Component } from 'solid-js'; +import CheckCircle2 from 'lucide-solid/icons/check-circle-2'; +import ClipboardCheck from 'lucide-solid/icons/clipboard-check'; +import KeyRound from 'lucide-solid/icons/key-round'; +import Search from 'lucide-solid/icons/search'; +import ShieldCheck from 'lucide-solid/icons/shield-check'; + +import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystemLedger'; +import { Button } from '@/components/shared/Button'; +import { formCheckbox } from '@/components/shared/Form'; +import type { NodeImportPlan, NodeImportPlanStep } from '../../infrastructureImportPlanModel'; +import { MonitoredSystemImpactPreview } from '../../MonitoredSystemImpactPreview'; + +interface NodeCandidateImportPlanProps { + plan: NodeImportPlan; + approved: boolean; + onApprovedChange: (approved: boolean) => void; + onPreviewImpact: () => void; + preview: MonitoredSystemLedgerPreviewResponse | null; + previewing: boolean; + previewError: string | null; +} + +const stepIcon = (step: NodeImportPlanStep) => { + switch (step.id) { + case 'candidate': + return Search; + case 'credentials': + return KeyRound; + case 'dry-run': + return ClipboardCheck; + case 'approval': + return ShieldCheck; + case 'verification': + return CheckCircle2; + } +}; + +export const NodeCandidateImportPlan: Component = (props) => { + return ( +
+
+
+
+

Candidate import plan

+ + {props.plan.sourceLabel} + +
+

+ {props.plan.name} ยท {props.plan.endpoint} +

+ + {(version) =>

{version()}

} +
+
+ +
+ +
+ + {(step) => { + const Icon = stepIcon(step); + return ( +
+
+
+

{step.detail}

+
+ ); + }} +
+
+ +
+ +
+ + +
+ ); +}; + +export default NodeCandidateImportPlan; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx index 9129c91a43..07467bfb8a 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx @@ -1,4 +1,8 @@ -import { Component } from 'solid-js'; +import { Component, createEffect, createMemo, createSignal } from 'solid-js'; +import { + MonitoredSystemLedgerAPI, + type MonitoredSystemLedgerPreviewResponse, +} from '@/api/monitoredSystemLedger'; import type { NodeConfig, NodeConfigWithStatus } from '@/types/nodes'; import { NodeModalAuthenticationSection } from '@/components/Settings/NodeModalAuthenticationSection'; import { NodeModalBasicInfoSection } from '@/components/Settings/NodeModalBasicInfoSection'; @@ -7,6 +11,13 @@ import { NodeModalStatusFooter } from '@/components/Settings/NodeModalStatusFoot import { useNodeModalState } from '@/components/Settings/useNodeModalState'; import type { NodeModalProps } from '@/components/Settings/nodeModalModel'; import type { InfrastructurePlatformSettingsProps } from '@/components/Settings/proxmoxSettingsModel'; +import { notificationStore } from '@/stores/notifications'; +import { logger } from '@/utils/logger'; +import { + buildNodeImportPlan, + type NodeImportPlanCandidate, +} from '../../infrastructureImportPlanModel'; +import { NodeCandidateImportPlan } from './NodeCandidateImportPlan'; export type NodeSlotType = 'pve' | 'pbs' | 'pmg'; @@ -15,6 +26,7 @@ export interface NodeCredentialSlotProps { settings: InfrastructurePlatformSettingsProps; editingNode?: NodeConfigWithStatus | null; prefillNode?: Partial; + importCandidate?: NodeImportPlanCandidate | null; initialAddress?: string; onCancel: () => void; onSaved: () => void; @@ -30,6 +42,13 @@ export interface NodeCredentialSlotProps { export const NodeCredentialSlot: Component = (props) => { const prefill: Partial | undefined = props.prefillNode ?? (props.initialAddress ? { host: props.initialAddress } : undefined); + const importApprovalRequiredMessage = + 'Approve the import plan before generating setup commands or adding this source.'; + const [importApproved, setImportApproved] = createSignal(false); + const [previewingImportImpact, setPreviewingImportImpact] = createSignal(false); + const [importImpactPreview, setImportImpactPreview] = + createSignal(null); + const [importImpactPreviewError, setImportImpactPreviewError] = createSignal(null); const handleSave = async (nodeData: Partial) => { await props.settings.saveNode(nodeData); @@ -49,6 +68,8 @@ export const NodeCredentialSlot: Component = (props) => ), temperatureMonitoringLocked: props.settings.temperatureMonitoringLocked(), savingTemperatureSetting: props.settings.savingTemperatureSetting(), + setupHandoffDisabled: () => Boolean(props.importCandidate && !importApproved()), + setupHandoffDisabledReason: importApprovalRequiredMessage, onToggleTemperatureMonitoring: async (enabled) => { const target = props.editingNode; if (target?.id) { @@ -60,9 +81,59 @@ export const NodeCredentialSlot: Component = (props) => }; const state = useNodeModalState(modalProps); + const importPlan = createMemo(() => + buildNodeImportPlan(props.nodeType, state.formData(), props.importCandidate), + ); + + let previousImportPlanSignature = ''; + + createEffect(() => { + const signature = importPlan()?.signature ?? ''; + if (signature === previousImportPlanSignature) { + return; + } + previousImportPlanSignature = signature; + setImportApproved(false); + setImportImpactPreview(null); + setImportImpactPreviewError(null); + }); + + const saveBlockedByImportPlan = () => Boolean(importPlan() && !importApproved()); + + const handlePreviewImportImpact = async () => { + const plan = importPlan(); + if (!plan?.previewRequest) { + setImportImpactPreviewError('Enter an endpoint before previewing impact.'); + return; + } + + setPreviewingImportImpact(true); + setImportImpactPreviewError(null); + try { + const preview = await MonitoredSystemLedgerAPI.preview(plan.previewRequest); + setImportImpactPreview(preview); + } catch (error) { + logger.error('[Infrastructure Import Plan] Impact preview failed', error); + setImportImpactPreview(null); + setImportImpactPreviewError( + error instanceof Error ? error.message : 'Unable to calculate import impact right now.', + ); + } finally { + setPreviewingImportImpact(false); + } + }; + + const handleSubmit = (event: Event) => { + if (saveBlockedByImportPlan()) { + event.preventDefault(); + notificationStore.error(importApprovalRequiredMessage); + return; + } + state.handleSubmit(event); + }; return ( -
+
= (props) =>
+ {importPlan() && ( + + )} @@ -110,6 +192,8 @@ export const NodeCredentialSlot: Component = (props) => deletePending={props.deletePending} deleteConfirming={props.deleteConfirming} deleteError={props.deleteError} + saveDisabled={saveBlockedByImportPlan()} + saveDisabledReason={importApprovalRequiredMessage} /> ); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/__tests__/NodeCredentialSlot.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/__tests__/NodeCredentialSlot.test.tsx new file mode 100644 index 0000000000..94ec137b45 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/__tests__/NodeCredentialSlot.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { InfrastructurePlatformSettingsProps } from '@/components/Settings/proxmoxSettingsModel'; +import { NodeCredentialSlot } from '../NodeCredentialSlot'; + +const importApprovalRequiredMessage = + 'Approve the import plan before generating setup commands or adding this source.'; + +const createSettings = (): InfrastructurePlatformSettingsProps => + ({ + securityStatus: () => null, + resolveTemperatureMonitoringEnabled: () => true, + temperatureMonitoringLocked: () => false, + savingTemperatureSetting: () => false, + handleNodeTemperatureMonitoringChange: vi.fn().mockResolvedValue(undefined), + handleTemperatureMonitoringChange: vi.fn().mockResolvedValue(undefined), + saveNode: vi.fn().mockResolvedValue(undefined), + }) as unknown as InfrastructurePlatformSettingsProps; + +describe('NodeCredentialSlot', () => { + afterEach(() => cleanup()); + + it('requires candidate import plan approval before guided setup handoff or manual save', () => { + render(() => ( + + )); + + expect(screen.getByText('Candidate import plan')).toBeInTheDocument(); + expect(screen.getByText('Proxmox VE 9.0')).toBeInTheDocument(); + + const blockedSetupButton = screen.getAllByTitle(importApprovalRequiredMessage)[0]; + expect(blockedSetupButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText(/Approve this import plan/i)); + + expect(screen.getByTitle('Copy command')).not.toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: /^Manual Token Setup$/i })); + + const addButton = screen.getByRole('button', { name: 'Add node' }); + expect(addButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText(/Approve this import plan/i)); + + expect(addButton).not.toBeDisabled(); + }); +}); diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index f7960403eb..1b00c44698 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -58,6 +58,7 @@ import { filterRepresentedDiscoveredServers, type DiscoveredServer, } from './infrastructureSettingsModel'; +import type { NodeImportPlanCandidate } from './infrastructureImportPlanModel'; export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps; @@ -312,13 +313,36 @@ const InfrastructureWorkspaceContent: Component = if (selected && activeType && activeType !== selected.type) { setSelectedDiscoveredSource(null); } + + const probed = selectedProbeCandidate(); + if (probed && activeType && activeType !== probed.type) { + setSelectedProbeCandidate(null); + } }); + const importCandidateForNodeType = ( + type: 'pve' | 'pbs' | 'pmg', + candidate?: ProbeCandidate | null, + ): NodeImportPlanCandidate | null => { + const discovered = selectedDiscoveredSource(); + if (discovered && discovered.type === type) { + return { kind: 'discovery', server: discovered }; + } + + const probed = candidate ?? selectedProbeCandidate(); + if (probed && probed.type === type) { + return { kind: 'probe', candidate: probed }; + } + + return null; + }; + const renderNodeSlot = ( type: 'pve' | 'pbs' | 'pmg', editingNode?: NodeConfigWithStatus | null, editingRow?: Connection | null, prefillNode?: Partial, + importCandidate?: NodeImportPlanCandidate | null, ) => { const connectionId = editingRow?.id ?? null; return ( @@ -327,6 +351,7 @@ const InfrastructureWorkspaceContent: Component = settings={props} editingNode={editingNode ?? null} prefillNode={prefillNode} + importCandidate={importCandidate ?? null} onCancel={editingRow ? closeEditFlow : closeAddFlow} onSaved={editingRow ? handleEditSaved : handleAddSaved} onToggleEnabled={ @@ -621,6 +646,7 @@ const InfrastructureWorkspaceContent: Component = const renderConnectionSlot = (context: { mode: 'add' | 'edit'; type: ConnectionType; + candidate?: ProbeCandidate | null; onCancel: () => void; onSaved: () => void; }) => { @@ -756,7 +782,13 @@ const InfrastructureWorkspaceContent: Component = case 'pve': case 'pbs': case 'pmg': - return renderNodeSlot(context.type, undefined, undefined, discoveredPrefill()); + return renderNodeSlot( + context.type, + undefined, + undefined, + discoveredPrefill(), + importCandidateForNodeType(context.type, context.candidate), + ); case 'truenas': return ( = onSelectCandidate={openAddFlowFromProbe} onClose={closeAddFlow} onSaved={handleAddSaved} - renderCredentialSlot={({ type, onCancel, onSaved }) => - renderConnectionSlot({ mode: 'add', type, onCancel, onSaved }) + renderCredentialSlot={({ type, candidate, onCancel, onSaved }) => + renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved }) } /> @@ -966,8 +998,8 @@ const InfrastructureWorkspaceContent: Component = onBackToCatalog={() => openAddFlow('pick')} onClose={closeAddFlow} onSaved={handleAddSaved} - renderCredentialSlot={({ type, onCancel, onSaved }) => - renderConnectionSlot({ mode: 'add', type, onCancel, onSaved }) + renderCredentialSlot={({ type, candidate, onCancel, onSaved }) => + renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved }) } /> diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index 9a3721bf13..b6671574c8 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -47,6 +47,14 @@ const setupModeButtonClass = (selected: boolean): string => export const NodeModalSetupGuideSection: Component = (props) => { const { modalProps, state } = props; + const setupHandoffDisabled = () => Boolean(modalProps.setupHandoffDisabled?.()); + const setupHandoffDisabledReason = () => + modalProps.setupHandoffDisabledReason ?? + 'Complete the required review before generating setup commands.'; + const setupCommandButtonTitle = () => + setupHandoffDisabled() ? setupHandoffDisabledReason() : 'Copy command'; + const setupCommandButtonClass = (className: string) => + `${className} disabled:cursor-not-allowed disabled:opacity-50`; const setupStrategy = () => getNodeSetupStrategyPresentation(modalProps.nodeType, state.formData().setupMode); const setupStrategyPanel = () => ( @@ -107,6 +115,11 @@ export const NodeModalSetupGuideSection: Component {setupStrategyPanel()} + +

+ {setupHandoffDisabledReason()} +

+
{setupModeControls()} @@ -152,15 +165,17 @@ export const NodeModalSetupGuideSection: Component @@ -409,8 +429,11 @@ export const NodeModalSetupGuideSection: Component { await state.copyCommand(PVE_MANUAL_PERMISSION_COMMAND); }} - class="absolute top-1 right-1 p-1 hover:text-muted transition-colors" - title="Copy command" + disabled={setupHandoffDisabled()} + class={setupCommandButtonClass( + 'absolute top-1 right-1 p-1 hover:text-muted transition-colors', + )} + title={setupCommandButtonTitle()} > {setupStrategyPanel()} + +

+ {setupHandoffDisabledReason()} +

+
{setupModeControls()} @@ -601,8 +638,10 @@ export const NodeModalSetupGuideSection: Component { await state.downloadProxmoxSetupScript('pbs'); }} - class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm font-medium" + disabled={setupHandoffDisabled()} + title={setupHandoffDisabled() ? setupHandoffDisabledReason() : undefined} + class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50" > Download setup script @@ -837,8 +881,11 @@ export const NodeModalSetupGuideSection: Component = (props) => { @@ -210,7 +212,8 @@ export const NodeModalStatusFooter: Component = (pro