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.
This commit is contained in:
rcourtman 2025-12-13 21:27:21 +00:00
parent 3958b4c8c5
commit 8ed0851fb9
5 changed files with 470 additions and 144 deletions

View file

@ -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<SetupWizardProps> = (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<SetupWizardProps> = (props) => {
setWizardState(prev => ({ ...prev, ...updates }));
};
const skipToComplete = () => {
setCurrentStep('complete');
};
return (
<div
class="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900 flex flex-col"
@ -80,12 +74,12 @@ export const SetupWizard: Component<SetupWizardProps> = (props) => {
<div class="absolute -bottom-40 right-1/3 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl" />
</div>
{/* Step indicator - only show after welcome */}
<Show when={currentStep() !== 'welcome' && currentStep() !== 'complete'}>
{/* Step indicator - only show during security step */}
<Show when={currentStep() === 'security'}>
<div class="relative z-10 pt-8 px-4" role="navigation" aria-label="Setup progress">
<StepIndicator
steps={['Security', 'Connect', 'Features']}
currentStep={currentStepIndex() - 1}
steps={['Welcome', 'Security', 'Done']}
currentStep={1}
/>
</div>
</Show>
@ -113,25 +107,6 @@ export const SetupWizard: Component<SetupWizardProps> = (props) => {
/>
</Show>
<Show when={currentStep() === 'connect'}>
<ConnectStep
state={wizardState()}
updateState={updateState}
onNext={nextStep}
onBack={prevStep}
onSkip={skipToComplete}
/>
</Show>
<Show when={currentStep() === 'features'}>
<FeaturesStep
state={wizardState()}
updateState={updateState}
onNext={nextStep}
onBack={prevStep}
/>
</Show>
<Show when={currentStep() === 'complete'}>
<CompleteStep
state={wizardState()}

View file

@ -1,6 +1,7 @@
import { Component, createSignal } from 'solid-js';
import { Component, createSignal, createEffect, onCleanup, Show, For } from 'solid-js';
import { copyToClipboard } from '@/utils/clipboard';
import { getPulseBaseUrl } from '@/utils/url';
import { SecurityAPI } from '@/api/security';
import type { WizardState } from '../SetupWizard';
interface CompleteStepProps {
@ -8,18 +9,178 @@ interface CompleteStepProps {
onComplete: () => void;
}
export const CompleteStep: Component<CompleteStepProps> = (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<CompleteStepProps> = (props) => {
const [copied, setCopied] = createSignal<'password' | 'token' | 'install' | null>(null);
const [showCredentials, setShowCredentials] = createSignal(false);
const [selectedPlatforms, setSelectedPlatforms] = createSignal<Platform[]>([]);
const [connectedAgents, setConnectedAgents] = createSignal<ConnectedAgent[]>([]);
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<string, ConnectedAgent>();
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 (
<div class="text-center">
{/* Success animation */}
<div class="mb-8">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-2xl shadow-green-500/30 mb-6">
<svg class="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<div class="mb-5">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-xl shadow-green-500/30 mb-3">
<svg class="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-3xl font-bold text-white mb-3">
You're All Set! 🎉
<h1 class="text-xl font-bold text-white mb-1">
Security Configured
</h1>
<p class="text-xl text-green-200/80">
Pulse is ready to monitor your infrastructure
<p class="text-sm text-green-200/80">
Install Pulse Agents on hosts you want to monitor
</p>
</div>
{/* Credentials box */}
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 p-6 text-left mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Your Credentials</h3>
<button
onClick={downloadCredentials}
class="text-sm text-blue-300 hover:text-blue-200 flex items-center gap-1"
>
{/* Connected Agents - show when agents connect */}
<Show when={connectedAgents().length > 0}>
<div class="bg-green-500/20 backdrop-blur-xl rounded-xl border border-green-400/30 p-4 text-left mb-4">
<h3 class="text-sm font-semibold text-green-300 mb-2 flex items-center gap-2">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Download
</button>
Connected ({connectedAgents().length} host{connectedAgents().length !== 1 ? 's' : ''})
</h3>
<div class="space-y-2">
<For each={connectedAgents()}>
{(agent) => (
<div class="flex items-center justify-between bg-black/20 rounded-lg px-3 py-2">
<div class="flex items-center gap-2">
<span class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
<span class="text-white text-sm font-medium">{agent.name}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] text-green-300/80 bg-green-500/20 px-1.5 py-0.5 rounded">{agent.type}</span>
<Show when={agent.host}>
<span class="text-[10px] text-white/40">{agent.host}</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="space-y-3">
{/* Username */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">Username</div>
<div class="text-white font-mono">{props.state.username}</div>
</div>
{/* Platform selection */}
<div class="bg-white/10 backdrop-blur-xl rounded-xl border border-white/20 p-4 text-left mb-4">
<h3 class="text-sm font-semibold text-white mb-3">What does this host have?</h3>
{/* Password */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">Password</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-sm break-all">{props.state.password}</code>
<button
onClick={() => handleCopy('password')}
class="ml-2 p-1.5 hover:bg-white/10 rounded transition-all"
>
{copied() === 'password' ? '✓' : '📋'}
</button>
{/* Always included - Host monitoring */}
<div class="bg-green-500/10 border border-green-400/30 rounded-lg p-2.5 mb-3">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-green-500 flex items-center justify-center">
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
{/* API Token */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">API Token</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-xs break-all">{props.state.apiToken}</code>
<button
onClick={() => handleCopy('token')}
class="ml-2 p-1.5 hover:bg-white/10 rounded transition-all"
>
{copied() === 'token' ? '✓' : '📋'}
</button>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-xs">Host Monitoring</span>
<span class="text-[9px] text-green-300 bg-green-500/20 px-1.5 py-0.5 rounded">Always included</span>
</div>
<p class="text-[10px] text-white/50">CPU, memory, disk, network on any Linux/macOS/Windows server</p>
</div>
</div>
</div>
<div class="mt-4 p-3 bg-amber-500/20 border border-amber-400/30 rounded-xl">
<p class="text-amber-200 text-sm">
Save these now they won't be shown again!
</p>
{/* Optional integrations */}
<p class="text-[10px] text-white/50 mb-2">Enable if this host runs:</p>
<div class="grid grid-cols-3 gap-2">
<For each={platforms}>
{(platform) => (
<button
onClick={() => togglePlatform(platform.id)}
class={`p-2 rounded-lg text-left transition-all border ${selectedPlatforms().includes(platform.id)
? 'bg-blue-500/20 border-blue-400/50'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}`}
>
<div class="flex items-center gap-1.5">
<div class={`w-3.5 h-3.5 rounded border-2 flex items-center justify-center ${selectedPlatforms().includes(platform.id)
? 'bg-blue-500 border-blue-500'
: 'border-white/40'
}`}>
<Show when={selectedPlatforms().includes(platform.id)}>
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</Show>
</div>
<span class="text-white font-medium text-xs">{platform.name}</span>
</div>
<p class="text-[9px] text-white/40 ml-5 mt-0.5">{platform.desc}</p>
</button>
)}
</For>
</div>
</div>
{/* Quick links */}
<div class="grid grid-cols-3 gap-3 mb-8">
<a href="/proxmox/overview" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2">📊</div>
<div class="text-sm text-white/80">Dashboard</div>
</a>
<a href="/settings/proxmox" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2"></div>
<div class="text-sm text-white/80">Add More Nodes</div>
</a>
<a href="/settings/system-ai" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2">🤖</div>
<div class="text-sm text-white/80">Configure AI</div>
</a>
{/* Agent installation */}
<div class="bg-white/10 backdrop-blur-xl rounded-xl border border-white/20 p-4 text-left mb-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-white flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Install Command
</h3>
<Show when={connectedAgents().length > 0}>
<button
onClick={generateNewToken}
disabled={generatingToken()}
class="text-xs text-blue-300 hover:text-blue-200 flex items-center gap-1 disabled:opacity-50"
>
{generatingToken() ? (
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
) : (
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
New Token
</button>
</Show>
</div>
<p class="text-white/70 text-xs mb-2">
Run on any host you want to monitor. Each copy uses a unique token.
</p>
<div class="bg-black/40 rounded-lg p-2.5 font-mono text-[10px] mb-2 relative group">
<code class="text-green-400 break-all block leading-relaxed">{getInstallCommand()}</code>
<button
onClick={handleCopyInstall}
class="absolute top-1.5 right-1.5 p-1.5 bg-white/10 hover:bg-white/20 rounded-md transition-all"
title="Copy command"
>
{copied() === 'install' ? (
<svg class="w-3.5 h-3.5 text-green-400" 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>
) : (
<svg class="w-3.5 h-3.5 text-white/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
<p class="text-[10px] text-white/40">
Agents auto-register with Pulse. Install on as many hosts as you like.
</p>
</div>
{/* Credentials section (collapsible) */}
<div class="bg-white/5 backdrop-blur rounded-lg border border-white/10 mb-4 overflow-hidden">
<button
onClick={() => setShowCredentials(!showCredentials())}
class="w-full p-2.5 flex items-center justify-between text-left hover:bg-white/5 transition-all"
>
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span class="text-white/80 text-xs">Your Credentials</span>
<span class="text-[10px] text-amber-300/80 bg-amber-500/20 px-1.5 py-0.5 rounded">Save these</span>
</div>
<svg class={`w-3.5 h-3.5 text-white/40 transition-transform ${showCredentials() ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={showCredentials()}>
<div class="p-2.5 pt-0 space-y-1.5">
<div class="bg-black/20 rounded-md p-2">
<div class="text-[10px] text-white/50 mb-0.5">Username</div>
<div class="text-white font-mono text-xs">{props.state.username}</div>
</div>
<div class="bg-black/20 rounded-md p-2">
<div class="text-[10px] text-white/50 mb-0.5">Password</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-xs break-all">{props.state.password}</code>
<button
onClick={() => handleCopy('password')}
class="ml-2 p-0.5 hover:bg-white/10 rounded transition-all text-white/40 hover:text-white"
>
{copied() === 'password' ? (
<svg class="w-3 h-3 text-green-400" 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>
) : (
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</div>
<div class="bg-black/20 rounded-md p-2">
<div class="text-[10px] text-white/50 mb-0.5">API Token (for web login)</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-[10px] break-all">{props.state.apiToken}</code>
<button
onClick={() => handleCopy('token')}
class="ml-2 p-0.5 hover:bg-white/10 rounded transition-all text-white/40 hover:text-white"
>
{copied() === 'token' ? (
<svg class="w-3 h-3 text-green-400" 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>
) : (
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</div>
<button
onClick={downloadCredentials}
class="w-full py-1.5 text-[10px] text-blue-300 hover:text-blue-200 flex items-center justify-center gap-1 hover:bg-white/5 rounded-md transition-all"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download credentials
</button>
</div>
</Show>
</div>
{/* Launch button */}
<button
onClick={props.onComplete}
class="w-full py-4 px-8 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white text-lg font-medium rounded-xl transition-all shadow-lg shadow-green-500/25"
class="w-full py-2.5 px-5 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white text-sm font-medium rounded-lg transition-all shadow-lg shadow-blue-500/25"
>
Launch Pulse
{connectedAgents().length > 0 ? 'Go to Dashboard' : 'Skip for Now'}
</button>
<p class="mt-2 text-[10px] text-white/40">
You can add more agents anytime from Settings
</p>
</div>
);
};

View file

@ -33,19 +33,54 @@ export const ConnectStep: Component<ConnectStepProps> = (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 (
<svg class={iconClass} viewBox="0 0 24 24" fill="#E57000">
<path d="M4.928 1.825c-1.09.553-1.09.64-.07 1.78 5.655 6.295 7.004 7.782 7.107 7.782.139.017 7.971-8.542 8.058-8.801.034-.07-.208-.312-.519-.536-.415-.312-.864-.433-1.712-.467-1.59-.104-2.144.242-4.115 2.455-.899 1.003-1.66 1.833-1.66 1.833-.017 0-.76-.813-1.642-1.798S8.473 2.1 8.127 1.91c-.796-.45-2.421-.484-3.2-.086zM1.297 4.367C.45 4.695 0 5.007 0 5.248c0 .121 1.331 1.678 2.94 3.459 1.625 1.78 2.939 3.268 2.939 3.302 0 .035-1.331 1.522-2.94 3.303C1.314 17.11.017 18.683.035 18.822c.086.467 1.504 1.055 2.541 1.055 1.678-.018 2.058-.312 5.603-4.202 1.78-1.954 3.233-3.614 3.233-3.666 0-.069-1.435-1.694-3.199-3.63-2.3-2.508-3.423-3.632-3.96-3.874-.812-.398-2.126-.467-2.956-.138zm18.467.12c-.502.26-1.764 1.505-3.943 3.891-1.763 1.937-3.199 3.562-3.199 3.631 0 .07 1.453 1.712 3.234 3.666 3.544 3.89 3.925 4.184 5.602 4.202 1.038 0 2.455-.588 2.542-1.055.017-.156-1.28-1.712-2.905-3.493-1.608-1.78-2.94-3.285-2.94-3.32 0-.034 1.332-1.539 2.94-3.32C22.72 6.91 24.017 5.352 24 5.214c-.087-.45-1.366-.968-2.473-1.038-.795-.034-1.21.035-1.763.312zM7.954 16.973c-2.144 2.369-3.908 4.374-3.943 4.46-.034.07.208.312.52.537.414.311.864.432 1.711.467 1.574.103 2.161-.26 4.15-2.508.864-.968 1.608-1.78 1.625-1.78s.761.812 1.643 1.798c2.023 2.248 2.559 2.576 4.132 2.49.848-.035 1.297-.156 1.712-.467.311-.225.553-.467.519-.536-.087-.26-7.92-8.819-8.058-8.801-.069 0-1.867 1.954-4.011 4.34z" />
</svg>
);
case 'docker':
// Official Docker Moby whale logo
return (
<svg class={iconClass} viewBox="0 0 24 24" fill="#2496ED">
<path d="M13.983 11.078h2.119a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.119a.185.185 0 0 0-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 0 0 .186-.186V3.574a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 0 0 .186-.186V6.29a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 0 0 .184-.186V6.29a.185.185 0 0 0-.185-.185H8.1a.185.185 0 0 0-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 0 0 .185-.186V6.29a.185.185 0 0 0-.185-.185H5.136a.186.186 0 0 0-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 0 0 .185-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.186.186 0 0 0-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 0 0-.75.748 11.376 11.376 0 0 0 .692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 0 0 3.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288z" />
</svg>
);
case 'kubernetes':
// Official Kubernetes logo (from simple-icons)
return (
<svg class={iconClass} viewBox="0 0 24 24" fill="#326CE5">
<path d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z" />
</svg>
);
default:
return null;
}
};
const runDiscovery = async () => {
setIsScanning(true);
setDiscoveredNodes([]);
const headers: Record<string, string> = { '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<ConnectStepProps> = (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<ConnectStepProps> = (props) => {
const connectNode = async (node?: DiscoveredNode) => {
setIsConnecting(true);
const headers: Record<string, string> = { '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<ConnectStepProps> = (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<ConnectStepProps> = (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"
>
<div class="text-3xl mb-2">{platform.icon}</div>
<div class="flex justify-center mb-2"><PlatformIcon platform={platform.id} /></div>
<div class="text-white font-medium">{platform.name}</div>
<div class="text-white/50 text-xs mt-1">{platform.desc}</div>
</button>
@ -148,7 +192,7 @@ export const ConnectStep: Component<ConnectStepProps> = (props) => {
>
</button>
<span class="text-xl">🖥</span>
<PlatformIcon platform="proxmox" class="w-6 h-6" />
<span class="text-white font-medium">Proxmox VE</span>
</div>
@ -159,14 +203,14 @@ export const ConnectStep: Component<ConnectStepProps> = (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"
>
<div class="text-lg mb-1">🔍 Auto-Discover</div>
<div class="text-lg mb-1 flex items-center gap-2"><svg class="w-5 h-5" 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> Auto-Discover</div>
<div class="text-sm text-white/60">Scan your network</div>
</button>
<button
onClick={() => setShowManualForm(true)}
class="bg-white/5 hover:bg-white/10 border border-white/20 rounded-xl p-4 text-left transition-all"
>
<div class="text-lg mb-1"> Manual Setup</div>
<div class="text-lg mb-1 flex items-center gap-2"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg> Manual Setup</div>
<div class="text-sm text-white/60">Enter details</div>
</button>
</div>
@ -240,7 +284,7 @@ export const ConnectStep: Component<ConnectStepProps> = (props) => {
{/* Docker placeholder */}
<Show when={selectedPlatform() === 'docker'}>
<div class="text-center py-8">
<div class="text-4xl mb-4">🐳</div>
<div class="flex justify-center mb-4"><PlatformIcon platform="docker" class="w-12 h-12" /></div>
<p class="text-white/70 mb-4">
Docker monitoring requires the Pulse Agent.<br />
Install it on your Docker host after setup.
@ -257,7 +301,7 @@ export const ConnectStep: Component<ConnectStepProps> = (props) => {
{/* Kubernetes placeholder */}
<Show when={selectedPlatform() === 'kubernetes'}>
<div class="text-center py-8">
<div class="text-4xl mb-4"></div>
<div class="flex justify-center mb-4"><PlatformIcon platform="kubernetes" class="w-12 h-12" /></div>
<p class="text-white/70 mb-4">
Kubernetes monitoring requires the Pulse Agent.<br />
Deploy via Helm after setup.

View file

@ -35,8 +35,8 @@ export const SecurityStep: Component<SecurityStepProps> = (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<SecurityStepProps> = (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
</button>
<button
type="button"
onClick={() => setUseCustomPassword(true)}
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'
}`}
>
Custom Password
Custom Password
</button>
</div>
@ -140,7 +140,7 @@ export const SecurityStep: Component<SecurityStepProps> = (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"
/>
<input
type="password"

View file

@ -99,22 +99,6 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
</p>
</div>
{/* Feature highlights */}
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2">🖥</div>
<div class="text-sm text-white/80">Proxmox</div>
</div>
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2">🐳</div>
<div class="text-sm text-white/80">Docker</div>
</div>
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2"></div>
<div class="text-sm text-white/80">Kubernetes</div>
</div>
</div>
{/* Bootstrap token unlock */}
<Show when={!props.isUnlocked}>
<div class="bg-white/10 backdrop-blur-xl rounded-2xl p-6 border border-white/20 text-left">