From 8ed0851fb9108b64bfde508ac647a29f3047368d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Dec 2025 21:27:21 +0000 Subject: [PATCH] feat: simplify setup wizard to 3 steps (welcome, security, complete) Removed Connect and Features steps that were adding friction without much value. The wizard now focuses on: 1. Welcome - introduction and bootstrap token entry 2. Security - credential setup 3. Complete - shows install command and connected agents Agent polling now happens in CompleteStep instead of relying on WebSockets which aren't available during the pre-login setup phase. --- .../components/SetupWizard/SetupWizard.tsx | 39 +- .../SetupWizard/steps/CompleteStep.tsx | 473 +++++++++++++++--- .../SetupWizard/steps/ConnectStep.tsx | 68 ++- .../SetupWizard/steps/SecurityStep.tsx | 18 +- .../SetupWizard/steps/WelcomeStep.tsx | 16 - 5 files changed, 470 insertions(+), 144 deletions(-) diff --git a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx index a98327b..26510a7 100644 --- a/frontend-modern/src/components/SetupWizard/SetupWizard.tsx +++ b/frontend-modern/src/components/SetupWizard/SetupWizard.tsx @@ -1,19 +1,17 @@ import { Component, createSignal, Show } from 'solid-js'; import { WelcomeStep } from './steps/WelcomeStep'; import { SecurityStep } from './steps/SecurityStep'; -import { ConnectStep } from './steps/ConnectStep'; -import { FeaturesStep } from './steps/FeaturesStep'; import { CompleteStep } from './steps/CompleteStep'; import { StepIndicator } from './StepIndicator'; -export type WizardStep = 'welcome' | 'security' | 'connect' | 'features' | 'complete'; +export type WizardStep = 'welcome' | 'security' | 'complete'; export interface WizardState { // Security username: string; password: string; apiToken: string; - // Node + // Node (for display in complete step) nodeAdded: boolean; nodeName: string; // Features @@ -41,7 +39,7 @@ export const SetupWizard: Component = (props) => { const [bootstrapToken, setBootstrapToken] = createSignal(props.bootstrapToken || ''); const [isUnlocked, setIsUnlocked] = createSignal(props.isUnlocked || false); - const steps: WizardStep[] = ['welcome', 'security', 'connect', 'features', 'complete']; + const steps: WizardStep[] = ['welcome', 'security', 'complete']; const currentStepIndex = () => steps.indexOf(currentStep()); @@ -63,10 +61,6 @@ export const SetupWizard: Component = (props) => { setWizardState(prev => ({ ...prev, ...updates })); }; - const skipToComplete = () => { - setCurrentStep('complete'); - }; - return (
= (props) => {
- {/* Step indicator - only show after welcome */} - + {/* Step indicator - only show during security step */} + @@ -113,25 +107,6 @@ export const SetupWizard: Component = (props) => { /> - - - - - - - - void; } -export const CompleteStep: Component = (props) => { - const [copied, setCopied] = createSignal<'password' | 'token' | null>(null); +type Platform = 'proxmox' | 'docker' | 'kubernetes' | 'host'; - const handleCopy = async (type: 'password' | 'token') => { - const value = type === 'password' ? props.state.password : props.state.apiToken; - const success = await copyToClipboard(value); +interface ConnectedAgent { + id: string; + name: string; + type: string; + host: string; + addedAt: Date; +} + +export const CompleteStep: Component = (props) => { + const [copied, setCopied] = createSignal<'password' | 'token' | 'install' | null>(null); + const [showCredentials, setShowCredentials] = createSignal(false); + const [selectedPlatforms, setSelectedPlatforms] = createSignal([]); + const [connectedAgents, setConnectedAgents] = createSignal([]); + const [currentInstallToken, setCurrentInstallToken] = createSignal(props.state.apiToken); + const [generatingToken, setGeneratingToken] = createSignal(false); + + // Available optional platforms (host monitoring is always enabled) + const platforms = [ + { id: 'proxmox' as Platform, name: 'Proxmox VE', desc: 'VMs & containers via API', icon: 'πŸ–₯️' }, + { id: 'docker' as Platform, name: 'Docker', desc: 'Container monitoring', icon: '🐳' }, + { id: 'kubernetes' as Platform, name: 'Kubernetes', desc: 'Cluster monitoring', icon: '☸️' }, + ]; + + // Poll for agent connections since WebSocket isn't available during setup + createEffect(() => { + let pollInterval: number | undefined; + let previousCount = 0; + + const checkForAgents = async () => { + try { + console.log('[CompleteStep] Checking for agents with token:', props.state.apiToken?.slice(0, 8) + '...'); + + const response = await fetch('/api/state', { + headers: { + 'X-API-Token': props.state.apiToken, + }, + }); + + console.log('[CompleteStep] API response status:', response.status); + + if (!response.ok) { + console.log('[CompleteStep] API returned non-OK status, skipping'); + return; + } + + const state = await response.json(); + const nodes = state.nodes || []; + const hosts = state.hosts || []; + + console.log('[CompleteStep] Received:', { nodes: nodes.length, hosts: hosts.length, hostNames: hosts.map((h: any) => h.hostname || h.displayName) }); + + // Check if we have new connections + const totalAgents = nodes.length + hosts.length; + + if (totalAgents > previousCount || totalAgents !== connectedAgents().length) { + // Group agents by hostname to avoid duplicates + const agentMap = new Map(); + + for (const node of nodes) { + const name = node.name || node.displayName || 'Unknown'; + const existing = agentMap.get(name); + if (existing) { + // Add PVE to existing agent's types + if (!existing.type.includes('Proxmox')) { + existing.type = `${existing.type} + Proxmox VE`; + } + if (node.host && !existing.host) { + existing.host = node.host; + } + } else { + agentMap.set(name, { + id: node.id || `node-${name}`, + name, + type: 'Proxmox VE', + host: node.host || '', + addedAt: new Date(), + }); + } + } + + for (const host of hosts) { + const name = host.displayName || host.hostname || 'Unknown'; + const existing = agentMap.get(name); + if (existing) { + // Add Host Agent to existing PVE node + if (!existing.type.includes('Host')) { + existing.type = `${existing.type} + Host Agent`; + } + } else { + agentMap.set(name, { + id: host.id || `host-${name}`, + name, + type: 'Host Agent', + host: '', + addedAt: new Date(), + }); + } + } + + setConnectedAgents(Array.from(agentMap.values())); + + // Generate new token if agents increased + if (previousCount > 0 && totalAgents > previousCount) { + generateNewToken(); + } + + previousCount = totalAgents; + } + } catch (error) { + console.error('Failed to check for agents:', error); + } + }; + + // Poll every 3 seconds + pollInterval = window.setInterval(checkForAgents, 3000); + + // Initial check + checkForAgents(); + + onCleanup(() => { + if (pollInterval) { + window.clearInterval(pollInterval); + } + }); + }); + + const togglePlatform = (platform: Platform) => { + const current = selectedPlatforms(); + if (current.includes(platform)) { + setSelectedPlatforms(current.filter(p => p !== platform)); + } else { + setSelectedPlatforms([...current, platform]); + } + }; + + const generateNewToken = async () => { + if (generatingToken()) return; + + setGeneratingToken(true); + try { + // Don't specify scopes - tokens without scopes default to wildcard access + const result = await SecurityAPI.createToken(`agent-install-${Date.now()}`); + if (result.token) { + setCurrentInstallToken(result.token); + } + } catch (error) { + console.error('Failed to generate new token:', error); + } finally { + setGeneratingToken(false); + } + }; + + const handleCopy = async (type: 'password' | 'token' | 'install', value?: string) => { + const copyValue = value || (type === 'password' ? props.state.password : props.state.apiToken); + const success = await copyToClipboard(copyValue); if (success) { setCopied(type); setTimeout(() => setCopied(null), 2000); } }; + const handleCopyInstall = async () => { + const command = getInstallCommand(); + const success = await copyToClipboard(command); + if (success) { + setCopied('install'); + setTimeout(() => setCopied(null), 2000); + } + }; + const downloadCredentials = () => { const baseUrl = getPulseBaseUrl(); const content = `Pulse Credentials @@ -38,7 +199,7 @@ ${props.state.apiToken} Example: curl -H "X-API-Token: ${props.state.apiToken}" ${baseUrl}/api/state -⚠️ Keep these credentials secure! +Keep these credentials secure! `; const blob = new Blob([content], { type: 'text/plain' }); @@ -52,104 +213,266 @@ Example: curl -H "X-API-Token: ${props.state.apiToken}" ${baseUrl}/api/state URL.revokeObjectURL(url); }; + const getInstallCommand = () => { + const baseUrl = getPulseBaseUrl(); + // Host monitoring is always enabled by default, only add flags for optional integrations + const platformFlags = selectedPlatforms() + .filter(p => p !== 'host') // host is default, don't need flag + .map(p => `--enable-${p}`) + .join(' '); + const flagsPart = platformFlags ? ` ${platformFlags}` : ''; + return `curl -sSL ${baseUrl}/install.sh | sudo bash -s -- --url "${baseUrl}" --token "${currentInstallToken()}"${flagsPart}`; + }; + return (
{/* Success animation */} -
-
- +
+
+
-

- You're All Set! πŸŽ‰ +

+ Security Configured

-

- Pulse is ready to monitor your infrastructure +

+ Install Pulse Agents on hosts you want to monitor

- {/* Credentials box */} -
-
-

Your Credentials

- + Connected ({connectedAgents().length} host{connectedAgents().length !== 1 ? 's' : ''}) + +
+ + {(agent) => ( +
+
+ + {agent.name} +
+
+ {agent.type} + + {agent.host} + +
+
+ )} +
+
+ -
- {/* Username */} -
-
Username
-
{props.state.username}
-
+ {/* Platform selection */} +
+

What does this host have?

- {/* Password */} -
-
Password
-
- {props.state.password} - + {/* Always included - Host monitoring */} +
+
+
+ + +
-
- - {/* API Token */} -
-
API Token
-
- {props.state.apiToken} - +
+
+ Host Monitoring + Always included +
+

CPU, memory, disk, network on any Linux/macOS/Windows server

-
-

- ⚠️ Save these now β€” they won't be shown again! -

+ {/* Optional integrations */} +

Enable if this host runs:

+
+ + {(platform) => ( + + )} +
- {/* Quick links */} -
- -
πŸ“Š
-
Dashboard
-
- -
βš™οΈ
-
Add More Nodes
-
- -
πŸ€–
-
Configure AI
-
+ {/* Agent installation */} +
+
+

+ + + + Install Command +

+ 0}> + + +
+

+ Run on any host you want to monitor. Each copy uses a unique token. +

+ +
+ {getInstallCommand()} + +
+ +

+ Agents auto-register with Pulse. Install on as many hosts as you like. +

+
+ + {/* Credentials section (collapsible) */} +
+ + + +
+
+
Username
+
{props.state.username}
+
+ +
+
Password
+
+ {props.state.password} + +
+
+ +
+
API Token (for web login)
+
+ {props.state.apiToken} + +
+
+ + +
+
{/* Launch button */} + +

+ You can add more agents anytime from Settings +

); }; diff --git a/frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx b/frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx index 21ca58e..0bd50a4 100644 --- a/frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx +++ b/frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx @@ -33,19 +33,54 @@ export const ConnectStep: Component = (props) => { const [tokenSecret, setTokenSecret] = createSignal(''); const platforms = [ - { id: 'proxmox' as Platform, name: 'Proxmox VE', icon: 'πŸ–₯️', desc: 'Hypervisor & containers', ports: ['8006'] }, - { id: 'docker' as Platform, name: 'Docker', icon: '🐳', desc: 'Container hosts', ports: ['2375', '2376'] }, - { id: 'kubernetes' as Platform, name: 'Kubernetes', icon: '☸️', desc: 'Container orchestration', ports: ['6443'] }, + { id: 'proxmox' as Platform, name: 'Proxmox VE', desc: 'Hypervisor & containers', ports: ['8006'] }, + { id: 'docker' as Platform, name: 'Docker', desc: 'Container hosts', ports: ['2375', '2376'] }, + { id: 'kubernetes' as Platform, name: 'Kubernetes', desc: 'Container orchestration', ports: ['6443'] }, ]; + const PlatformIcon: Component<{ platform: Platform; class?: string }> = (iconProps) => { + const iconClass = iconProps.class || 'w-10 h-10'; + switch (iconProps.platform) { + case 'proxmox': + // Official Proxmox VE logo (from simple-icons) + return ( + + + + ); + case 'docker': + // Official Docker Moby whale logo + return ( + + + + ); + case 'kubernetes': + // Official Kubernetes logo (from simple-icons) + return ( + + + + ); + default: + return null; + } + }; + const runDiscovery = async () => { setIsScanning(true); setDiscoveredNodes([]); + const headers: Record = { 'Content-Type': 'application/json' }; + if (props.state.apiToken) { + headers['X-API-Token'] = props.state.apiToken; + } + try { const response = await fetch('/api/discover', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', body: JSON.stringify({ subnet: 'auto' }), }); @@ -54,7 +89,10 @@ export const ConnectStep: Component = (props) => { // Poll for results for (let i = 0; i < 10; i++) { await new Promise(r => setTimeout(r, 2000)); - const results = await fetch('/api/discover/results'); + const results = await fetch('/api/discover/results', { + headers: props.state.apiToken ? { 'X-API-Token': props.state.apiToken } : {}, + credentials: 'include', + }); if (results.ok) { const data = await results.json(); if (data.nodes && data.nodes.length > 0) { @@ -79,6 +117,11 @@ export const ConnectStep: Component = (props) => { const connectNode = async (node?: DiscoveredNode) => { setIsConnecting(true); + const headers: Record = { 'Content-Type': 'application/json' }; + if (props.state.apiToken) { + headers['X-API-Token'] = props.state.apiToken; + } + try { const nodeData = node ? { type: node.type === 'pbs' ? 'pbs' : node.type === 'pmg' ? 'pmg' : 'pve', @@ -96,7 +139,8 @@ export const ConnectStep: Component = (props) => { const response = await fetch('/api/nodes', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', body: JSON.stringify(nodeData), }); @@ -129,7 +173,7 @@ export const ConnectStep: Component = (props) => { onClick={() => setSelectedPlatform(platform.id)} class="bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 rounded-xl p-4 text-center transition-all" > -
{platform.icon}
+
{platform.name}
{platform.desc}
@@ -148,7 +192,7 @@ export const ConnectStep: Component = (props) => { > ← - πŸ–₯️ + Proxmox VE
@@ -159,14 +203,14 @@ export const ConnectStep: Component = (props) => { disabled={isScanning()} class="bg-blue-500/20 hover:bg-blue-500/30 border border-blue-400/30 rounded-xl p-4 text-left transition-all" > -
πŸ” Auto-Discover
+
Auto-Discover
Scan your network
@@ -240,7 +284,7 @@ export const ConnectStep: Component = (props) => { {/* Docker placeholder */}
-
🐳
+

Docker monitoring requires the Pulse Agent.
Install it on your Docker host after setup. @@ -257,7 +301,7 @@ export const ConnectStep: Component = (props) => { {/* Kubernetes placeholder */}

-
☸️
+

Kubernetes monitoring requires the Pulse Agent.
Deploy via Helm after setup. diff --git a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx index d2b5763..d3df876 100644 --- a/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx +++ b/frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx @@ -35,8 +35,8 @@ export const SecurityStep: Component = (props) => { const handleSetup = async () => { if (useCustomPassword()) { - if (!password() || password().length < 12) { - showError('Password must be at least 12 characters'); + if (!password()) { + showError('Please enter a password'); return; } if (password() !== confirmPassword()) { @@ -115,21 +115,21 @@ export const SecurityStep: Component = (props) => { type="button" onClick={() => setUseCustomPassword(false)} class={`py-3 px-4 rounded-xl text-sm font-medium transition-all ${!useCustomPassword() - ? 'bg-blue-500 text-white' - : 'bg-white/10 text-white/70 hover:bg-white/20' + ? 'bg-blue-500 text-white' + : 'bg-white/10 text-white/70 hover:bg-white/20' }`} > - πŸ” Generate Secure + Generate Secure

@@ -140,7 +140,7 @@ export const SecurityStep: Component = (props) => { value={password()} onInput={(e) => setPassword(e.currentTarget.value)} class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Password (min 12 characters)" + placeholder="Password" /> = (props) => {

- {/* Feature highlights */} -
-
-
πŸ–₯️
-
Proxmox
-
-
-
🐳
-
Docker
-
-
-
☸️
-
Kubernetes
-
-
- {/* Bootstrap token unlock */}