1812 lines
97 KiB
TypeScript
1812 lines
97 KiB
TypeScript
import { Component, Show, createSignal, createEffect } from 'solid-js';
|
||
import { Portal } from 'solid-js/web';
|
||
import type { NodeConfig } from '@/types/nodes';
|
||
import type { SecurityStatus } from '@/types/config';
|
||
import { copyToClipboard } from '@/utils/clipboard';
|
||
import { showSuccess, showError } from '@/utils/toast';
|
||
import { NodesAPI } from '@/api/nodes';
|
||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||
import {
|
||
formField,
|
||
formHelpText,
|
||
controlClass,
|
||
labelClass,
|
||
formCheckbox,
|
||
} from '@/components/shared/Form';
|
||
|
||
interface NodeModalProps {
|
||
isOpen: boolean;
|
||
resetKey?: number;
|
||
onClose: () => void;
|
||
nodeType: 'pve' | 'pbs' | 'pmg';
|
||
editingNode?: NodeConfig;
|
||
onSave: (nodeData: Partial<NodeConfig>) => void;
|
||
showBackToDiscovery?: boolean;
|
||
onBackToDiscovery?: () => void;
|
||
securityStatus?: Partial<SecurityStatus>;
|
||
}
|
||
|
||
export const NodeModal: Component<NodeModalProps> = (props) => {
|
||
const [testResult, setTestResult] = createSignal<{
|
||
status: string;
|
||
message: string;
|
||
isCluster?: boolean;
|
||
} | null>(null);
|
||
const [isTesting, setIsTesting] = createSignal(false);
|
||
|
||
// Function to get clean form data
|
||
const getCleanFormData = (nodeType: 'pve' | 'pbs' | 'pmg' = props.nodeType) => ({
|
||
name: '',
|
||
host: '',
|
||
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'),
|
||
setupMode: 'auto' as 'auto' | 'manual',
|
||
user: '',
|
||
password: '',
|
||
tokenName: '',
|
||
tokenValue: '',
|
||
fingerprint: '',
|
||
verifySSL: true,
|
||
monitorPhysicalDisks: false,
|
||
monitorMailStats: true,
|
||
monitorQueues: true,
|
||
monitorQuarantine: true,
|
||
monitorDomainStats: false,
|
||
});
|
||
|
||
const [formData, setFormData] = createSignal(getCleanFormData());
|
||
const [quickSetupCommand, setQuickSetupCommand] = createSignal('');
|
||
|
||
// Track previous state to detect changes
|
||
let previousResetKey: number | undefined = undefined;
|
||
let previousNodeType: string | undefined = undefined;
|
||
|
||
// Reset form when conditions change
|
||
createEffect(() => {
|
||
const key = props.resetKey;
|
||
const nodeType = props.nodeType;
|
||
const isOpen = props.isOpen;
|
||
const editingNode = props.editingNode;
|
||
|
||
// Force reset if resetKey changed
|
||
if (key !== undefined && key !== previousResetKey) {
|
||
previousResetKey = key;
|
||
setFormData(() => getCleanFormData(props.nodeType));
|
||
setQuickSetupCommand('');
|
||
setTestResult(null);
|
||
return;
|
||
}
|
||
|
||
// Force reset if node type changed
|
||
if (nodeType !== previousNodeType && previousNodeType !== undefined) {
|
||
previousNodeType = nodeType;
|
||
setFormData(() => getCleanFormData(props.nodeType));
|
||
setQuickSetupCommand('');
|
||
setTestResult(null);
|
||
return;
|
||
}
|
||
previousNodeType = nodeType;
|
||
|
||
// Reset when opening for new node
|
||
if (isOpen && !editingNode) {
|
||
setFormData(() => getCleanFormData(props.nodeType));
|
||
setQuickSetupCommand('');
|
||
setTestResult(null);
|
||
}
|
||
});
|
||
|
||
// Generate setup URL when Quick Setup tab is shown
|
||
// Skip auto-generation to avoid race conditions - generate on-demand when copy is clicked
|
||
|
||
// Update form when editing node changes
|
||
createEffect(() => {
|
||
// Only populate form if we have an editing node AND it matches the current node type
|
||
// This prevents PVE data from being used when adding a PBS node
|
||
if (props.editingNode && props.editingNode.type === props.nodeType) {
|
||
const node = props.editingNode;
|
||
let username = ('user' in node ? node.user : '') || '';
|
||
let tokenName = node.tokenName || '';
|
||
|
||
const usesToken =
|
||
node.type !== 'pve' && tokenName && tokenName.includes('!') && !node.hasPassword;
|
||
if (usesToken) {
|
||
const parts = tokenName.split('!');
|
||
username = parts[0];
|
||
}
|
||
|
||
const pmgConfig =
|
||
node.type === 'pmg'
|
||
? (node as NodeConfig & {
|
||
monitorMailStats?: boolean;
|
||
monitorQueues?: boolean;
|
||
monitorQuarantine?: boolean;
|
||
monitorDomainStats?: boolean;
|
||
})
|
||
: undefined;
|
||
|
||
setFormData({
|
||
name: node.name || '',
|
||
host: node.host || '',
|
||
authType: node.hasPassword ? 'password' : 'token',
|
||
setupMode: 'auto',
|
||
user: username,
|
||
password: '',
|
||
tokenName: tokenName,
|
||
tokenValue: '',
|
||
fingerprint: ('fingerprint' in node ? node.fingerprint : '') || '',
|
||
verifySSL: node.verifySSL ?? true,
|
||
monitorPhysicalDisks:
|
||
node.type === 'pve'
|
||
? (node as NodeConfig & { monitorPhysicalDisks?: boolean }).monitorPhysicalDisks ?? false
|
||
: false,
|
||
monitorMailStats: pmgConfig?.monitorMailStats ?? true,
|
||
monitorQueues: pmgConfig?.monitorQueues ?? true,
|
||
monitorQuarantine: pmgConfig?.monitorQuarantine ?? true,
|
||
monitorDomainStats: pmgConfig?.monitorDomainStats ?? false,
|
||
});
|
||
}
|
||
});
|
||
|
||
const handleSubmit = (e: Event) => {
|
||
e.preventDefault();
|
||
const data = formData();
|
||
|
||
// Prepare data based on auth type
|
||
const nodeData: Partial<NodeConfig> = {
|
||
type: props.nodeType,
|
||
name: data.name || '', // Will be auto-generated by backend if empty
|
||
host: data.host,
|
||
fingerprint: data.fingerprint,
|
||
verifySSL: data.verifySSL,
|
||
};
|
||
|
||
if (data.authType === 'password') {
|
||
nodeData.user = data.user;
|
||
if (data.password) {
|
||
nodeData.password = data.password;
|
||
}
|
||
} else {
|
||
// For token auth, tokenName should already contain the full token ID
|
||
nodeData.tokenName = data.tokenName;
|
||
if (data.tokenValue) {
|
||
nodeData.tokenValue = data.tokenValue;
|
||
}
|
||
}
|
||
|
||
// Add monitor settings based on type
|
||
if (props.nodeType === 'pve') {
|
||
Object.assign(nodeData, {
|
||
monitorVMs: true,
|
||
monitorContainers: true,
|
||
monitorStorage: true,
|
||
monitorBackups: true,
|
||
monitorPhysicalDisks: data.monitorPhysicalDisks,
|
||
});
|
||
} else if (props.nodeType === 'pbs') {
|
||
Object.assign(nodeData, {
|
||
monitorDatastores: true,
|
||
monitorSyncJobs: true,
|
||
monitorVerifyJobs: true,
|
||
monitorPruneJobs: true,
|
||
monitorGarbageJobs: true,
|
||
});
|
||
} else {
|
||
Object.assign(nodeData, {
|
||
monitorMailStats: data.monitorMailStats,
|
||
monitorQueues: data.monitorQueues,
|
||
monitorQuarantine: data.monitorQuarantine,
|
||
monitorDomainStats: data.monitorDomainStats,
|
||
});
|
||
}
|
||
|
||
props.onSave(nodeData);
|
||
};
|
||
|
||
const updateField = (field: string, value: string | boolean) => {
|
||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||
if (field === 'host') {
|
||
setQuickSetupCommand('');
|
||
}
|
||
if (field === 'setupMode' && value !== 'auto') {
|
||
setQuickSetupCommand('');
|
||
}
|
||
};
|
||
|
||
const handleTestConnection = async () => {
|
||
const data = formData();
|
||
|
||
// If editing an existing node and no new credentials provided, use stored credentials
|
||
if (props.editingNode) {
|
||
const hasNewPassword = data.authType === 'password' && data.password;
|
||
const hasNewToken = data.authType === 'token' && data.tokenValue;
|
||
|
||
if (!hasNewPassword && !hasNewToken) {
|
||
// Use the existing node test endpoint which uses stored credentials
|
||
setIsTesting(true);
|
||
setTestResult(null);
|
||
|
||
try {
|
||
const result = await NodesAPI.testExistingNode(props.editingNode.id);
|
||
setTestResult({
|
||
status: 'success',
|
||
message: result.message || 'Connection successful',
|
||
});
|
||
} catch (error) {
|
||
console.error('Test existing node error:', error);
|
||
let errorMessage = 'Connection failed';
|
||
if (error instanceof Error) {
|
||
// Remove "API request failed: XXX " prefix if present
|
||
errorMessage = error.message.replace(/^API request failed: \d{3}\s*/, '');
|
||
}
|
||
setTestResult({
|
||
status: 'error',
|
||
message: errorMessage,
|
||
});
|
||
} finally {
|
||
setIsTesting(false);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Validate required fields for new nodes or when new credentials are provided
|
||
if (!data.host) {
|
||
setTestResult({ status: 'error', message: 'Host is required' });
|
||
return;
|
||
}
|
||
|
||
if (data.authType === 'password' && (!data.user || !data.password)) {
|
||
setTestResult({ status: 'error', message: 'Username and password are required' });
|
||
return;
|
||
}
|
||
|
||
if (data.authType === 'token' && (!data.tokenName || !data.tokenValue)) {
|
||
setTestResult({ status: 'error', message: 'Token ID and token value are required' });
|
||
return;
|
||
}
|
||
|
||
// Prepare test data
|
||
const testData: Partial<NodeConfig> = {
|
||
type: props.nodeType,
|
||
name: data.name || '', // Will be auto-generated by backend if empty
|
||
host: data.host,
|
||
fingerprint: data.fingerprint,
|
||
verifySSL: data.verifySSL,
|
||
};
|
||
|
||
if (data.authType === 'password') {
|
||
testData.user = data.user;
|
||
testData.password = data.password;
|
||
} else {
|
||
// For token auth, tokenName contains the full token ID
|
||
testData.tokenName = data.tokenName;
|
||
testData.tokenValue = data.tokenValue;
|
||
}
|
||
|
||
setIsTesting(true);
|
||
setTestResult(null);
|
||
|
||
try {
|
||
const result = await NodesAPI.testConnection(testData as NodeConfig);
|
||
setTestResult({
|
||
status: 'success',
|
||
message: result.message || 'Connection successful',
|
||
isCluster: result.isCluster,
|
||
});
|
||
} catch (error) {
|
||
console.error('Test connection error:', error);
|
||
let errorMessage = 'Connection failed';
|
||
if (error instanceof Error) {
|
||
// Remove "API request failed: XXX " prefix if present
|
||
errorMessage = error.message.replace(/^API request failed: \d{3}\s*/, '');
|
||
}
|
||
setTestResult({
|
||
status: 'error',
|
||
message: errorMessage,
|
||
});
|
||
} finally {
|
||
setIsTesting(false);
|
||
}
|
||
};
|
||
|
||
const nodeProductName = () => {
|
||
switch (props.nodeType) {
|
||
case 'pve':
|
||
return 'Proxmox VE';
|
||
case 'pbs':
|
||
return 'Proxmox Backup Server';
|
||
default:
|
||
return 'Proxmox Mail Gateway';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Portal>
|
||
<Show when={props.isOpen}>
|
||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||
<div class="flex min-h-screen items-center justify-center p-4">
|
||
{/* Backdrop */}
|
||
<div class="fixed inset-0 bg-black/50 transition-opacity" onClick={props.onClose} />
|
||
|
||
{/* Modal */}
|
||
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||
<form onSubmit={handleSubmit}>
|
||
{/* Header */}
|
||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||
<SectionHeader
|
||
title={`${props.editingNode ? 'Edit' : 'Add'} ${nodeProductName()} node`}
|
||
size="md"
|
||
class="flex-1"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={props.onClose}
|
||
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||
>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div class="p-6 space-y-6">
|
||
{/* Basic Information */}
|
||
<div>
|
||
<SectionHeader
|
||
title="Basic information"
|
||
size="sm"
|
||
class="mb-4"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div class={formField}>
|
||
<label class={labelClass('flex items-center gap-2')}>
|
||
Node Name <span class="text-xs text-gray-500">(optional)</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData().name}
|
||
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||
placeholder="Will auto-detect from hostname"
|
||
class={controlClass()}
|
||
/>
|
||
</div>
|
||
|
||
<div class={formField}>
|
||
<label class={labelClass('flex items-center gap-1')}>
|
||
Host URL <span class="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData().host}
|
||
onInput={(e) => updateField('host', e.currentTarget.value)}
|
||
placeholder={
|
||
props.nodeType === 'pve'
|
||
? 'https://proxmox.example.com:8006'
|
||
: props.nodeType === 'pbs'
|
||
? 'https://backup.example.com:8007'
|
||
: 'https://mail-gateway.example.com:8006'
|
||
}
|
||
required
|
||
class={controlClass()}
|
||
/>
|
||
<Show when={props.nodeType === 'pbs'}>
|
||
<p class={formHelpText}>
|
||
PBS requires HTTPS (not HTTP). Default port is 8007.
|
||
</p>
|
||
</Show>
|
||
<Show when={props.nodeType === 'pmg'}>
|
||
<p class={formHelpText}>
|
||
PMG API listens on HTTPS. Default port is 8006.
|
||
</p>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Authentication */}
|
||
<div>
|
||
<SectionHeader
|
||
title="Authentication"
|
||
size="sm"
|
||
class="mb-4"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
|
||
{/* Auth Type Selector */}
|
||
<div class="mb-4">
|
||
<div class="flex gap-4">
|
||
<label class="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="authType"
|
||
value="password"
|
||
checked={formData().authType === 'password'}
|
||
onChange={() => updateField('authType', 'password')}
|
||
class="mr-2"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||
Username & Password
|
||
</span>
|
||
</label>
|
||
<Show when={props.nodeType !== 'pmg'}>
|
||
<label class="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="authType"
|
||
value="token"
|
||
checked={formData().authType === 'token'}
|
||
onChange={() => updateField('authType', 'token')}
|
||
class="mr-2"
|
||
/>
|
||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||
API Token{' '}
|
||
<span class="text-green-600 dark:text-green-400 text-xs ml-1">
|
||
(Recommended)
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</Show>
|
||
</div>
|
||
<Show when={props.nodeType === 'pmg'}>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
Proxmox Mail Gateway does not support API tokens. Use a service account with
|
||
password authentication (for example <code>root@pam</code> or a dedicated{' '}
|
||
<code>api@pmg</code> user).
|
||
</p>
|
||
</Show>
|
||
</div>
|
||
|
||
{/* Password Auth Fields */}
|
||
<Show when={formData().authType === 'password'}>
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div class={formField}>
|
||
<label class={labelClass()}>
|
||
Username <span class="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData().user}
|
||
onInput={(e) => updateField('user', e.currentTarget.value)}
|
||
placeholder={
|
||
props.nodeType === 'pve'
|
||
? 'root@pam'
|
||
: props.nodeType === 'pbs'
|
||
? 'admin@pbs'
|
||
: 'root@pam'
|
||
}
|
||
required={formData().authType === 'password'}
|
||
class={controlClass()}
|
||
/>
|
||
<Show when={props.nodeType === 'pbs'}>
|
||
<p class={formHelpText}>Must include realm (e.g., admin@pbs).</p>
|
||
</Show>
|
||
<Show when={props.nodeType === 'pmg'}>
|
||
<p class={formHelpText}>Include realm (e.g., root@pam or api@pmg).</p>
|
||
</Show>
|
||
</div>
|
||
|
||
<div class={formField}>
|
||
<label class={labelClass('flex items-center gap-2')}>
|
||
Password
|
||
<Show when={!props.editingNode}>
|
||
<span class="text-red-500">*</span>
|
||
</Show>
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData().password}
|
||
onInput={(e) => updateField('password', e.currentTarget.value)}
|
||
placeholder={
|
||
props.editingNode ? 'Leave blank to keep existing' : 'Password'
|
||
}
|
||
required={formData().authType === 'password' && !props.editingNode}
|
||
class={controlClass()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Token Auth Fields */}
|
||
<Show when={formData().authType === 'token'}>
|
||
<div class="space-y-4">
|
||
{/* Token Creation Guide */}
|
||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||
<h5 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3 flex items-center gap-2">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<path d="M12 6v6l4 2"></path>
|
||
</svg>
|
||
Quick Token Setup
|
||
</h5>
|
||
|
||
<Show when={props.nodeType === 'pve'}>
|
||
<div class="space-y-3 text-xs">
|
||
{/* Tab buttons */}
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateField('setupMode', 'auto')}
|
||
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
|
||
formData().setupMode === 'auto'
|
||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
|
||
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
|
||
}`}
|
||
>
|
||
Quick Setup
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateField('setupMode', 'manual')}
|
||
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
|
||
formData().setupMode === 'manual'
|
||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
|
||
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
|
||
}`}
|
||
>
|
||
Manual Setup
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Setup Tab */}
|
||
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
|
||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
|
||
The command below creates the monitoring user, applies read-only
|
||
access, and adds the storage permissions Pulse needs to display
|
||
backups.
|
||
</p>
|
||
<p class="text-blue-800 dark:text-blue-200">
|
||
Just copy and run this one command on your Proxmox VE server:
|
||
</p>
|
||
|
||
{/* One-line command */}
|
||
<div class="space-y-3">
|
||
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
console.log('[Quick Setup] Copy button clicked');
|
||
try {
|
||
// Check if host is populated
|
||
if (!formData().host || formData().host.trim() === '') {
|
||
console.log('[Quick Setup] No host entered');
|
||
showError('Please enter the Host URL first');
|
||
return;
|
||
}
|
||
|
||
console.log('[Quick Setup] Generating setup URL for host:', formData().host);
|
||
// Always regenerate URL when host changes
|
||
const { apiFetch } = await import('@/utils/apiClient');
|
||
const response = await apiFetch('/api/setup-script-url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: 'pve',
|
||
host: formData().host,
|
||
backupPerms: true,
|
||
}),
|
||
});
|
||
|
||
console.log('[Quick Setup] API response:', response.status, response.ok);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
console.log('[Quick Setup] Setup data received:', data);
|
||
|
||
// Backend returns url, command, and expires
|
||
// Just copy the command - don't show the modal
|
||
if (data.command) {
|
||
console.log('[Quick Setup] Copying command to clipboard:', data.command);
|
||
setQuickSetupCommand(data.command);
|
||
const copied = await copyToClipboard(data.command);
|
||
console.log('[Quick Setup] Copy result:', copied);
|
||
if (copied) {
|
||
showSuccess('Command copied to clipboard!');
|
||
} else {
|
||
showError('Failed to copy to clipboard');
|
||
}
|
||
} else {
|
||
console.log('[Quick Setup] No command in response');
|
||
}
|
||
} else {
|
||
showError('Failed to generate setup URL');
|
||
}
|
||
} catch (error) {
|
||
console.error('[Quick Setup] Error:', error);
|
||
showError('Failed to copy command');
|
||
}
|
||
}}
|
||
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-gray-200 bg-gray-700 rounded-md transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<Show
|
||
when={quickSetupCommand().length > 0}
|
||
fallback={
|
||
<code class="text-blue-400">
|
||
{formData().host
|
||
? 'Click the button above to copy the setup command'
|
||
: '⚠️ Please enter the Host URL above first'}
|
||
</code>
|
||
}
|
||
>
|
||
<code class="block text-blue-100 whitespace-pre-wrap break-words">
|
||
{quickSetupCommand()}
|
||
</code>
|
||
</Show>
|
||
</div>
|
||
|
||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||
<div class="flex items-start space-x-2">
|
||
<svg
|
||
class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
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-semibold mb-1">
|
||
If the command doesn't work:
|
||
</p>
|
||
<p>
|
||
Your Proxmox server may not be able to reach Pulse. Use
|
||
the alternative method below.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Alternative: Download script */}
|
||
<details class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
|
||
Alternative: Download script manually
|
||
</summary>
|
||
<div class="mt-3 space-y-3">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
try {
|
||
const hostValue = formData().host || '';
|
||
const encodedHost = encodeURIComponent(hostValue);
|
||
const pulseUrl = encodeURIComponent(
|
||
window.location.origin,
|
||
);
|
||
const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}&backup_perms=true`;
|
||
|
||
// Fetch the script using the current session
|
||
const response = await fetch(scriptUrl);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch setup script');
|
||
}
|
||
const scriptContent = await response.text();
|
||
|
||
// Create a blob and download
|
||
const blob = new Blob([scriptContent], {
|
||
type: 'text/plain',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'pulse-setup.sh';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showSuccess(
|
||
'Script downloaded! Upload it to your server and run: bash pulse-setup.sh',
|
||
);
|
||
} catch (error) {
|
||
console.error('Failed to download script:', error);
|
||
showSuccess(
|
||
'Failed to download script. Please check your connection.',
|
||
);
|
||
}
|
||
}}
|
||
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm font-medium"
|
||
>
|
||
Download pulse-setup.sh
|
||
</button>
|
||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||
1. Click to download the script
|
||
<br />
|
||
2. Upload to your server via SCP/SFTP
|
||
<br />
|
||
3. Run:{' '}
|
||
<code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
||
bash pulse-setup.sh
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<p class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||
What this does:
|
||
</p>
|
||
<ul class="text-xs text-blue-700 dark:text-blue-300 space-y-1">
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>
|
||
Creates monitoring user{' '}
|
||
<code class="bg-blue-100 dark:bg-blue-800/50 px-1 rounded">
|
||
pulse-monitor@pam
|
||
</code>
|
||
</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>Generates secure API token</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>
|
||
Sets up monitoring permissions (PVEAuditor + guest agent
|
||
access + backup visibility)
|
||
</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>Automatically registers node with Pulse</span>
|
||
</li>
|
||
</ul>
|
||
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
|
||
✨ Fully automatic - no manual token copying needed!
|
||
</p>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Manual Setup Tab */}
|
||
<Show when={formData().setupMode === 'manual'}>
|
||
<p class="text-blue-800 dark:text-blue-200 mb-2">
|
||
Run these commands one by one on your Proxmox VE server:
|
||
</p>
|
||
|
||
<div class="space-y-3">
|
||
{/* Step 1: Create user */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
1. Create monitoring user:
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'pveum user add pulse-monitor@pam --comment "Pulse monitoring service"';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
pveum user add pulse-monitor@pam --comment "Pulse monitoring
|
||
service"
|
||
</code>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 2: Generate token */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
2. Generate API token (save the output!):
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'pveum user token add pulse-monitor@pam pulse-token --privsep 0';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
pveum user token add pulse-monitor@pam pulse-token --privsep
|
||
0
|
||
</code>
|
||
</div>
|
||
<p class="text-amber-600 dark:text-amber-400 text-xs mt-1">
|
||
⚠️ Copy the token value immediately - it won't be shown again!
|
||
</p>
|
||
</div>
|
||
|
||
{/* Step 3: Set permissions */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
3. Set up monitoring permissions:
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs mb-1">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'pveum aclmod / -user pulse-monitor@pam -role PVEAuditor && if pveum role list 2>/dev/null | grep -q "VM.Monitor" || pveum role add TestMonitor -privs VM.Monitor 2>/dev/null; then pveum role delete TestMonitor 2>/dev/null; pveum role delete PulseMonitor 2>/dev/null; pveum role add PulseMonitor -privs VM.Monitor; pveum aclmod / -user pulse-monitor@pam -role PulseMonitor; fi';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200 whitespace-pre-line">
|
||
{
|
||
'# Apply monitoring permissions - use built-in PVEAuditor role\npveum aclmod / -user pulse-monitor@pam -role PVEAuditor\n\n# Gather additional privileges for VM metrics\nEXTRA_PRIVS=()\n\n# Sys.Audit (Ceph, cluster status)\nif pveum role list 2>/dev/null | grep -q "Sys.Audit"; then\n EXTRA_PRIVS+=(\"Sys.Audit\")\nelse\n if pveum role add PulseTmpSysAudit -privs Sys.Audit 2>/dev/null; then\n EXTRA_PRIVS+=(\"Sys.Audit\")\n pveum role delete PulseTmpSysAudit 2>/dev/null\n fi\nfi\n\n# VM guest agent / monitor privileges\nVM_PRIV=\"\"\nif pveum role list 2>/dev/null | grep -q "VM.Monitor"; then\n VM_PRIV=\"VM.Monitor\"\nelif pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then\n VM_PRIV=\"VM.GuestAgent.Audit\"\nelse\n if pveum role add PulseTmpVMMonitor -privs VM.Monitor 2>/dev/null; then\n VM_PRIV=\"VM.Monitor\"\n pveum role delete PulseTmpVMMonitor 2>/dev/null\n elif pveum role add PulseTmpGuestAudit -privs VM.GuestAgent.Audit 2>/dev/null; then\n VM_PRIV=\"VM.GuestAgent.Audit\"\n pveum role delete PulseTmpGuestAudit 2>/dev/null\n fi\nfi\n\nif [ -n \"$VM_PRIV\" ]; then\n EXTRA_PRIVS+=(\"$VM_PRIV\")\nfi\n\nif [ ${#EXTRA_PRIVS[@]} -gt 0 ]; then\n PRIV_STRING=\"${EXTRA_PRIVS[*]}\"\n pveum role delete PulseMonitor 2>/dev/null\n pveum role add PulseMonitor -privs \"$PRIV_STRING\"\n pveum aclmod / -user pulse-monitor@pam -role PulseMonitor\nfi'
|
||
}
|
||
</code>
|
||
</div>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
pveum aclmod /storage -user pulse-monitor@pam -role
|
||
PVEDatastoreAdmin
|
||
</code>
|
||
</div>
|
||
<p class="text-gray-600 dark:text-gray-400 text-xs mt-1">
|
||
ℹ️ PVEAuditor gives read-only API access. PulseMonitor adds
|
||
Sys.Audit plus either VM.Monitor (PVE 8) or VM.GuestAgent.Audit
|
||
(PVE 9+) for disk and guest metrics. PVEDatastoreAdmin on
|
||
/storage adds backup visibility.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Step 4: Use in Pulse */}
|
||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-2">
|
||
<p class="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
|
||
4. Add to Pulse with:
|
||
</p>
|
||
<ul class="text-xs text-green-800 dark:text-green-200 ml-4 list-disc">
|
||
<li>
|
||
<strong>Token ID:</strong> pulse-monitor@pam!pulse-token
|
||
</li>
|
||
<li>
|
||
<strong>Token Value:</strong> [The value from step 2]
|
||
</li>
|
||
<li>
|
||
<strong>Host URL:</strong>{' '}
|
||
{formData().host || 'https://your-server:8006'}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
|
||
<Show when={props.nodeType === 'pbs'}>
|
||
<div class="space-y-3 text-xs">
|
||
{/* Tab buttons for PBS */}
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateField('setupMode', 'auto')}
|
||
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
|
||
formData().setupMode === 'auto' || !formData().setupMode
|
||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
|
||
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
|
||
}`}
|
||
>
|
||
Quick Setup
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateField('setupMode', 'manual')}
|
||
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
|
||
formData().setupMode === 'manual'
|
||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
|
||
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
|
||
}`}
|
||
>
|
||
Manual Setup
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Setup Tab for PBS */}
|
||
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
|
||
<p class="text-blue-800 dark:text-blue-200">
|
||
Just copy and run this one command on your Proxmox Backup Server:
|
||
</p>
|
||
|
||
{/* One-line command */}
|
||
<div class="space-y-3">
|
||
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||
<Show when={formData().host && formData().host.trim() !== ''}>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
try {
|
||
// Check if host is populated
|
||
if (!formData().host || formData().host.trim() === '') {
|
||
showError('Please enter the Host URL first');
|
||
return;
|
||
}
|
||
|
||
// Always regenerate URL when host changes
|
||
const { apiFetch } = await import('@/utils/apiClient');
|
||
const response = await apiFetch(
|
||
'/api/setup-script-url',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: 'pbs',
|
||
host: formData().host,
|
||
backupPerms: false,
|
||
}),
|
||
},
|
||
);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.command) {
|
||
setQuickSetupCommand(data.command);
|
||
if (await copyToClipboard(data.command)) {
|
||
showSuccess('Command copied to clipboard!');
|
||
} else {
|
||
showError('Failed to copy to clipboard');
|
||
}
|
||
}
|
||
} else {
|
||
showError('Failed to generate setup URL');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to copy command:', error);
|
||
showError('Failed to copy command');
|
||
}
|
||
}}
|
||
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-gray-200 bg-gray-700 rounded-md transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
</Show>
|
||
<Show
|
||
when={quickSetupCommand().length > 0}
|
||
fallback={
|
||
<code class="text-blue-400">
|
||
{formData().host
|
||
? 'Click the button above to copy the setup command'
|
||
: '⚠️ Please enter the Host URL above first'}
|
||
</code>
|
||
}
|
||
>
|
||
<code class="block text-blue-100 whitespace-pre-wrap break-words">
|
||
{quickSetupCommand()}
|
||
</code>
|
||
</Show>
|
||
</div>
|
||
|
||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||
<div class="flex items-start space-x-2">
|
||
<svg
|
||
class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
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-semibold mb-1">
|
||
If the command doesn't work:
|
||
</p>
|
||
<p>
|
||
Your PBS server may not be able to reach Pulse. Use the
|
||
alternative method below.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Alternative: Download script */}
|
||
<details class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
|
||
Alternative: Download script manually
|
||
</summary>
|
||
<div class="mt-3 space-y-3">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
try {
|
||
const hostValue = formData().host || '';
|
||
const encodedHost = encodeURIComponent(hostValue);
|
||
const pulseUrl = encodeURIComponent(
|
||
window.location.origin,
|
||
);
|
||
const scriptUrl = `/api/setup-script?type=pbs&host=${encodedHost}&pulse_url=${pulseUrl}`;
|
||
|
||
// Fetch the script using the current session
|
||
const response = await fetch(scriptUrl);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch setup script');
|
||
}
|
||
const scriptContent = await response.text();
|
||
|
||
// Create a blob and download
|
||
const blob = new Blob([scriptContent], {
|
||
type: 'text/plain',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'pulse-pbs-setup.sh';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
showSuccess(
|
||
'Script downloaded! Upload it to your PBS and run: bash pulse-pbs-setup.sh',
|
||
);
|
||
} catch (error) {
|
||
console.error('Failed to download script:', error);
|
||
showSuccess(
|
||
'Failed to download script. Please check your connection.',
|
||
);
|
||
}
|
||
}}
|
||
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors text-sm font-medium"
|
||
>
|
||
Download pulse-pbs-setup.sh
|
||
</button>
|
||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||
1. Click to download the script
|
||
<br />
|
||
2. Upload to your PBS via SCP/SFTP
|
||
<br />
|
||
3. Run:{' '}
|
||
<code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
||
bash pulse-pbs-setup.sh
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<p class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||
What this does:
|
||
</p>
|
||
<ul class="text-xs text-blue-700 dark:text-blue-300 space-y-1">
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>
|
||
Creates monitoring user{' '}
|
||
<code class="bg-blue-100 dark:bg-blue-800/50 px-1 rounded">
|
||
pulse-monitor@pbs
|
||
</code>
|
||
</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>Generates secure API token</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>
|
||
Sets up Audit permissions (read-only access to backups +
|
||
system stats)
|
||
</span>
|
||
</li>
|
||
<li class="flex items-start">
|
||
<span class="text-green-500 mr-2 mt-0.5">✓</span>
|
||
<span>Automatically registers server with Pulse</span>
|
||
</li>
|
||
</ul>
|
||
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
|
||
✨ Fully automatic - no manual token copying needed!
|
||
</p>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Manual Setup Tab for PBS */}
|
||
<Show when={formData().setupMode === 'manual'}>
|
||
<p class="text-blue-800 dark:text-blue-200 mb-2">
|
||
Run these commands one by one on your Proxmox Backup Server:
|
||
</p>
|
||
|
||
<div class="space-y-3">
|
||
{/* Step 1: Create user */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
1. Create monitoring user:
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'proxmox-backup-manager user create pulse-monitor@pbs';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
proxmox-backup-manager user create pulse-monitor@pbs
|
||
</code>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 2: Generate token */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
2. Generate API token (save the output!):
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
proxmox-backup-manager user generate-token pulse-monitor@pbs
|
||
pulse-token
|
||
</code>
|
||
</div>
|
||
<p class="text-amber-600 dark:text-amber-400 text-xs mt-1">
|
||
⚠️ Copy the token value immediately - it won't be shown again!
|
||
</p>
|
||
</div>
|
||
|
||
{/* Step 3: Set permissions */}
|
||
<div>
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
3. Set up read-only permissions (includes system stats):
|
||
</p>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs mb-1">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
'proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs';
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
proxmox-backup-manager acl update / Audit --auth-id
|
||
pulse-monitor@pbs
|
||
</code>
|
||
</div>
|
||
<div class="relative bg-white dark:bg-gray-800 rounded-md p-2 font-mono text-xs">
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
const cmd =
|
||
"proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'";
|
||
if (await copyToClipboard(cmd)) {
|
||
showSuccess('Command copied!');
|
||
}
|
||
}}
|
||
class="absolute top-1 right-1 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
title="Copy command"
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<rect
|
||
x="9"
|
||
y="9"
|
||
width="13"
|
||
height="13"
|
||
rx="2"
|
||
ry="2"
|
||
></rect>
|
||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
<code class="text-gray-800 dark:text-gray-200">
|
||
proxmox-backup-manager acl update / Audit --auth-id
|
||
'pulse-monitor@pbs!pulse-token'
|
||
</code>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 4: Use in Pulse */}
|
||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-2">
|
||
<p class="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
|
||
4. Add to Pulse with:
|
||
</p>
|
||
<ul class="text-xs text-green-800 dark:text-green-200 ml-4 list-disc">
|
||
<li>
|
||
<strong>Token ID:</strong> pulse-monitor@pbs!pulse-token
|
||
</li>
|
||
<li>
|
||
<strong>Token Value:</strong> [The value from step 2]
|
||
</li>
|
||
<li>
|
||
<strong>Host URL:</strong>{' '}
|
||
{formData().host || 'https://your-server:8007'}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* Permission Info Box */}
|
||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md p-2 mt-3">
|
||
<p class="text-xs font-semibold text-amber-800 dark:text-amber-200 mb-1">
|
||
About PBS Permissions:
|
||
</p>
|
||
<ul class="text-xs text-amber-700 dark:text-amber-300 space-y-0.5">
|
||
<li>
|
||
<strong>Basic (DatastoreAudit):</strong> View backups only
|
||
</li>
|
||
<li>
|
||
<strong>Enhanced (Audit on /):</strong> View backups +
|
||
CPU/memory/uptime stats
|
||
</li>
|
||
<li class="text-amber-600 dark:text-amber-400">
|
||
→ We use Enhanced for better monitoring visibility
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
<Show when={props.nodeType === 'pmg'}>
|
||
<div class="space-y-3 text-xs text-gray-700 dark:text-gray-200">
|
||
<p>
|
||
Generate a dedicated API token in <strong>Configuration → API Tokens</strong> on your
|
||
Mail Gateway. We recommend creating a service user such as <code class="font-mono">pulse-monitor@pmg</code>
|
||
with <em>Auditor</em> privileges.
|
||
</p>
|
||
<ol class="list-decimal ml-4 space-y-1">
|
||
<li>Click <em>Add</em> and choose the service user (or create one if needed).</li>
|
||
<li>Enable <em>Privilege Separation</em> and assign the <em>Auditor</em> role.</li>
|
||
<li>Copy the generated Token ID (e.g. <code class="font-mono">pulse-monitor@pmg!pulse-edge</code>) and the secret value into the fields below.</li>
|
||
</ol>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
Pulse only requires read-only access. Avoid granting administrator permissions to the token.
|
||
</p>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
|
||
{/* Token Input Fields */}
|
||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div class={formField}>
|
||
<label class={labelClass()}>
|
||
Token ID <span class="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData().tokenName}
|
||
onInput={(e) => updateField('tokenName', e.currentTarget.value)}
|
||
placeholder={
|
||
props.nodeType === 'pve'
|
||
? 'pulse-monitor@pam!pulse-token'
|
||
: 'pulse-monitor@pbs!pulse-token'
|
||
}
|
||
required={formData().authType === 'token'}
|
||
class={controlClass('font-mono')}
|
||
/>
|
||
<p class={formHelpText}>
|
||
Full token ID from Proxmox (user@realm!tokenname).
|
||
</p>
|
||
</div>
|
||
|
||
<div class={formField}>
|
||
<label class={labelClass('flex items-center gap-2')}>
|
||
Token Value
|
||
<Show when={!props.editingNode}>
|
||
<span class="text-red-500">*</span>
|
||
</Show>
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={formData().tokenValue}
|
||
onInput={(e) => updateField('tokenValue', e.currentTarget.value)}
|
||
placeholder={
|
||
props.editingNode
|
||
? 'Leave blank to keep existing'
|
||
: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||
}
|
||
required={formData().authType === 'token' && !props.editingNode}
|
||
class={controlClass('font-mono')}
|
||
/>
|
||
<p class={formHelpText}>
|
||
The secret value shown when creating the token.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
|
||
{/* SSL Settings */}
|
||
<div>
|
||
<SectionHeader
|
||
title="SSL settings"
|
||
size="sm"
|
||
class="mb-4"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<div class="space-y-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().verifySSL}
|
||
onChange={(e) => updateField('verifySSL', e.currentTarget.checked)}
|
||
class={formCheckbox}
|
||
/>
|
||
Verify SSL certificate
|
||
</label>
|
||
|
||
<div class={formField}>
|
||
<label class={labelClass()}>SSL Fingerprint (optional)</label>
|
||
<input
|
||
type="text"
|
||
value={formData().fingerprint}
|
||
onInput={(e) => updateField('fingerprint', e.currentTarget.value)}
|
||
placeholder="AA:BB:CC:DD:EE:FF:..."
|
||
class={controlClass('font-mono')}
|
||
/>
|
||
<p class={formHelpText}>
|
||
Useful when connecting to servers with self-signed certificates.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Monitoring Overview */}
|
||
<div>
|
||
<SectionHeader
|
||
title="Monitoring coverage"
|
||
size="sm"
|
||
class="mb-2"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||
{props.nodeType === 'pmg'
|
||
? 'Pulse captures mail flow analytics, rejection causes, and quarantine visibility without additional scripts.'
|
||
: 'Pulse automatically tracks all supported resources for this node — virtual machines, containers, storage usage, backups, and PBS job activity — so you always get full visibility without extra configuration.'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Physical Disk Monitoring - PVE only */}
|
||
<Show when={props.nodeType === 'pve'}>
|
||
<div>
|
||
<SectionHeader
|
||
title="Advanced monitoring"
|
||
size="sm"
|
||
class="mb-3"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().monitorPhysicalDisks}
|
||
onChange={(e) => updateField('monitorPhysicalDisks', e.currentTarget.checked)}
|
||
class={formCheckbox + ' mt-0.5'}
|
||
/>
|
||
<div>
|
||
<div>Monitor physical disk health (SMART)</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Polls disk SMART data every 5 minutes. Note: This will cause HDDs to spin up from standby.
|
||
If you have HDDs that should stay idle, leave this disabled.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</Show>
|
||
<Show when={props.nodeType === 'pmg'}>
|
||
<div class="space-y-3">
|
||
<SectionHeader
|
||
title="Data collection"
|
||
size="sm"
|
||
class="mb-1"
|
||
titleClass="text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||
Control which PMG data sets Pulse ingests. Disable individual collectors if you want to limit API usage.
|
||
</p>
|
||
|
||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().monitorMailStats}
|
||
onChange={(e) => updateField('monitorMailStats', e.currentTarget.checked)}
|
||
class={formCheckbox + ' mt-0.5'}
|
||
/>
|
||
<div>
|
||
<div>Mail statistics & trends</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Total mail volume, inbound/outbound breakdown, spam and virus counts.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().monitorQueues}
|
||
onChange={(e) => updateField('monitorQueues', e.currentTarget.checked)}
|
||
class={formCheckbox + ' mt-0.5'}
|
||
/>
|
||
<div>
|
||
<div>Queue health insights</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Track Postfix queue depth and rejection trends to spot delivery bottlenecks.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().monitorQuarantine}
|
||
onChange={(e) => updateField('monitorQuarantine', e.currentTarget.checked)}
|
||
class={formCheckbox + ' mt-0.5'}
|
||
/>
|
||
<div>
|
||
<div>Quarantine totals</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Mirror PMG quarantine sizes for spam, virus, and attachment buckets.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={formData().monitorDomainStats}
|
||
onChange={(e) => updateField('monitorDomainStats', e.currentTarget.checked)}
|
||
class={formCheckbox + ' mt-0.5'}
|
||
/>
|
||
<div>
|
||
<div>Domain-level statistics</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Gather per-domain metrics for deeper mail routing analysis.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
|
||
{/* Test Result */}
|
||
<Show when={testResult()}>
|
||
{(() => {
|
||
const result = testResult();
|
||
console.log('Test result display:', {
|
||
status: result?.status,
|
||
message: result?.message,
|
||
});
|
||
return null;
|
||
})()}
|
||
<div
|
||
class={`mx-6 p-3 rounded-lg text-sm ${
|
||
testResult()?.status === 'success'
|
||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||
}`}
|
||
>
|
||
<div class="flex items-start gap-2">
|
||
<Show when={testResult()?.status === 'success'}>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
class="flex-shrink-0 mt-0.5"
|
||
>
|
||
<path d="M9 12l2 2 4-4"></path>
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
</svg>
|
||
</Show>
|
||
<Show when={testResult()?.status === 'error'}>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
class="flex-shrink-0 mt-0.5"
|
||
>
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||
</svg>
|
||
</Show>
|
||
<div>
|
||
<p>{testResult()?.message}</p>
|
||
<Show when={testResult()?.isCluster}>
|
||
<p class="mt-1 text-xs opacity-80">
|
||
✨ Cluster detected! All cluster nodes will be automatically added.
|
||
</p>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Footer */}
|
||
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||
<button
|
||
type="button"
|
||
onClick={handleTestConnection}
|
||
disabled={isTesting()}
|
||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||
</button>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<Show when={props.showBackToDiscovery && props.onBackToDiscovery}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
props.onBackToDiscovery!();
|
||
props.onClose();
|
||
}}
|
||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||
<polyline points="12 19 5 12 12 5"></polyline>
|
||
</svg>
|
||
Back to Discovery
|
||
</button>
|
||
</Show>
|
||
<button
|
||
type="button"
|
||
onClick={props.onClose}
|
||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
{props.editingNode ? 'Update' : 'Add'} Node
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</Portal>
|
||
);
|
||
};
|