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 { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
||||
const FirstRunSetup = lazy(() =>
|
||||
import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })),
|
||||
const SetupWizard = lazy(() =>
|
||||
import('./SetupWizard').then((m) => ({ default: m.SetupWizard })),
|
||||
);
|
||||
|
||||
interface LoginProps {
|
||||
|
|
@ -339,9 +339,8 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<FirstRunSetup
|
||||
force={legacyDisableAuth()}
|
||||
showLegacyBanner={legacyDisableAuth()}
|
||||
<SetupWizard
|
||||
onComplete={() => window.location.reload()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -239,30 +239,54 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Export Section */}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 dark:from-blue-900/10 dark:to-indigo-900/10 hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
{/* Archive/Download Box Icon */}
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Export Configuration
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
|
||||
Download an encrypted backup of all nodes and settings
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Create an encrypted backup package
|
||||
</p>
|
||||
|
||||
{/* Feature list */}
|
||||
<ul class="space-y-1 mb-4">
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>All node connections & credentials</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Alert thresholds & overrides</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>AES-256 encryption</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -270,7 +294,7 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
|||
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
|
||||
props.setShowExportDialog(true);
|
||||
}}
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors inline-flex items-center gap-2"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-sm font-medium rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -280,41 +304,65 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
|||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Export Backup
|
||||
Create Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-gray-50/50 to-slate-50/30 dark:from-gray-800/30 dark:to-slate-800/20 hover:border-gray-400 dark:hover:border-gray-600 transition-all duration-200">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-gray-500 to-slate-600 rounded-xl flex items-center justify-center shadow-lg shadow-gray-500/20">
|
||||
{/* Upload/Restore Icon */}
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-600 dark:text-gray-400"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Restore Configuration
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
|
||||
Upload a backup file to restore nodes and settings
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Import from an encrypted backup file
|
||||
</p>
|
||||
|
||||
{/* Feature list */}
|
||||
<ul class="space-y-1 mb-4">
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Merge or replace existing config</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Validates backup before applying</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Requires original passphrase</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setShowImportDialog(true)}
|
||||
class="px-3 py-1.5 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700 transition-colors inline-flex items-center gap-2"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-gray-600 to-slate-600 text-white text-sm font-medium rounded-lg hover:from-gray-700 hover:to-slate-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -331,28 +379,46 @@ export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important Notes */}
|
||||
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div class="flex gap-2">
|
||||
<svg
|
||||
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{/* Security Tips */}
|
||||
<div class="mt-6 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/10 rounded-xl border border-amber-200 dark:border-amber-800/50">
|
||||
<div class="flex gap-3">
|
||||
{/* Shield Icon */}
|
||||
<div class="flex-shrink-0 w-10 h-10 bg-amber-100 dark:bg-amber-900/40 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
class="w-5 h-5 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-300">
|
||||
<p class="font-medium mb-1">Important Notes</p>
|
||||
<ul class="space-y-0.5 text-amber-600 dark:text-amber-400">
|
||||
<li>• Backups contain encrypted credentials and sensitive data</li>
|
||||
<li>• Use a strong passphrase to protect your backup</li>
|
||||
<li>• Store backup files securely and never share the passphrase</li>
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Security Tips</p>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Backups contain encrypted credentials and sensitive data</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Use a strong passphrase (12+ characters, mix of letters, numbers, symbols)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2V7a5 5 0 00-5-5zm3 7V7a3 3 0 10-6 0v2h6z" />
|
||||
</svg>
|
||||
<span>Store backup files securely and never share the passphrase</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { DiagnosticsPanel } from './DiagnosticsPanel';
|
|||
import { GeneralSettingsPanel } from './GeneralSettingsPanel';
|
||||
import { NetworkSettingsPanel } from './NetworkSettingsPanel';
|
||||
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
|
||||
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
|
||||
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
|
||||
import { SecurityAuthPanel } from './SecurityAuthPanel';
|
||||
import { APIAccessPanel } from './APIAccessPanel';
|
||||
|
|
@ -764,6 +765,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
|
||||
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
|
||||
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
|
||||
// Update installation state
|
||||
const [updatePlan, setUpdatePlan] = createSignal<import('@/api/updates').UpdatePlan | null>(null);
|
||||
const [isInstallingUpdate, setIsInstallingUpdate] = createSignal(false);
|
||||
const [showUpdateConfirmation, setShowUpdateConfirmation] = createSignal(false);
|
||||
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
|
||||
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
|
||||
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
|
||||
|
|
@ -1832,7 +1837,23 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const version = await UpdatesAPI.getVersion();
|
||||
setVersionInfo(version);
|
||||
// Also set it in the store so it's available globally
|
||||
updateStore.checkForUpdates(); // This will load version info too
|
||||
await updateStore.checkForUpdates(); // This will load version info too
|
||||
|
||||
// Fetch update info and plan from store
|
||||
const storeInfo = updateStore.updateInfo();
|
||||
if (storeInfo) {
|
||||
setUpdateInfo(storeInfo);
|
||||
// Fetch update plan if update is available
|
||||
if (storeInfo.available && storeInfo.latestVersion) {
|
||||
try {
|
||||
const plan = await UpdatesAPI.getUpdatePlan(storeInfo.latestVersion);
|
||||
setUpdatePlan(plan);
|
||||
} catch (planError) {
|
||||
logger.warn('Failed to fetch update plan on load', planError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only use version.channel as fallback if user hasn't configured a preference
|
||||
// The user's saved updateChannel preference should take priority
|
||||
// Check the signal value since systemSettings is scoped to the previous try block
|
||||
|
|
@ -2028,6 +2049,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const info = updateStore.updateInfo();
|
||||
setUpdateInfo(info);
|
||||
|
||||
// Fetch update plan if update is available
|
||||
if (info?.available && info.latestVersion) {
|
||||
try {
|
||||
const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion);
|
||||
setUpdatePlan(plan);
|
||||
} catch (planError) {
|
||||
logger.warn('Failed to fetch update plan', planError);
|
||||
setUpdatePlan(null);
|
||||
}
|
||||
} else {
|
||||
setUpdatePlan(null);
|
||||
}
|
||||
|
||||
// If update was dismissed, clear it so user can see it again
|
||||
if (info?.available && updateStore.isDismissed()) {
|
||||
updateStore.clearDismissed();
|
||||
|
|
@ -2044,6 +2078,28 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle install update from settings panel
|
||||
const handleInstallUpdate = () => {
|
||||
setShowUpdateConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmUpdate = async () => {
|
||||
const info = updateInfo();
|
||||
if (!info?.downloadUrl) return;
|
||||
|
||||
setIsInstallingUpdate(true);
|
||||
try {
|
||||
await UpdatesAPI.applyUpdate(info.downloadUrl);
|
||||
// Close confirmation - GlobalUpdateProgressWatcher will auto-open the progress modal
|
||||
setShowUpdateConfirmation(false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start update', error);
|
||||
showError('Failed to start update. Please try again.');
|
||||
} finally {
|
||||
setIsInstallingUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!exportPassphrase()) {
|
||||
const hasAuth = securityStatus()?.hasAuthentication;
|
||||
|
|
@ -3463,6 +3519,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
setAutoUpdateTime={setAutoUpdateTime}
|
||||
checkForUpdates={checkForUpdates}
|
||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||
updatePlan={updatePlan}
|
||||
onInstallUpdate={handleInstallUpdate}
|
||||
isInstalling={isInstallingUpdate}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
|
@ -3826,6 +3885,22 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}}
|
||||
/>
|
||||
</Show >
|
||||
|
||||
{/* Update Confirmation Modal */}
|
||||
<UpdateConfirmationModal
|
||||
isOpen={showUpdateConfirmation()}
|
||||
onClose={() => setShowUpdateConfirmation(false)}
|
||||
onConfirm={handleConfirmUpdate}
|
||||
currentVersion={versionInfo()?.version || 'Unknown'}
|
||||
latestVersion={updateInfo()?.latestVersion || ''}
|
||||
plan={updatePlan() || {
|
||||
canAutoUpdate: false,
|
||||
requiresRoot: false,
|
||||
rollbackSupport: false,
|
||||
}}
|
||||
isApplying={isInstallingUpdate()}
|
||||
/>
|
||||
|
||||
{/* Export Dialog */}
|
||||
< Show when={showExportDialog()} >
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
|||
import CheckCircle from 'lucide-solid/icons/check-circle';
|
||||
import ArrowRight from 'lucide-solid/icons/arrow-right';
|
||||
import Package from 'lucide-solid/icons/package';
|
||||
import type { UpdateInfo, VersionInfo } from '@/api/updates';
|
||||
import Download from 'lucide-solid/icons/download';
|
||||
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
|
||||
|
||||
interface UpdatesSettingsPanelProps {
|
||||
versionInfo: Accessor<VersionInfo | null>;
|
||||
|
|
@ -21,6 +22,10 @@ interface UpdatesSettingsPanelProps {
|
|||
setAutoUpdateTime: Setter<string>;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
setHasUnsavedChanges: Setter<boolean>;
|
||||
// Update installation props
|
||||
updatePlan: Accessor<UpdatePlan | null>;
|
||||
onInstallUpdate: () => void;
|
||||
isInstalling: Accessor<boolean>;
|
||||
}
|
||||
|
||||
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
|
||||
|
|
@ -209,186 +214,436 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
|
|||
|
||||
{/* Update available */}
|
||||
<Show when={props.updateInfo()?.available}>
|
||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h4 class="text-sm font-semibold text-green-900 dark:text-green-100">
|
||||
How to install the update
|
||||
</h4>
|
||||
<div class="rounded-xl border border-green-200 dark:border-green-700 overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/10">
|
||||
{/* Header */}
|
||||
<div class="px-5 py-4 border-b border-green-200 dark:border-green-800/50 bg-gradient-to-r from-green-100/80 to-emerald-100/50 dark:from-green-900/40 dark:to-emerald-900/30">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-gradient-to-br from-green-500 to-emerald-600 rounded-lg shadow-lg shadow-green-500/20">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-green-900 dark:text-green-100">
|
||||
Update Available
|
||||
</h4>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Version {props.updateInfo()?.latestVersion} is ready to install
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Automated Install Button */}
|
||||
<Show when={props.updatePlan()?.canAutoUpdate}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onInstallUpdate}
|
||||
disabled={props.isInstalling()}
|
||||
class={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${props.isInstalling()
|
||||
? 'bg-green-400 dark:bg-green-600 text-white cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg shadow-green-500/25 hover:shadow-green-500/40'
|
||||
}`}
|
||||
>
|
||||
<Show when={props.isInstalling()} fallback={
|
||||
<>
|
||||
<Download class="w-4 h-4" />
|
||||
Install Update
|
||||
</>
|
||||
}>
|
||||
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
Installing...
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/40 rounded-lg space-y-2">
|
||||
{/* Installation Steps */}
|
||||
<div class="p-5 space-y-4">
|
||||
{/* Manual Steps Header */}
|
||||
<Show when={!props.updatePlan()?.canAutoUpdate}>
|
||||
<div class="text-sm font-medium text-green-800 dark:text-green-200 mb-3">
|
||||
Follow these steps to update manually:
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.updatePlan()?.canAutoUpdate}>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 mb-3">
|
||||
Click "Install Update" above for automatic installation, or update manually:
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ProxmoxVE LXC Installation */}
|
||||
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Type{' '}
|
||||
<code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code>{' '}
|
||||
in the LXC console
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
|
||||
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
|
||||
<p>Run these commands:</p>
|
||||
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
|
||||
docker pull rcourtman/pulse:latest
|
||||
<br />
|
||||
docker restart pulse
|
||||
</code>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.versionInfo()?.deploymentType === 'systemd' ||
|
||||
props.versionInfo()?.deploymentType === 'manual'
|
||||
}
|
||||
>
|
||||
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
|
||||
<p>
|
||||
Click the "Install Update" button below, or download and install manually:
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
|
||||
Open your Pulse LXC console
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
|
||||
Run the update command:
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
update
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('update')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="ml-8 text-xs text-green-600 dark:text-green-400">
|
||||
The script will automatically download and install the latest version.
|
||||
</p>
|
||||
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
|
||||
curl -LO
|
||||
https://github.com/rcourtman/Pulse/releases/download/
|
||||
{props.updateInfo()?.latestVersion}/pulse-{props.updateInfo()?.latestVersion}
|
||||
-linux-amd64.tar.gz
|
||||
<br />
|
||||
sudo systemctl stop pulse
|
||||
<br />
|
||||
sudo tar -xzf pulse-{props.updateInfo()?.latestVersion}
|
||||
-linux-amd64.tar.gz -C /usr/local/bin pulse
|
||||
<br />
|
||||
sudo systemctl start pulse
|
||||
</code>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.versionInfo()?.deploymentType === 'development'}>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Pull latest changes and rebuild
|
||||
</p>
|
||||
|
||||
{/* Docker Installation */}
|
||||
<Show when={props.versionInfo()?.deploymentType === 'docker' || (!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker)}>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
|
||||
Pull the latest image
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
docker pull rcourtman/pulse:latest
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('docker pull rcourtman/pulse:latest')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
|
||||
Restart the container
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
docker restart pulse
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('docker restart pulse')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="ml-8 text-xs text-green-600 dark:text-green-400">
|
||||
Or use Docker Compose: <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">docker-compose pull && docker-compose up -d</code>
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker}>
|
||||
<p class="text-xs text-green-700 dark:text-green-300">
|
||||
Pull the latest Pulse Docker image and recreate your container.
|
||||
</p>
|
||||
|
||||
{/* Systemd/Manual Installation */}
|
||||
<Show when={props.versionInfo()?.deploymentType === 'systemd' || props.versionInfo()?.deploymentType === 'manual'}>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
|
||||
Stop the service
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
sudo systemctl stop pulse
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('sudo systemctl stop pulse')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
|
||||
Download and extract the new version
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700 whitespace-pre-wrap break-all">
|
||||
{`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz
|
||||
sudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz\nsudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`)}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">3</span>
|
||||
Start the service
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
sudo systemctl start pulse
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('sudo systemctl start pulse')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Development Installation */}
|
||||
<Show when={props.versionInfo()?.deploymentType === 'development'}>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
|
||||
Pull the latest changes
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
git pull origin main
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('git pull origin main')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
|
||||
Rebuild and restart
|
||||
</div>
|
||||
<div class="ml-8 relative group">
|
||||
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
|
||||
make build && make run
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText('make build && make run')}
|
||||
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Release notes */}
|
||||
{/* Release notes footer */}
|
||||
<Show when={props.updateInfo()?.releaseNotes}>
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">
|
||||
Release Notes
|
||||
</summary>
|
||||
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
|
||||
{props.updateInfo()?.releaseNotes}
|
||||
</pre>
|
||||
</details>
|
||||
<div class="px-5 py-3 border-t border-green-200 dark:border-green-800/50 bg-white/50 dark:bg-gray-900/30">
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300 cursor-pointer hover:text-green-800 dark:hover:text-green-200 transition-colors">
|
||||
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
View Release Notes
|
||||
</summary>
|
||||
<pre class="mt-3 p-4 text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 max-h-64 overflow-y-auto">
|
||||
{props.updateInfo()?.releaseNotes}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Update settings */}
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-5">
|
||||
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Update Preferences
|
||||
</h4>
|
||||
|
||||
{/* Update Channel */}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Update Channel
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Choose between stable and release candidate versions
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={props.updateChannel()}
|
||||
onChange={(e) => {
|
||||
props.setUpdateChannel(e.currentTarget.value as 'stable' | 'rc');
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.setUpdateChannel('stable');
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
disabled={props.versionInfo()?.isDocker}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
|
||||
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'stable'
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<option value="rc">Release Candidate</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={`p-2 rounded-lg ${props.updateChannel() === 'stable'
|
||||
? 'bg-green-100 dark:bg-green-900/40'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<svg class={`w-5 h-5 ${props.updateChannel() === 'stable'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class={`text-sm font-semibold ${props.updateChannel() === 'stable'
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>Stable</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Production-ready releases
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.setUpdateChannel('rc');
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
disabled={props.versionInfo()?.isDocker}
|
||||
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'rc'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={`p-2 rounded-lg ${props.updateChannel() === 'rc'
|
||||
? 'bg-purple-100 dark:bg-purple-900/40'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<svg class={`w-5 h-5 ${props.updateChannel() === 'rc'
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class={`text-sm font-semibold ${props.updateChannel() === 'rc'
|
||||
? 'text-purple-900 dark:text-purple-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>Release Candidate</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Preview upcoming features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto Update Toggle */}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Update Checks
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Automatically check for updates (installation is manual)
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoUpdateEnabled()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateEnabled(e.currentTarget.checked);
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
disabled={props.versionInfo()?.isDocker}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto update options (shown when enabled) */}
|
||||
<Show when={props.autoUpdateEnabled()}>
|
||||
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
|
||||
{/* Check Interval */}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Automatic Update Checks
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Periodically check for new versions (installation is always manual)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoUpdateEnabled()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateEnabled(e.currentTarget.checked);
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
disabled={props.versionInfo()?.isDocker}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto update options (shown when enabled) */}
|
||||
<Show when={props.autoUpdateEnabled()}>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Check Interval */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Check Interval
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
How often to check for updates
|
||||
</p>
|
||||
<select
|
||||
value={props.autoUpdateCheckInterval()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="6">Every 6 hours</option>
|
||||
<option value="12">Every 12 hours</option>
|
||||
<option value="24">Daily</option>
|
||||
<option value="168">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
value={props.autoUpdateCheckInterval()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="6">Every 6 hours</option>
|
||||
<option value="12">Every 12 hours</option>
|
||||
<option value="24">Daily</option>
|
||||
<option value="168">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Check Time */}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Check Time
|
||||
{/* Check Time */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Preferred Time
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Preferred time to check for updates
|
||||
</p>
|
||||
<input
|
||||
type="time"
|
||||
value={props.autoUpdateTime()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateTime(e.currentTarget.value);
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={props.autoUpdateTime()}
|
||||
onChange={(e) => {
|
||||
props.setAutoUpdateTime(e.currentTarget.value);
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
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