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:
parent
dbac8632b7
commit
cb06077fab
12 changed files with 1707 additions and 198 deletions
|
|
@ -2,8 +2,8 @@ import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||||
|
|
||||||
const FirstRunSetup = lazy(() =>
|
const SetupWizard = lazy(() =>
|
||||||
import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })),
|
import('./SetupWizard').then((m) => ({ default: m.SetupWizard })),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
|
|
@ -339,9 +339,8 @@ export const Login: Component<LoginProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FirstRunSetup
|
<SetupWizard
|
||||||
force={legacyDisableAuth()}
|
onComplete={() => window.location.reload()}
|
||||||
showLegacyBanner={legacyDisableAuth()}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -239,30 +239,54 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Export Section */}
|
{/* Export Section */}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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-3">
|
<div class="flex items-start gap-4">
|
||||||
<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="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
|
<svg
|
||||||
class="w-5 h-5 text-blue-600 dark:text-blue-400"
|
class="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1 min-w-0">
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||||
Export Configuration
|
Export Configuration
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
Download an encrypted backup of all nodes and settings
|
Create an encrypted backup package
|
||||||
</p>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -270,7 +294,7 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
||||||
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
|
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
|
||||||
props.setShowExportDialog(true);
|
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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<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"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Export Backup
|
Create Backup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Section */}
|
{/* Import Section */}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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-3">
|
<div class="flex items-start gap-4">
|
||||||
<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="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
|
<svg
|
||||||
class="w-5 h-5 text-gray-600 dark:text-gray-400"
|
class="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1 min-w-0">
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||||
Restore Configuration
|
Restore Configuration
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
Upload a backup file to restore nodes and settings
|
Import from an encrypted backup file
|
||||||
</p>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setShowImportDialog(true)}
|
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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|
@ -331,28 +379,46 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Important Notes */}
|
{/* Security Tips */}
|
||||||
<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="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-2">
|
<div class="flex gap-3">
|
||||||
<svg
|
{/* Shield Icon */}
|
||||||
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
<div class="flex-shrink-0 w-10 h-10 bg-amber-100 dark:bg-amber-900/40 rounded-lg flex items-center justify-center">
|
||||||
fill="none"
|
<svg
|
||||||
stroke="currentColor"
|
class="w-5 h-5 text-amber-600 dark:text-amber-400"
|
||||||
viewBox="0 0 24 24"
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path
|
viewBox="0 0 24 24"
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
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"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<div class="text-xs text-amber-700 dark:text-amber-300">
|
stroke-linejoin="round"
|
||||||
<p class="font-medium mb-1">Important Notes</p>
|
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"
|
||||||
<ul class="space-y-0.5 text-amber-600 dark:text-amber-400">
|
/>
|
||||||
<li>• Backups contain encrypted credentials and sensitive data</li>
|
</svg>
|
||||||
<li>• Use a strong passphrase to protect your backup</li>
|
</div>
|
||||||
<li>• Store backup files securely and never share the passphrase</li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { DiagnosticsPanel } from './DiagnosticsPanel';
|
||||||
import { GeneralSettingsPanel } from './GeneralSettingsPanel';
|
import { GeneralSettingsPanel } from './GeneralSettingsPanel';
|
||||||
import { NetworkSettingsPanel } from './NetworkSettingsPanel';
|
import { NetworkSettingsPanel } from './NetworkSettingsPanel';
|
||||||
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
|
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
|
||||||
|
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
|
||||||
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
|
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
|
||||||
import { SecurityAuthPanel } from './SecurityAuthPanel';
|
import { SecurityAuthPanel } from './SecurityAuthPanel';
|
||||||
import { APIAccessPanel } from './APIAccessPanel';
|
import { APIAccessPanel } from './APIAccessPanel';
|
||||||
|
|
@ -764,6 +765,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
||||||
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
|
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
|
||||||
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
|
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 [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
|
||||||
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
|
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
|
||||||
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
|
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
|
||||||
|
|
@ -1832,7 +1837,23 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const version = await UpdatesAPI.getVersion();
|
const version = await UpdatesAPI.getVersion();
|
||||||
setVersionInfo(version);
|
setVersionInfo(version);
|
||||||
// Also set it in the store so it's available globally
|
// 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
|
// Only use version.channel as fallback if user hasn't configured a preference
|
||||||
// The user's saved updateChannel preference should take priority
|
// The user's saved updateChannel preference should take priority
|
||||||
// Check the signal value since systemSettings is scoped to the previous try block
|
// 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();
|
const info = updateStore.updateInfo();
|
||||||
setUpdateInfo(info);
|
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 update was dismissed, clear it so user can see it again
|
||||||
if (info?.available && updateStore.isDismissed()) {
|
if (info?.available && updateStore.isDismissed()) {
|
||||||
updateStore.clearDismissed();
|
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 () => {
|
const handleExport = async () => {
|
||||||
if (!exportPassphrase()) {
|
if (!exportPassphrase()) {
|
||||||
const hasAuth = securityStatus()?.hasAuthentication;
|
const hasAuth = securityStatus()?.hasAuthentication;
|
||||||
|
|
@ -3463,6 +3519,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
setAutoUpdateTime={setAutoUpdateTime}
|
setAutoUpdateTime={setAutoUpdateTime}
|
||||||
checkForUpdates={checkForUpdates}
|
checkForUpdates={checkForUpdates}
|
||||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||||
|
updatePlan={updatePlan}
|
||||||
|
onInstallUpdate={handleInstallUpdate}
|
||||||
|
isInstalling={isInstallingUpdate}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -3826,6 +3885,22 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Show >
|
</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 */}
|
{/* Export Dialog */}
|
||||||
< Show when={showExportDialog()} >
|
< Show when={showExportDialog()} >
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||||
import CheckCircle from 'lucide-solid/icons/check-circle';
|
import CheckCircle from 'lucide-solid/icons/check-circle';
|
||||||
import ArrowRight from 'lucide-solid/icons/arrow-right';
|
import ArrowRight from 'lucide-solid/icons/arrow-right';
|
||||||
import Package from 'lucide-solid/icons/package';
|
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 {
|
interface UpdatesSettingsPanelProps {
|
||||||
versionInfo: Accessor<VersionInfo | null>;
|
versionInfo: Accessor<VersionInfo | null>;
|
||||||
|
|
@ -21,6 +22,10 @@ interface UpdatesSettingsPanelProps {
|
||||||
setAutoUpdateTime: Setter<string>;
|
setAutoUpdateTime: Setter<string>;
|
||||||
checkForUpdates: () => Promise<void>;
|
checkForUpdates: () => Promise<void>;
|
||||||
setHasUnsavedChanges: Setter<boolean>;
|
setHasUnsavedChanges: Setter<boolean>;
|
||||||
|
// Update installation props
|
||||||
|
updatePlan: Accessor<UpdatePlan | null>;
|
||||||
|
onInstallUpdate: () => void;
|
||||||
|
isInstalling: Accessor<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
|
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
|
||||||
|
|
@ -209,186 +214,436 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
|
||||||
|
|
||||||
{/* Update available */}
|
{/* Update available */}
|
||||||
<Show when={props.updateInfo()?.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="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">
|
||||||
<div class="flex items-center gap-2">
|
{/* Header */}
|
||||||
<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">
|
<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">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="flex items-center justify-between gap-3">
|
||||||
</svg>
|
<div class="flex items-center gap-3">
|
||||||
<h4 class="text-sm font-semibold text-green-900 dark:text-green-100">
|
<div class="p-2 bg-gradient-to-br from-green-500 to-emerald-600 rounded-lg shadow-lg shadow-green-500/20">
|
||||||
How to install the update
|
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
</h4>
|
<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>
|
||||||
|
|
||||||
<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'}>
|
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
|
||||||
<p class="text-xs text-green-700 dark:text-green-300">
|
<div class="space-y-3">
|
||||||
Type{' '}
|
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
<code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code>{' '}
|
<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>
|
||||||
in the LXC console
|
Open your Pulse LXC console
|
||||||
</p>
|
</div>
|
||||||
</Show>
|
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
|
<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>
|
||||||
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
|
Run the update command:
|
||||||
<p>Run these commands:</p>
|
</div>
|
||||||
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
|
<div class="ml-8 relative group">
|
||||||
docker pull rcourtman/pulse:latest
|
<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">
|
||||||
<br />
|
update
|
||||||
docker restart pulse
|
</code>
|
||||||
</code>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</Show>
|
onClick={() => navigator.clipboard.writeText('update')}
|
||||||
<Show
|
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"
|
||||||
when={
|
title="Copy to clipboard"
|
||||||
props.versionInfo()?.deploymentType === 'systemd' ||
|
>
|
||||||
props.versionInfo()?.deploymentType === 'manual'
|
<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>
|
||||||
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
|
</button>
|
||||||
<p>
|
</div>
|
||||||
Click the "Install Update" button below, or download and install manually:
|
<p class="ml-8 text-xs text-green-600 dark:text-green-400">
|
||||||
|
The script will automatically download and install the latest version.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.versionInfo()?.deploymentType === 'development'}>
|
|
||||||
<p class="text-xs text-green-700 dark:text-green-300">
|
{/* Docker Installation */}
|
||||||
Pull latest changes and rebuild
|
<Show when={props.versionInfo()?.deploymentType === 'docker' || (!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker)}>
|
||||||
</p>
|
<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>
|
||||||
<Show when={!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker}>
|
|
||||||
<p class="text-xs text-green-700 dark:text-green-300">
|
{/* Systemd/Manual Installation */}
|
||||||
Pull the latest Pulse Docker image and recreate your container.
|
<Show when={props.versionInfo()?.deploymentType === 'systemd' || props.versionInfo()?.deploymentType === 'manual'}>
|
||||||
</p>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Release notes */}
|
{/* Release notes footer */}
|
||||||
<Show when={props.updateInfo()?.releaseNotes}>
|
<Show when={props.updateInfo()?.releaseNotes}>
|
||||||
<details class="mt-1">
|
<div class="px-5 py-3 border-t border-green-200 dark:border-green-800/50 bg-white/50 dark:bg-gray-900/30">
|
||||||
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">
|
<details class="group">
|
||||||
Release Notes
|
<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">
|
||||||
</summary>
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<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">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
{props.updateInfo()?.releaseNotes}
|
</svg>
|
||||||
</pre>
|
View Release Notes
|
||||||
</details>
|
</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Update settings */}
|
{/* 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 */}
|
{/* Update Channel */}
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<button
|
||||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
type="button"
|
||||||
Update Channel
|
onClick={() => {
|
||||||
</label>
|
props.setUpdateChannel('stable');
|
||||||
<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');
|
|
||||||
props.setHasUnsavedChanges(true);
|
props.setHasUnsavedChanges(true);
|
||||||
}}
|
}}
|
||||||
disabled={props.versionInfo()?.isDocker}
|
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>
|
<div class="flex items-center gap-3">
|
||||||
<option value="rc">Release Candidate</option>
|
<div class={`p-2 rounded-lg ${props.updateChannel() === 'stable'
|
||||||
</select>
|
? '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>
|
</div>
|
||||||
|
|
||||||
{/* Auto Update Toggle */}
|
{/* Auto Update Toggle */}
|
||||||
<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>
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div class="flex items-center gap-3">
|
||||||
Update Checks
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||||
</label>
|
<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">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
Automatically check for updates (installation is manual)
|
</svg>
|
||||||
</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="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>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<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
|
Check Interval
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
<select
|
||||||
How often to check for updates
|
value={props.autoUpdateCheckInterval()}
|
||||||
</p>
|
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>
|
</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 */}
|
{/* Check Time */}
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="space-y-2">
|
||||||
<div>
|
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
Preferred Time
|
||||||
Check Time
|
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
<input
|
||||||
Preferred time to check for updates
|
type="time"
|
||||||
</p>
|
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>
|
</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>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
141
frontend-modern/src/components/SetupWizard/SetupWizard.tsx
Normal file
141
frontend-modern/src/components/SetupWizard/SetupWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
frontend-modern/src/components/SetupWizard/StepIndicator.tsx
Normal file
37
frontend-modern/src/components/SetupWizard/StepIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
frontend-modern/src/components/SetupWizard/index.ts
Normal file
3
frontend-modern/src/components/SetupWizard/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { SetupWizard } from './SetupWizard';
|
||||||
|
export { StepIndicator } from './StepIndicator';
|
||||||
|
export type { WizardState, WizardStep } from './SetupWizard';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
293
frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx
Normal file
293
frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx
Normal file
156
frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue