feat: Add multi-step Setup Wizard for Pulse 5.0 onboarding

- New SetupWizard component with 5-step flow:
  1. Welcome: Bootstrap token unlock, platform showcase
  2. Security: Admin account + API token creation
  3. Connect: Multi-platform infrastructure (Proxmox, Docker, K8s)
  4. Features: AI and auto-updates toggles
  5. Complete: Credentials display with copy/download

- Replaced FirstRunSetup with SetupWizard in Login.tsx
- Added Install Update button to UpdatesSettingsPanel
- Enhanced UpdatesSettingsPanel with update plan integration
- Added UpdateConfirmationModal to Settings for inline updates

Positions Pulse as a unified infrastructure monitoring platform,
not just a Proxmox-specific tool.
This commit is contained in:
rcourtman 2025-12-13 15:08:47 +00:00
parent dbac8632b7
commit cb06077fab
12 changed files with 1707 additions and 198 deletions

View file

@ -2,8 +2,8 @@ import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js
import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage';
const FirstRunSetup = lazy(() =>
import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })),
const SetupWizard = lazy(() =>
import('./SetupWizard').then((m) => ({ default: m.SetupWizard })),
);
interface LoginProps {
@ -339,9 +339,8 @@ export const Login: Component<LoginProps> = (props) => {
</div>
}
>
<FirstRunSetup
force={legacyDisableAuth()}
showLegacyBanner={legacyDisableAuth()}
<SetupWizard
onComplete={() => window.location.reload()}
/>
</Suspense>
</Show>

View file

@ -239,30 +239,54 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 dark:from-blue-900/10 dark:to-indigo-900/10 hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
{/* Archive/Download Box Icon */}
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
class="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
<div class="flex-1 min-w-0">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Export Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Download an encrypted backup of all nodes and settings
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Create an encrypted backup package
</p>
{/* Feature list */}
<ul class="space-y-1 mb-4">
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>All node connections & credentials</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>Alert thresholds & overrides</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>AES-256 encryption</span>
</li>
</ul>
<button
type="button"
onClick={() => {
@ -270,7 +294,7 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
props.setShowExportDialog(true);
}}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors inline-flex items-center gap-2"
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-sm font-medium rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -280,41 +304,65 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Export Backup
Create Backup
</button>
</div>
</div>
</div>
{/* Import Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-gray-50/50 to-slate-50/30 dark:from-gray-800/30 dark:to-slate-800/20 hover:border-gray-400 dark:hover:border-gray-600 transition-all duration-200">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-gray-500 to-slate-600 rounded-xl flex items-center justify-center shadow-lg shadow-gray-500/20">
{/* Upload/Restore Icon */}
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
class="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
<div class="flex-1 min-w-0">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Restore Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Upload a backup file to restore nodes and settings
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Import from an encrypted backup file
</p>
{/* Feature list */}
<ul class="space-y-1 mb-4">
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Merge or replace existing config</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Validates backup before applying</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Requires original passphrase</span>
</li>
</ul>
<button
type="button"
onClick={() => props.setShowImportDialog(true)}
class="px-3 py-1.5 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700 transition-colors inline-flex items-center gap-2"
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-gray-600 to-slate-600 text-white text-sm font-medium rounded-lg hover:from-gray-700 hover:to-slate-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -331,28 +379,46 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
</div>
</div>
{/* Important Notes */}
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<div class="flex gap-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
{/* Security Tips */}
<div class="mt-6 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/10 rounded-xl border border-amber-200 dark:border-amber-800/50">
<div class="flex gap-3">
{/* Shield Icon */}
<div class="flex-shrink-0 w-10 h-10 bg-amber-100 dark:bg-amber-900/40 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-xs text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">Important Notes</p>
<ul class="space-y-0.5 text-amber-600 dark:text-amber-400">
<li> Backups contain encrypted credentials and sensitive data</li>
<li> Use a strong passphrase to protect your backup</li>
<li> Store backup files securely and never share the passphrase</li>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Security Tips</p>
<ul class="space-y-2">
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
<span>Backups contain encrypted credentials and sensitive data</span>
</li>
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd" />
</svg>
<span>Use a strong passphrase (12+ characters, mix of letters, numbers, symbols)</span>
</li>
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2V7a5 5 0 00-5-5zm3 7V7a3 3 0 10-6 0v2h6z" />
</svg>
<span>Store backup files securely and never share the passphrase</span>
</li>
</ul>
</div>
</div>

View file

@ -30,6 +30,7 @@ import { DiagnosticsPanel } from './DiagnosticsPanel';
import { GeneralSettingsPanel } from './GeneralSettingsPanel';
import { NetworkSettingsPanel } from './NetworkSettingsPanel';
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
import { SecurityAuthPanel } from './SecurityAuthPanel';
import { APIAccessPanel } from './APIAccessPanel';
@ -764,6 +765,10 @@ const Settings: Component<SettingsProps> = (props) => {
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
// Update installation state
const [updatePlan, setUpdatePlan] = createSignal<import('@/api/updates').UpdatePlan | null>(null);
const [isInstallingUpdate, setIsInstallingUpdate] = createSignal(false);
const [showUpdateConfirmation, setShowUpdateConfirmation] = createSignal(false);
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
@ -1832,7 +1837,23 @@ const Settings: Component<SettingsProps> = (props) => {
const version = await UpdatesAPI.getVersion();
setVersionInfo(version);
// Also set it in the store so it's available globally
updateStore.checkForUpdates(); // This will load version info too
await updateStore.checkForUpdates(); // This will load version info too
// Fetch update info and plan from store
const storeInfo = updateStore.updateInfo();
if (storeInfo) {
setUpdateInfo(storeInfo);
// Fetch update plan if update is available
if (storeInfo.available && storeInfo.latestVersion) {
try {
const plan = await UpdatesAPI.getUpdatePlan(storeInfo.latestVersion);
setUpdatePlan(plan);
} catch (planError) {
logger.warn('Failed to fetch update plan on load', planError);
}
}
}
// Only use version.channel as fallback if user hasn't configured a preference
// The user's saved updateChannel preference should take priority
// Check the signal value since systemSettings is scoped to the previous try block
@ -2028,6 +2049,19 @@ const Settings: Component<SettingsProps> = (props) => {
const info = updateStore.updateInfo();
setUpdateInfo(info);
// Fetch update plan if update is available
if (info?.available && info.latestVersion) {
try {
const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion);
setUpdatePlan(plan);
} catch (planError) {
logger.warn('Failed to fetch update plan', planError);
setUpdatePlan(null);
}
} else {
setUpdatePlan(null);
}
// If update was dismissed, clear it so user can see it again
if (info?.available && updateStore.isDismissed()) {
updateStore.clearDismissed();
@ -2044,6 +2078,28 @@ const Settings: Component<SettingsProps> = (props) => {
}
};
// Handle install update from settings panel
const handleInstallUpdate = () => {
setShowUpdateConfirmation(true);
};
const handleConfirmUpdate = async () => {
const info = updateInfo();
if (!info?.downloadUrl) return;
setIsInstallingUpdate(true);
try {
await UpdatesAPI.applyUpdate(info.downloadUrl);
// Close confirmation - GlobalUpdateProgressWatcher will auto-open the progress modal
setShowUpdateConfirmation(false);
} catch (error) {
logger.error('Failed to start update', error);
showError('Failed to start update. Please try again.');
} finally {
setIsInstallingUpdate(false);
}
};
const handleExport = async () => {
if (!exportPassphrase()) {
const hasAuth = securityStatus()?.hasAuthentication;
@ -3463,6 +3519,9 @@ const Settings: Component<SettingsProps> = (props) => {
setAutoUpdateTime={setAutoUpdateTime}
checkForUpdates={checkForUpdates}
setHasUnsavedChanges={setHasUnsavedChanges}
updatePlan={updatePlan}
onInstallUpdate={handleInstallUpdate}
isInstalling={isInstallingUpdate}
/>
</Show>
@ -3826,6 +3885,22 @@ const Settings: Component<SettingsProps> = (props) => {
}}
/>
</Show >
{/* Update Confirmation Modal */}
<UpdateConfirmationModal
isOpen={showUpdateConfirmation()}
onClose={() => setShowUpdateConfirmation(false)}
onConfirm={handleConfirmUpdate}
currentVersion={versionInfo()?.version || 'Unknown'}
latestVersion={updateInfo()?.latestVersion || ''}
plan={updatePlan() || {
canAutoUpdate: false,
requiresRoot: false,
rollbackSupport: false,
}}
isApplying={isInstallingUpdate()}
/>
{/* Export Dialog */}
< Show when={showExportDialog()} >
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">

View file

@ -5,7 +5,8 @@ import RefreshCw from 'lucide-solid/icons/refresh-cw';
import CheckCircle from 'lucide-solid/icons/check-circle';
import ArrowRight from 'lucide-solid/icons/arrow-right';
import Package from 'lucide-solid/icons/package';
import type { UpdateInfo, VersionInfo } from '@/api/updates';
import Download from 'lucide-solid/icons/download';
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
interface UpdatesSettingsPanelProps {
versionInfo: Accessor<VersionInfo | null>;
@ -21,6 +22,10 @@ interface UpdatesSettingsPanelProps {
setAutoUpdateTime: Setter<string>;
checkForUpdates: () => Promise<void>;
setHasUnsavedChanges: Setter<boolean>;
// Update installation props
updatePlan: Accessor<UpdatePlan | null>;
onInstallUpdate: () => void;
isInstalling: Accessor<boolean>;
}
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
@ -209,186 +214,436 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
{/* Update available */}
<Show when={props.updateInfo()?.available}>
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl space-y-3">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h4 class="text-sm font-semibold text-green-900 dark:text-green-100">
How to install the update
</h4>
<div class="rounded-xl border border-green-200 dark:border-green-700 overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/10">
{/* Header */}
<div class="px-5 py-4 border-b border-green-200 dark:border-green-800/50 bg-gradient-to-r from-green-100/80 to-emerald-100/50 dark:from-green-900/40 dark:to-emerald-900/30">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="p-2 bg-gradient-to-br from-green-500 to-emerald-600 rounded-lg shadow-lg shadow-green-500/20">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div>
<h4 class="text-base font-semibold text-green-900 dark:text-green-100">
Update Available
</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Version {props.updateInfo()?.latestVersion} is ready to install
</p>
</div>
</div>
{/* Automated Install Button */}
<Show when={props.updatePlan()?.canAutoUpdate}>
<button
type="button"
onClick={props.onInstallUpdate}
disabled={props.isInstalling()}
class={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${props.isInstalling()
? 'bg-green-400 dark:bg-green-600 text-white cursor-not-allowed'
: 'bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg shadow-green-500/25 hover:shadow-green-500/40'
}`}
>
<Show when={props.isInstalling()} fallback={
<>
<Download class="w-4 h-4" />
Install Update
</>
}>
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
Installing...
</Show>
</button>
</Show>
</div>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900/40 rounded-lg space-y-2">
{/* Installation Steps */}
<div class="p-5 space-y-4">
{/* Manual Steps Header */}
<Show when={!props.updatePlan()?.canAutoUpdate}>
<div class="text-sm font-medium text-green-800 dark:text-green-200 mb-3">
Follow these steps to update manually:
</div>
</Show>
<Show when={props.updatePlan()?.canAutoUpdate}>
<div class="text-sm text-green-700 dark:text-green-300 mb-3">
Click "Install Update" above for automatic installation, or update manually:
</div>
</Show>
{/* ProxmoxVE LXC Installation */}
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
<p class="text-xs text-green-700 dark:text-green-300">
Type{' '}
<code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code>{' '}
in the LXC console
</p>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>Run these commands:</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
docker pull rcourtman/pulse:latest
<br />
docker restart pulse
</code>
</div>
</Show>
<Show
when={
props.versionInfo()?.deploymentType === 'systemd' ||
props.versionInfo()?.deploymentType === 'manual'
}
>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>
Click the "Install Update" button below, or download and install manually:
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Open your Pulse LXC console
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Run the update command:
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
update
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('update')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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="ml-8 text-xs text-green-600 dark:text-green-400">
The script will automatically download and install the latest version.
</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
curl -LO
https://github.com/rcourtman/Pulse/releases/download/
{props.updateInfo()?.latestVersion}/pulse-{props.updateInfo()?.latestVersion}
-linux-amd64.tar.gz
<br />
sudo systemctl stop pulse
<br />
sudo tar -xzf pulse-{props.updateInfo()?.latestVersion}
-linux-amd64.tar.gz -C /usr/local/bin pulse
<br />
sudo systemctl start pulse
</code>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull latest changes and rebuild
</p>
{/* Docker Installation */}
<Show when={props.versionInfo()?.deploymentType === 'docker' || (!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker)}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Pull the latest image
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
docker pull rcourtman/pulse:latest
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('docker pull rcourtman/pulse:latest')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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 class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Restart the container
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
docker restart pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('docker restart pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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="ml-8 text-xs text-green-600 dark:text-green-400">
Or use Docker Compose: <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">docker-compose pull && docker-compose up -d</code>
</p>
</div>
</Show>
<Show when={!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull the latest Pulse Docker image and recreate your container.
</p>
{/* Systemd/Manual Installation */}
<Show when={props.versionInfo()?.deploymentType === 'systemd' || props.versionInfo()?.deploymentType === 'manual'}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Stop the service
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
sudo systemctl stop pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('sudo systemctl stop pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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 class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Download and extract the new version
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700 whitespace-pre-wrap break-all">
{`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz
sudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`}
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText(`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz\nsudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`)}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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 class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">3</span>
Start the service
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
sudo systemctl start pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('sudo systemctl start pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
</Show>
{/* Development Installation */}
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Pull the latest changes
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
git pull origin main
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('git pull origin main')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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 class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Rebuild and restart
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
make build && make run
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('make build && make run')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
</Show>
</div>
{/* Release notes */}
{/* Release notes footer */}
<Show when={props.updateInfo()?.releaseNotes}>
<details class="mt-1">
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">
Release Notes
</summary>
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
{props.updateInfo()?.releaseNotes}
</pre>
</details>
<div class="px-5 py-3 border-t border-green-200 dark:border-green-800/50 bg-white/50 dark:bg-gray-900/30">
<details class="group">
<summary class="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300 cursor-pointer hover:text-green-800 dark:hover:text-green-200 transition-colors">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
View Release Notes
</summary>
<pre class="mt-3 p-4 text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 max-h-64 overflow-y-auto">
{props.updateInfo()?.releaseNotes}
</pre>
</details>
</div>
</Show>
</div>
</Show>
{/* Update settings */}
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-5">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Update Preferences
</h4>
{/* Update Channel */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Channel
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Choose between stable and release candidate versions
</p>
</div>
<select
value={props.updateChannel()}
onChange={(e) => {
props.setUpdateChannel(e.currentTarget.value as 'stable' | 'rc');
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
props.setUpdateChannel('stable');
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'stable'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<option value="stable">Stable</option>
<option value="rc">Release Candidate</option>
</select>
<div class="flex items-center gap-3">
<div class={`p-2 rounded-lg ${props.updateChannel() === 'stable'
? 'bg-green-100 dark:bg-green-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<svg class={`w-5 h-5 ${props.updateChannel() === 'stable'
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class={`text-sm font-semibold ${props.updateChannel() === 'stable'
? 'text-green-900 dark:text-green-100'
: 'text-gray-900 dark:text-gray-100'
}`}>Stable</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Production-ready releases
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => {
props.setUpdateChannel('rc');
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'rc'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div class="flex items-center gap-3">
<div class={`p-2 rounded-lg ${props.updateChannel() === 'rc'
? 'bg-purple-100 dark:bg-purple-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<svg class={`w-5 h-5 ${props.updateChannel() === 'rc'
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-500 dark:text-gray-400'
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div>
<p class={`text-sm font-semibold ${props.updateChannel() === 'rc'
? 'text-purple-900 dark:text-purple-100'
: 'text-gray-900 dark:text-gray-100'
}`}>Release Candidate</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Preview upcoming features
</p>
</div>
</div>
</button>
</div>
{/* Auto Update Toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Checks
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Automatically check for updates (installation is manual)
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={props.autoUpdateEnabled()}
onChange={(e) => {
props.setAutoUpdateEnabled(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Auto update options (shown when enabled) */}
<Show when={props.autoUpdateEnabled()}>
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
{/* Check Interval */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Automatic Update Checks
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Periodically check for new versions (installation is always manual)
</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={props.autoUpdateEnabled()}
onChange={(e) => {
props.setAutoUpdateEnabled(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Auto update options (shown when enabled) */}
<Show when={props.autoUpdateEnabled()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Check Interval */}
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Check Interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
How often to check for updates
</p>
<select
value={props.autoUpdateCheckInterval()}
onChange={(e) => {
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
props.setHasUnsavedChanges(true);
}}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="6">Every 6 hours</option>
<option value="12">Every 12 hours</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
</select>
</div>
<select
value={props.autoUpdateCheckInterval()}
onChange={(e) => {
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
props.setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="6">Every 6 hours</option>
<option value="12">Every 12 hours</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
</select>
</div>
{/* Check Time */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Check Time
{/* Check Time */}
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Preferred Time
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Preferred time to check for updates
</p>
<input
type="time"
value={props.autoUpdateTime()}
onChange={(e) => {
props.setAutoUpdateTime(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
<input
type="time"
value={props.autoUpdateTime()}
onChange={(e) => {
props.setAutoUpdateTime(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
</div>
</Show>
</Show>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,141 @@
import { Component, createSignal, Show, onMount } 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 interface WizardState {
// Security
username: string;
password: string;
apiToken: string;
// Node
nodeAdded: boolean;
nodeName: string;
// Features
aiEnabled: boolean;
autoUpdatesEnabled: boolean;
}
interface SetupWizardProps {
onComplete: () => void;
bootstrapToken?: string;
isUnlocked?: boolean;
}
export const SetupWizard: Component<SetupWizardProps> = (props) => {
const [currentStep, setCurrentStep] = createSignal<WizardStep>('welcome');
const [wizardState, setWizardState] = createSignal<WizardState>({
username: 'admin',
password: '',
apiToken: '',
nodeAdded: false,
nodeName: '',
aiEnabled: false,
autoUpdatesEnabled: true,
});
const [bootstrapToken, setBootstrapToken] = createSignal(props.bootstrapToken || '');
const [isUnlocked, setIsUnlocked] = createSignal(props.isUnlocked || false);
const steps: WizardStep[] = ['welcome', 'security', 'connect', 'features', 'complete'];
const currentStepIndex = () => steps.indexOf(currentStep());
const nextStep = () => {
const idx = currentStepIndex();
if (idx < steps.length - 1) {
setCurrentStep(steps[idx + 1]);
}
};
const prevStep = () => {
const idx = currentStepIndex();
if (idx > 0) {
setCurrentStep(steps[idx - 1]);
}
};
const updateState = (updates: Partial<WizardState>) => {
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">
{/* Background decoration */}
<div class="fixed inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl" />
<div class="absolute top-1/2 -left-40 w-80 h-80 bg-indigo-500/20 rounded-full blur-3xl" />
<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'}>
<div class="relative z-10 pt-8 px-4">
<StepIndicator
steps={['Security', 'Connect', 'Features']}
currentStep={currentStepIndex() - 1}
/>
</div>
</Show>
{/* Main content */}
<div class="flex-1 flex items-center justify-center p-4 relative z-10">
<div class="w-full max-w-2xl">
<Show when={currentStep() === 'welcome'}>
<WelcomeStep
onNext={nextStep}
bootstrapToken={bootstrapToken()}
setBootstrapToken={setBootstrapToken}
isUnlocked={isUnlocked()}
setIsUnlocked={setIsUnlocked}
/>
</Show>
<Show when={currentStep() === 'security'}>
<SecurityStep
state={wizardState()}
updateState={updateState}
bootstrapToken={bootstrapToken()}
onNext={nextStep}
onBack={prevStep}
/>
</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()}
onComplete={props.onComplete}
/>
</Show>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,37 @@
import { Component } from 'solid-js';
interface StepIndicatorProps {
steps: string[];
currentStep: number;
}
export const StepIndicator: Component<StepIndicatorProps> = (props) => {
return (
<div class="flex items-center justify-center gap-2">
{props.steps.map((step, index) => (
<div class="flex items-center">
<div class={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${index < props.currentStep
? 'bg-green-500/20 text-green-300'
: index === props.currentStep
? 'bg-blue-500/30 text-white border border-blue-400/50'
: 'bg-white/10 text-white/50'
}`}>
<span class={`w-5 h-5 flex items-center justify-center rounded-full text-xs ${index < props.currentStep
? 'bg-green-500 text-white'
: index === props.currentStep
? 'bg-blue-500 text-white'
: 'bg-white/20 text-white/50'
}`}>
{index < props.currentStep ? '✓' : index + 1}
</span>
<span class="hidden sm:inline">{step}</span>
</div>
{index < props.steps.length - 1 && (
<div class={`w-8 h-0.5 mx-1 ${index < props.currentStep ? 'bg-green-500/50' : 'bg-white/20'
}`} />
)}
</div>
))}
</div>
);
};

View file

@ -0,0 +1,3 @@
export { SetupWizard } from './SetupWizard';
export { StepIndicator } from './StepIndicator';
export type { WizardState, WizardStep } from './SetupWizard';

View file

@ -0,0 +1,155 @@
import { Component, createSignal } from 'solid-js';
import { copyToClipboard } from '@/utils/clipboard';
import { getPulseBaseUrl } from '@/utils/url';
import type { WizardState } from '../SetupWizard';
interface CompleteStepProps {
state: WizardState;
onComplete: () => void;
}
export const CompleteStep: Component<CompleteStepProps> = (props) => {
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
const handleCopy = async (type: 'password' | 'token') => {
const value = type === 'password' ? props.state.password : props.state.apiToken;
const success = await copyToClipboard(value);
if (success) {
setCopied(type);
setTimeout(() => setCopied(null), 2000);
}
};
const downloadCredentials = () => {
const baseUrl = getPulseBaseUrl();
const content = `Pulse Credentials
==================
Generated: ${new Date().toISOString()}
Web Login:
----------
URL: ${baseUrl}
Username: ${props.state.username}
Password: ${props.state.password}
API Token:
----------
${props.state.apiToken}
Example: curl -H "X-API-Token: ${props.state.apiToken}" ${baseUrl}/api/state
Keep these credentials secure!
`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-credentials-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
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">
<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>
<p class="text-xl text-green-200/80">
Pulse is ready to monitor your infrastructure
</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"
>
<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" />
</svg>
Download
</button>
</div>
<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>
{/* 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>
</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>
</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>
</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>
</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"
>
Launch Pulse
</button>
</div>
);
};

View file

@ -0,0 +1,293 @@
import { Component, createSignal, For, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
import type { WizardState } from '../SetupWizard';
interface ConnectStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
type Platform = 'proxmox' | 'docker' | 'kubernetes';
interface DiscoveredNode {
ip: string;
port: number;
type: string;
hostname?: string;
}
export const ConnectStep: Component<ConnectStepProps> = (props) => {
const [selectedPlatform, setSelectedPlatform] = createSignal<Platform | null>(null);
const [isScanning, setIsScanning] = createSignal(false);
const [discoveredNodes, setDiscoveredNodes] = createSignal<DiscoveredNode[]>([]);
const [showManualForm, setShowManualForm] = createSignal(false);
const [isConnecting, setIsConnecting] = createSignal(false);
// Manual form fields
const [host, setHost] = createSignal('');
const [port, setPort] = createSignal('8006');
const [tokenId, setTokenId] = createSignal('');
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'] },
];
const runDiscovery = async () => {
setIsScanning(true);
setDiscoveredNodes([]);
try {
const response = await fetch('/api/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subnet: 'auto' }),
});
if (!response.ok) throw new Error('Discovery failed');
// Poll for results
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 2000));
const results = await fetch('/api/discover/results');
if (results.ok) {
const data = await results.json();
if (data.nodes && data.nodes.length > 0) {
setDiscoveredNodes(data.nodes.filter((n: DiscoveredNode) =>
selectedPlatform() === 'proxmox' ? ['pve', 'pbs', 'pmg'].includes(n.type) : true
));
break;
}
}
}
if (discoveredNodes().length === 0) {
showSuccess('Scan complete - no nodes found. Try manual setup.');
}
} catch (error) {
showError('Discovery failed. Try manual setup.');
} finally {
setIsScanning(false);
}
};
const connectNode = async (node?: DiscoveredNode) => {
setIsConnecting(true);
try {
const nodeData = node ? {
type: node.type === 'pbs' ? 'pbs' : node.type === 'pmg' ? 'pmg' : 'pve',
name: node.hostname || node.ip,
host: node.ip,
port: node.port,
} : {
type: 'pve',
name: host().replace(/:\d+$/, ''),
host: host(),
port: parseInt(port()),
tokenId: tokenId(),
tokenValue: tokenSecret(),
};
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodeData),
});
if (!response.ok) throw new Error(await response.text());
props.updateState({ nodeAdded: true, nodeName: nodeData.name });
showSuccess(`Connected to ${nodeData.name}!`);
props.onNext();
} catch (error) {
showError(`Connection failed: ${error}`);
} finally {
setIsConnecting(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Connect Your Infrastructure</h2>
<p class="text-white/70 mt-1">Add monitoring for your systems</p>
</div>
<div class="p-6">
{/* Platform selection */}
<Show when={!selectedPlatform()}>
<div class="grid grid-cols-3 gap-4 mb-6">
<For each={platforms}>
{(platform) => (
<button
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="text-white font-medium">{platform.name}</div>
<div class="text-white/50 text-xs mt-1">{platform.desc}</div>
</button>
)}
</For>
</div>
</Show>
{/* Proxmox connection */}
<Show when={selectedPlatform() === 'proxmox'}>
<div class="space-y-4">
<div class="flex items-center gap-2 mb-4">
<button
onClick={() => setSelectedPlatform(null)}
class="text-white/60 hover:text-white"
>
</button>
<span class="text-xl">🖥</span>
<span class="text-white font-medium">Proxmox VE</span>
</div>
{/* Options */}
<div class="grid grid-cols-2 gap-3">
<button
onClick={runDiscovery}
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-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-sm text-white/60">Enter details</div>
</button>
</div>
{/* Scanning indicator */}
<Show when={isScanning()}>
<div class="bg-blue-500/10 border border-blue-400/20 rounded-xl p-4 text-center">
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full mb-2" />
<p class="text-blue-200">Scanning network...</p>
</div>
</Show>
{/* Discovered nodes */}
<Show when={discoveredNodes().length > 0}>
<div class="space-y-2">
<p class="text-sm text-white/60">Found {discoveredNodes().length} node(s):</p>
<For each={discoveredNodes()}>
{(node) => (
<button
onClick={() => connectNode(node)}
disabled={isConnecting()}
class="w-full bg-green-500/10 hover:bg-green-500/20 border border-green-400/30 rounded-xl p-3 flex items-center justify-between transition-all"
>
<div class="text-left">
<div class="text-white font-medium">{node.hostname || node.ip}</div>
<div class="text-white/50 text-sm">{node.ip}:{node.port}</div>
</div>
<span class="text-green-400">Connect </span>
</button>
)}
</For>
</div>
</Show>
{/* Manual form */}
<Show when={showManualForm()}>
<div class="space-y-3 bg-white/5 rounded-xl p-4">
<input
type="text"
value={host()}
onInput={(e) => setHost(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="Host (e.g., 192.168.1.100)"
/>
<input
type="text"
value={tokenId()}
onInput={(e) => setTokenId(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="API Token ID (e.g., root@pam!pulse)"
/>
<input
type="password"
value={tokenSecret()}
onInput={(e) => setTokenSecret(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="API Token Secret"
/>
<button
onClick={() => connectNode()}
disabled={isConnecting() || !host()}
class="w-full py-3 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isConnecting() ? 'Connecting...' : 'Connect'}
</button>
</div>
</Show>
</div>
</Show>
{/* Docker placeholder */}
<Show when={selectedPlatform() === 'docker'}>
<div class="text-center py-8">
<div class="text-4xl mb-4">🐳</div>
<p class="text-white/70 mb-4">
Docker monitoring requires the Pulse Agent.<br />
Install it on your Docker host after setup.
</p>
<button
onClick={props.onNext}
class="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl"
>
Continue Setup
</button>
</div>
</Show>
{/* Kubernetes placeholder */}
<Show when={selectedPlatform() === 'kubernetes'}>
<div class="text-center py-8">
<div class="text-4xl mb-4"></div>
<p class="text-white/70 mb-4">
Kubernetes monitoring requires the Pulse Agent.<br />
Deploy via Helm after setup.
</p>
<button
onClick={props.onNext}
class="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl"
>
Continue Setup
</button>
</div>
</Show>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl"
>
Back
</button>
<div class="flex-1" />
<button
onClick={props.onSkip}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white/60 rounded-xl"
>
Skip for now
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,138 @@
import { Component, createSignal } from 'solid-js';
import { showSuccess } from '@/utils/toast';
import { SettingsAPI } from '@/api/settings';
import type { WizardState } from '../SetupWizard';
interface FeaturesStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
}
export const FeaturesStep: Component<FeaturesStepProps> = (props) => {
const [aiEnabled, setAiEnabled] = createSignal(false);
const [autoUpdates, setAutoUpdates] = createSignal(true);
const [isSaving, setIsSaving] = createSignal(false);
const features = [
{
id: 'ai',
name: 'Pulse AI',
icon: '🤖',
desc: 'Intelligent monitoring assistant with auto-fix capabilities',
enabled: aiEnabled,
setEnabled: setAiEnabled,
badge: 'New in 5.0',
},
{
id: 'updates',
name: 'Automatic Updates',
icon: '🔄',
desc: 'Keep Pulse up-to-date automatically',
enabled: autoUpdates,
setEnabled: setAutoUpdates,
badge: null,
},
];
const handleContinue = async () => {
setIsSaving(true);
try {
// Save feature preferences
await SettingsAPI.updateSystemSettings({
aiEnabled: aiEnabled(),
autoUpdateEnabled: autoUpdates(),
});
props.updateState({
aiEnabled: aiEnabled(),
autoUpdatesEnabled: autoUpdates(),
});
showSuccess('Preferences saved!');
props.onNext();
} catch (error) {
// Continue anyway - settings can be changed later
props.onNext();
} finally {
setIsSaving(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Enable Features</h2>
<p class="text-white/70 mt-1">Customize your Pulse experience</p>
</div>
<div class="p-6 space-y-4">
{features.map((feature) => (
<button
onClick={() => feature.setEnabled(!feature.enabled())}
class={`w-full p-4 rounded-xl border transition-all text-left flex items-start gap-4 ${feature.enabled()
? 'bg-blue-500/20 border-blue-400/40'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}`}
>
<div class="text-3xl">{feature.icon}</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-white font-medium">{feature.name}</span>
{feature.badge && (
<span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-xs rounded-full">
{feature.badge}
</span>
)}
</div>
<p class="text-white/60 text-sm mt-1">{feature.desc}</p>
</div>
<div class={`w-12 h-7 rounded-full transition-all flex items-center px-1 ${feature.enabled() ? 'bg-blue-500' : 'bg-white/20'
}`}>
<div class={`w-5 h-5 rounded-full bg-white transition-transform ${feature.enabled() ? 'translate-x-5' : ''
}`} />
</div>
</button>
))}
{/* AI info box */}
<div class="bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-400/20 rounded-xl p-4">
<div class="flex items-start gap-3">
<div class="text-2xl"></div>
<div>
<p class="text-white font-medium">Pulse AI Features</p>
<p class="text-white/60 text-sm mt-1">
Chat assistant for infrastructure questions<br />
Patrol mode for proactive monitoring<br />
Auto-fix for common issues<br />
Predictive failure detection
</p>
<p class="text-white/40 text-xs mt-2">
Requires API key configuration in Settings AI after setup
</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl"
>
Back
</button>
<button
onClick={handleContinue}
disabled={isSaving()}
class="flex-1 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isSaving() ? 'Saving...' : 'Continue →'}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,191 @@
import { Component, createSignal, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
import type { WizardState } from '../SetupWizard';
interface SecurityStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
bootstrapToken: string;
onNext: () => void;
onBack: () => void;
}
export const SecurityStep: Component<SecurityStepProps> = (props) => {
const [username, setUsername] = createSignal(props.state.username || 'admin');
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [isSettingUp, setIsSettingUp] = createSignal(false);
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
let pass = '';
for (let i = 0; i < 16; i++) {
pass += chars.charAt(Math.floor(Math.random() * chars.length));
}
return pass;
};
const generateToken = (): string => {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const handleSetup = async () => {
if (useCustomPassword()) {
if (!password() || password().length < 12) {
showError('Password must be at least 12 characters');
return;
}
if (password() !== confirmPassword()) {
showError('Passwords do not match');
return;
}
}
setIsSettingUp(true);
const finalPassword = useCustomPassword() ? password() : generatePassword();
const token = generateToken();
try {
setApiClientToken(token);
const response = await fetch('/api/security/quick-setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Setup-Token': props.bootstrapToken,
},
credentials: 'include',
body: JSON.stringify({
username: username(),
password: finalPassword,
apiToken: token,
force: false,
setupToken: props.bootstrapToken,
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
props.updateState({
username: username(),
password: finalPassword,
apiToken: token,
});
showSuccess('Security configured!');
props.onNext();
} catch (error) {
showError(`Setup failed: ${error}`);
} finally {
setIsSettingUp(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Secure Your Dashboard</h2>
<p class="text-white/70 mt-1">Create your admin account</p>
</div>
<div class="p-6 space-y-6">
{/* Username */}
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Username</label>
<input
type="text"
value={username()}
onInput={(e) => setUsername(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="admin"
/>
</div>
{/* Password choice */}
<div>
<label class="block text-sm font-medium text-white/80 mb-3">Password</label>
<div class="grid grid-cols-2 gap-3 mb-4">
<button
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'
}`}
>
🔐 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'
}`}
>
Custom Password
</button>
</div>
<Show when={useCustomPassword()}>
<div class="space-y-3">
<input
type="password"
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)"
/>
<input
type="password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(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="Confirm password"
/>
</div>
</Show>
<Show when={!useCustomPassword()}>
<div class="bg-blue-500/20 border border-blue-400/30 rounded-xl p-4">
<p class="text-sm text-blue-200">
A secure 16-character password will be generated and shown after setup.
</p>
</div>
</Show>
</div>
{/* Info */}
<div class="bg-white/5 rounded-xl p-4">
<p class="text-sm text-white/60">
This creates your admin account and an API token for automation.
Credentials will be displayed once - save them securely!
</p>
</div>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
>
Back
</button>
<button
onClick={handleSetup}
disabled={isSettingUp()}
class="flex-1 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isSettingUp() ? 'Setting up...' : 'Create Account →'}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,156 @@
import { Component, createSignal, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
interface WelcomeStepProps {
onNext: () => void;
bootstrapToken: string;
setBootstrapToken: (token: string) => void;
isUnlocked: boolean;
setIsUnlocked: (unlocked: boolean) => void;
}
export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
const [isValidating, setIsValidating] = createSignal(false);
const [tokenPath, setTokenPath] = createSignal('');
const [isDocker, setIsDocker] = createSignal(false);
const [inContainer, setInContainer] = createSignal(false);
const [lxcCtid, setLxcCtid] = createSignal('');
// Fetch bootstrap info on mount
const fetchBootstrapInfo = async () => {
try {
const response = await fetch('/api/security/status');
if (response.ok) {
const data = await response.json();
if (data.bootstrapTokenPath) {
setTokenPath(data.bootstrapTokenPath);
setIsDocker(data.isDocker || false);
setInContainer(data.inContainer || false);
setLxcCtid(data.lxcCtid || '');
}
}
} catch (error) {
console.error('Failed to fetch bootstrap info:', error);
}
};
// Call on component load
fetchBootstrapInfo();
const handleUnlock = async () => {
if (!props.bootstrapToken.trim()) {
showError('Please enter the bootstrap token');
return;
}
setIsValidating(true);
try {
const response = await fetch('/api/security/validate-bootstrap-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: props.bootstrapToken.trim() }),
});
if (!response.ok) {
throw new Error('Invalid bootstrap token');
}
props.setIsUnlocked(true);
showSuccess('Token verified!');
props.onNext();
} catch (error) {
showError('Invalid bootstrap token. Please check and try again.');
} finally {
setIsValidating(false);
}
};
const getTokenCommand = () => {
const path = tokenPath() || '/etc/pulse/.bootstrap_token';
if (isDocker()) {
return `docker exec <container> cat ${path}`;
}
if (inContainer() && lxcCtid()) {
return `pct exec ${lxcCtid()} -- cat ${path}`;
}
if (inContainer()) {
return `pct exec <ctid> -- cat ${path}`;
}
return `cat ${path}`;
};
return (
<div class="text-center">
{/* Logo */}
<div class="mb-8">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 shadow-2xl shadow-blue-500/30 mb-6">
<svg width="56" height="56" viewBox="0 0 256 256" class="text-white">
<circle class="fill-current opacity-20" cx="128" cy="128" r="122" />
<circle class="fill-none stroke-current" stroke-width="14" cx="128" cy="128" r="84" opacity="0.9" />
<circle class="fill-current" cx="128" cy="128" r="26" />
</svg>
</div>
<h1 class="text-4xl font-bold text-white mb-3">
Welcome to Pulse
</h1>
<p class="text-xl text-blue-200/80">
Unified infrastructure monitoring
</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">
<h3 class="text-lg font-semibold text-white mb-2">Unlock Setup</h3>
<p class="text-sm text-white/70 mb-4">
Retrieve the bootstrap token from your host:
</p>
<div class="bg-black/30 rounded-lg p-3 font-mono text-sm text-green-400 mb-4">
{getTokenCommand()}
</div>
<input
type="text"
value={props.bootstrapToken}
onInput={(e) => props.setBootstrapToken(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
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 font-mono"
placeholder="Paste your bootstrap token"
autofocus
/>
<button
onClick={handleUnlock}
disabled={isValidating() || !props.bootstrapToken.trim()}
class="w-full mt-4 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all shadow-lg shadow-blue-500/25"
>
{isValidating() ? 'Validating...' : 'Continue →'}
</button>
</div>
</Show>
<Show when={props.isUnlocked}>
<button
onClick={props.onNext}
class="py-4 px-8 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white text-lg font-medium rounded-xl transition-all shadow-lg shadow-blue-500/25"
>
Get Started
</button>
</Show>
</div>
);
};