feat: unify styling and improve cluster detection

This commit is contained in:
rcourtman
2025-09-28 18:46:52 +00:00
parent 6575a70465
commit 8e502dc7d8
51 changed files with 2724 additions and 1830 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
# Pulse Makefile for development
.PHONY: build run dev frontend backend all clean
.PHONY: build run dev frontend backend all clean dev-hot
# Build everything
all: frontend backend
@@ -32,6 +32,9 @@ run: build
dev: frontend backend
sudo systemctl restart pulse-backend
dev-hot:
./scripts/dev-hot.sh
# Clean build artifacts
clean:
rm -f pulse
@@ -39,4 +42,4 @@ clean:
# Quick rebuild and restart for development
restart: frontend backend
sudo systemctl restart pulse-backend
sudo systemctl restart pulse-backend
+7 -5
View File
@@ -504,12 +504,14 @@ journalctl -u pulse -f
### Quick Start - Hot Reload (Recommended)
```bash
# Best development experience with instant frontend updates
./scripts/hot-dev.sh
# Frontend: http://localhost:5173 (hot reload)
# Backend: http://localhost:7655
# Launch Vite + Go with automatic frontend proxying
make dev-hot
# Frontend HMR: http://127.0.0.1:5173
# Backend API: http://127.0.0.1:7655 (served via the Go app)
```
The backend now detects `FRONTEND_DEV_SERVER` and proxies requests straight to the Vite dev server. Edit files under `frontend-modern/src/` and the browser refreshes instantly—no manual rebuilds or service restarts required. Use `CTRL+C` to stop both processes.
### Production-like Development
```bash
# Watches files and rebuilds/embeds frontend into Go binary
@@ -565,4 +567,4 @@ See Pulse in action with our [complete screenshot gallery →](docs/SCREENSHOTS.
## License
MIT - See [LICENSE](LICENSE)
MIT - See [LICENSE](LICENSE)
+82
View File
@@ -0,0 +1,82 @@
# Frontend UI Style Guide
This project now ships a handful of shared primitives to keep typography and form layouts consistent. The snippets below show the preferred usage.
## Section headers
Use `SectionHeader` for any inline card titles, modal headings, or sub-section titles instead of ad-hoc `<h2>`/`<h3>` elements.
```tsx
import { SectionHeader } from '@/components/shared/SectionHeader';
<SectionHeader
label="Overview"
title="Cluster health"
description="Key metrics across every node"
size="sm" // sm | md | lg (defaults to md)
align="left" // left | center (defaults to left)
/>
```
Pass `titleClass`/`descriptionClass` when you need to tweak color or emphasis without rebuilding the layout.
## Empty states
Whenever a panel needs to show a loading, error, or "no data" treatment, render `EmptyState` inside a `Card`.
```tsx
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
<Card padding="lg" tone="info">
<EmptyState
align="center" // center | left (defaults to center)
tone="info" // default | info | success | warning | danger
icon={<MyIcon class="h-12 w-12 text-blue-400" />}
title="No backups yet"
description="Run your first job or adjust the filters to see activity."
actions={(
<Button onClick={openScheduler}>Open Scheduler</Button>
)}
/>
</Card>
```
Icons and actions are optional; omit them when not needed.
## Form helpers
Shared form styles live in `@/components/shared/Form`. Import the helpers and apply them to each field container, label, and control for a uniform look.
```tsx
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Host URL <span class="text-red-500">*</span>
</label>
<input
type="url"
placeholder="https://cluster.example.com:8006"
class={controlClass('px-2 py-1.5 font-mono')}
/>
<p class={`${formHelpText} mt-1`}>
Use HTTPS on port 8006 for Proxmox VE and 8007 for PBS.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class={formCheckbox} />
Enable this integration
</label>
```
Helper summary:
- `formField`: wraps a label + control stack.
- `labelClass(extra?)`: base typography for labels, with optional extra classes.
- `controlClass(extra?)`: base input styling; append sizing tweaks (`px-2 py-1.5`) as needed.
- `formHelpText`: small secondary text (validation notes, hints).
- `formCheckbox`: shared checkbox styling for toggles inside copy-heavy forms.
Stick to these helpers when building new settings panels, modals, or detail cards. If a component needs a variant that the helpers do not cover, extend them in `Form.ts` so the convention remains centralized.
+2 -1
View File
@@ -107,7 +107,8 @@ export class NotificationsAPI {
// Webhook management
static async getWebhooks(): Promise<Webhook[]> {
return apiFetchJSON(`${this.baseUrl}/webhooks`);
const data = await apiFetchJSON<Webhook[] | null>(`${this.baseUrl}/webhooks`);
return Array.isArray(data) ? data : [];
}
static async createWebhook(webhook: Omit<Webhook, 'id'>): Promise<Webhook> {
@@ -1,5 +1,8 @@
import { For, Show } from 'solid-js';
import type { CustomAlertRule } from '@/types/alerts';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { EmptyState } from '@/components/shared/EmptyState';
interface CustomRulesTabProps {
rules: CustomAlertRule[];
@@ -46,13 +49,15 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
return (
<div class="space-y-4">
{/* Header */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">Custom Alert Rules</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Custom rules apply specific thresholds to guests matching filter conditions.
Rules are evaluated in priority order (higher number = higher priority).
</p>
</div>
<Card padding="md">
<SectionHeader
title="Custom alert rules"
description="Custom rules apply specific thresholds to guests matching filter conditions. Rules are evaluated in priority order (higher number = higher priority)."
size="md"
titleClass="text-gray-800 dark:text-gray-200"
descriptionClass="text-sm text-gray-600 dark:text-gray-400"
/>
</Card>
{/* Priority Order Explanation */}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
@@ -73,21 +78,23 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
{/* Rules List */}
<Show when={props.rules.length > 0} fallback={
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<p class="text-sm text-gray-600 dark:text-gray-400">
No custom alert rules defined yet. Create rules from the Dashboard by applying filters and clicking "Create Alert".
</p>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
)}
title="No custom alert rules"
description="Create rules from the Dashboard by applying filters and choosing Create Alert."
/>
</Card>
}>
<div class="space-y-3">
<For each={props.rules.sort((a, b) => b.priority - a.priority)}>
{(rule) => (
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div class="p-4">
<div class="flex items-start justify-between mb-3">
<Card padding="md" class="overflow-hidden">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">{rule.name}</h4>
@@ -132,7 +139,7 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
</div>
</div>
<div class="space-y-2">
<div class="space-y-2">
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Filters:</span>
<div class="flex-1">
@@ -167,12 +174,11 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
</div>
</Show>
</div>
</div>
</div>
</Card>
)}
</For>
</div>
</Show>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
interface EmailProvider {
name: string;
@@ -14,8 +15,8 @@ interface EmailProvider {
interface EmailConfig {
enabled: boolean;
provider: string;
server: string; // Fixed: use 'server' not 'smtpHost'
port: number; // Fixed: use 'port' not 'smtpPort'
server: string;
port: number;
from: string;
username: string;
password: string;
@@ -37,10 +38,10 @@ interface EmailProviderSelectProps {
export function EmailProviderSelect(props: EmailProviderSelectProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showProviders, setShowProviders] = createSignal(false);
const [showAdvanced, setShowAdvanced] = createSignal(false);
// Load email providers
const [showInstructions, setShowInstructions] = createSignal(false);
// Load email providers once
createEffect(async () => {
try {
const data = await NotificationsAPI.getEmailProviders();
@@ -49,284 +50,261 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
console.error('Failed to load email providers:', err);
}
});
const selectProvider = (provider: EmailProvider) => {
const applyProvider = (provider: EmailProvider | undefined) => {
if (!provider) {
props.onChange({ ...props.config, provider: '' });
setShowInstructions(false);
return;
}
props.onChange({
...props.config,
provider: provider.name,
server: provider.smtpHost, // Fixed: use 'server' not 'smtpHost'
port: provider.smtpPort, // Fixed: use 'port' not 'smtpPort'
server: provider.smtpHost,
port: provider.smtpPort,
tls: provider.tls,
startTLS: provider.startTLS,
username: provider.name === 'SendGrid' ? 'apikey' : props.config.username,
});
setShowProviders(false);
setShowInstructions(true);
};
const currentProvider = () => providers().find(p => p.name === props.config.provider);
const handleProviderChange = (value: string) => {
if (!value) {
applyProvider(undefined);
return;
}
const provider = providers().find((p) => p.name === value);
applyProvider(provider);
};
const currentProvider = () => providers().find((p) => p.name === props.config.provider);
return (
<div class="space-y-6">
{/* Provider Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Email Provider
</label>
<button type="button"
onClick={() => setShowProviders(!showProviders())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
<div class="space-y-4 text-sm overflow-hidden">
<div class="grid w-full gap-2 sm:grid-cols-[150px_1fr] sm:items-center">
<label class={`${labelClass()} sm:text-right`}>Email provider</label>
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<select
value={props.config.provider}
onChange={(e) => handleProviderChange(e.currentTarget.value)}
class={`${controlClass('px-2 py-1.5')} sm:w-auto sm:min-w-[160px]`}
>
{props.config.provider || 'Select Provider'}
</button>
</div>
<Show when={showProviders()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<option value="">Manual configuration</option>
<For each={providers()}>
{(provider) => (
<button type="button"
onClick={() => selectProvider(provider)}
class={`p-3 text-left rounded-lg border transition-all ${
props.config.provider === provider.name
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
{provider.name}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{provider.smtpHost}:{provider.smtpPort}
</div>
</button>
<option value={provider.name}>
{provider.name} ({provider.smtpHost}:{provider.smtpPort})
</option>
)}
</For>
</div>
</Show>
<Show when={currentProvider()}>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentProvider()!.instructions}
</pre>
</div>
</Show>
</select>
<Show when={props.config.provider}>
<button
type="button"
onClick={() => {
const provider = currentProvider();
if (provider) applyProvider(provider);
}}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Reapply defaults
</button>
</Show>
</div>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Server
</label>
<Show when={currentProvider()}>
<div class="sm:hidden w-full">
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-300"
>
{showInstructions() ? 'Hide setup instructions' : 'Show setup instructions'}
</button>
<Show when={showInstructions()}>
<div class="mt-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs leading-relaxed text-blue-900 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200">
{currentProvider()!.instructions}
</div>
</Show>
</div>
<div class="hidden w-full sm:block sm:border-l-2 sm:border-blue-300 sm:pl-3 sm:text-xs sm:leading-relaxed sm:text-blue-800 dark:sm:border-blue-700 dark:sm:text-blue-200">
{currentProvider()!.instructions}
</div>
</Show>
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>SMTP server</label>
<input
type="text"
value={props.config.server}
onInput={(e) => props.onChange({ ...props.config, server: e.currentTarget.value })}
placeholder="smtp.example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Port
</label>
<div class={formField}>
<label class={labelClass()}>SMTP port</label>
<input
type="number"
value={props.config.port}
onInput={(e) => props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })}
placeholder="587"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Address
</label>
<div class={formField}>
<label class={labelClass()}>From address</label>
<input
type="email"
value={props.config.from}
onInput={(e) => props.onChange({ ...props.config, from: e.currentTarget.value })}
placeholder="noreply@example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reply-To Address
</label>
<div class={formField}>
<label class={labelClass()}>Reply-to address</label>
<input
type="email"
value={props.config.replyTo || ''}
onInput={(e) => props.onChange({ ...props.config, replyTo: e.currentTarget.value })}
placeholder="admin@example.com (optional)"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="admin@example.com"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<div class={formField}>
<label class={labelClass()}>Username</label>
<input
type="text"
value={props.config.username}
onInput={(e) => props.onChange({ ...props.config, username: e.currentTarget.value })}
placeholder={props.config.provider === 'SendGrid' ? 'apikey' : 'username@example.com'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password / API Key
</label>
<div class={formField}>
<label class={labelClass()}>Password / API key</label>
<input
type="password"
value={props.config.password}
onInput={(e) => props.onChange({ ...props.config, password: e.currentTarget.value })}
placeholder="••••••••"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
</div>
{/* Recipients */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Recipients (one per line)
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
Leave empty to send to the From address
</span>
</label>
<div class={formField}>
<label class={labelClass()}>Recipients (one per line)</label>
<textarea
value={props.config.to.join('\n')}
onInput={(e) => {
// Parse recipients - split by newlines and keep all non-empty lines
const rawValue = e.currentTarget.value;
const recipients = rawValue
const recipients = e.currentTarget.value
.split('\n')
.map(r => r.trim())
.filter(r => r.length > 0); // Keep all non-empty lines, validation happens on save
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
props.onChange({ ...props.config, to: recipients });
}}
onKeyDown={(e) => {
// Allow Enter key in textarea without triggering form submission
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
placeholder={`Leave empty to use ${props.config.from || 'From address'}\nOr add additional recipients:\nadmin@company.com\nops-team@company.com`}
rows="3"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
rows={3}
class={controlClass('px-2 py-1.5 font-mono leading-snug')}
placeholder={`Leave empty to use ${props.config.from || 'the from address'}\nOr add one recipient per line`}
/>
</div>
{/* Advanced Settings */}
<div>
<button type="button"
<div class="border-t border-gray-200 pt-3 dark:border-gray-700">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 flex items-center gap-1"
class="text-xs font-semibold uppercase tracking-wide text-gray-600 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg class={`w-4 h-4 transition-transform ${showAdvanced() ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Advanced Settings
{showAdvanced() ? 'Hide advanced options' : 'Show advanced options'}
</button>
<Show when={showAdvanced()}>
<div class="mt-4 space-y-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) => props.onChange({ ...props.config, tls: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use TLS</span>
</label>
</div>
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) => props.onChange({ ...props.config, startTLS: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rate Limit
</label>
<div class="flex items-center gap-2">
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) => props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })}
class="w-20 px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
/>
<span class="text-sm text-gray-600 dark:text-gray-400">/min</span>
</div>
<div class="mt-3 space-y-3 text-xs text-gray-700 dark:text-gray-300">
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) => props.onChange({ ...props.config, tls: e.currentTarget.checked })}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use TLS</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) => props.onChange({ ...props.config, startTLS: e.currentTarget.checked })}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use STARTTLS</span>
</label>
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Rate limit</label>
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) => props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
<span class={formHelpText}>/min</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Retries
</label>
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Max retries</label>
<input
type="number"
value={props.config.maxRetries || 3}
min="0"
max="5"
min={0}
max={5}
onInput={(e) => props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Retry Delay (seconds)
</label>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Retry delay (seconds)</label>
<input
type="number"
value={props.config.retryDelay || 5}
min="1"
max="60"
min={1}
max={60}
onInput={(e) => props.onChange({ ...props.config, retryDelay: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
</div>
</div>
</Show>
</div>
{/* Test Button */}
<div class="flex justify-end">
<button type="button"
<div class="flex justify-end pt-2">
<button
type="button"
onClick={props.onTest}
disabled={props.testing || !props.config.enabled}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="rounded border border-blue-500 px-3 py-1.5 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/30"
>
{props.testing ? 'Sending Test Email...' : 'Send Test Email'}
{props.testing ? 'Sending test email' : 'Send test email'}
</button>
</div>
</div>
);
}
}
@@ -1,6 +1,7 @@
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Override {
id?: string; // Full guest ID (e.g. "Main-node1-105")
@@ -154,9 +155,11 @@ export function OverrideModal(props: OverrideModalProps) {
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
{props.existingOverride ? 'Edit Guest Override' : 'Add Guest Override'}
</h2>
<SectionHeader
title={props.existingOverride ? 'Edit guest override' : 'Add guest override'}
size="md"
titleClass="text-gray-800 dark:text-gray-200"
/>
</div>
{/* Content */}
@@ -210,9 +213,11 @@ export function OverrideModal(props: OverrideModalProps) {
{/* Threshold Overrides */}
<div class={`space-y-4 ${alertsDisabled() ? 'opacity-50 pointer-events-none' : ''}`}>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Threshold Overrides
</h3>
<SectionHeader
title="Threshold overrides"
size="sm"
titleClass="text-gray-700 dark:text-gray-300"
/>
{/* CPU */}
<div class="flex items-start gap-3">
@@ -1,5 +1,7 @@
import { For, Show } from 'solid-js';
import type { Alert } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Resource {
id: string;
@@ -65,9 +67,9 @@ export function ResourceTable(props: ResourceTableProps) {
);
return (
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">{props.title}</h3>
<SectionHeader title={props.title} size="sm" />
</div>
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`
@@ -570,6 +572,6 @@ export function ResourceTable(props: ResourceTableProps) {
</tbody>
</table>
</div>
</div>
</Card>
);
}
}
@@ -1,6 +1,8 @@
import { createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import type { VM, Container, Node, Alert, Storage, PBSInstance } from '@/types/api';
import { ResourceTable } from './ResourceTable';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Override {
id: string;
@@ -605,14 +607,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
return (
<div class="space-y-6">
{/* Global Settings Section */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<Card padding="none">
<div class="p-4">
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Global Default Thresholds</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Default thresholds that apply to all resources unless overridden
</p>
</div>
<SectionHeader
title="Global default thresholds"
description="Default thresholds that apply to all resources unless overridden"
size="md"
/>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
@@ -930,7 +931,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</div>
</div>
</div>
</div>
</Card>
{/* Search Bar */}
<div class="relative">
@@ -1040,4 +1041,4 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</Show>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { createSignal, createEffect, Show, For, Index } from 'solid-js';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
interface WebhookTemplate {
service: string;
@@ -187,81 +188,82 @@ export function WebhookConfig(props: WebhookConfigProps) {
const someEnabled = () => props.webhooks.some(w => w.enabled);
return (
<div class="space-y-6">
<div class="space-y-6 min-w-0 w-full">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3">
<div class="space-y-3 w-full">
{/* Quick Actions Bar */}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
<div class="flex flex-col gap-2 rounded border border-gray-200 px-3 py-3 text-xs dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div class="text-gray-600 dark:text-gray-400 sm:text-sm">
{props.webhooks.filter(w => w.enabled).length} of {props.webhooks.length} webhooks enabled
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => toggleAllWebhooks(false)}
disabled={!someEnabled()}
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
class="w-full rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 sm:w-auto">
Disable All
</button>
<button
onClick={() => toggleAllWebhooks(true)}
disabled={allEnabled()}
class="px-3 py-1 text-xs bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20 sm:w-auto">
Enable All
</button>
</div>
</div>
<For each={props.webhooks}>
{(webhook) => (
<div class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<span class="font-medium text-sm text-gray-800 dark:text-gray-200">
{webhook.name}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{serviceName(webhook.service || 'generic')}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{webhook.method}
</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
{webhook.url}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`px-3 py-1 text-xs rounded transition-colors ${
webhook.enabled
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
>
{webhook.enabled ? 'Enabled' : 'Disabled'}
</button>
<button
onClick={() => webhook.id && props.onTest(webhook.id)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
>
{props.testing === webhook.id ? 'Testing...' : 'Test'}
</button>
<button
onClick={() => editWebhook(webhook)}
class="px-3 py-1 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
onClick={() => webhook.id && props.onDelete(webhook.id)}
class="px-3 py-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
<div class="w-full px-3 py-3 border border-gray-200 text-xs dark:border-gray-700 sm:text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-gray-800 dark:text-gray-200">
{webhook.name}
</span>
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${
webhook.enabled
? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20'
: 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{webhook.enabled ? 'Enabled' : 'Disabled'}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-gray-600 dark:text-gray-400 sm:text-xs">
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{serviceName(webhook.service || 'generic')}
</span>
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{webhook.method}
</span>
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
ID: {webhook.id || '—'}
</span>
</div>
<p class="mt-2 break-all text-[11px] font-mono text-gray-500 dark:text-gray-400 sm:text-xs">
{webhook.url}
</p>
<div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 pt-2 dark:border-gray-700 sm:justify-end w-full">
<button
onClick={() => webhook.id && props.onTest(webhook.id)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
{props.testing === webhook.id ? 'Testing…' : 'Test'}
</button>
<button
onClick={() => editWebhook(webhook)}
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/20"
>
Edit
</button>
<button
onClick={() => webhook.id && props.onDelete(webhook.id)}
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900/20"
>
Delete
</button>
</div>
</div>
)}
@@ -271,7 +273,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4">
<div class="space-y-4 text-sm">
{/* Service Selection */}
<div>
<div class="flex items-center justify-between mb-4">
@@ -287,21 +289,21 @@ export function WebhookConfig(props: WebhookConfigProps) {
</div>
<Show when={showServiceDropdown()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 border border-gray-200 dark:border-gray-700 px-3 py-2 mb-3 text-xs">
<For each={['generic', 'discord', 'slack', 'telegram', 'teams', 'teams-adaptive', 'pagerduty', 'pushover', 'gotify', 'ntfy']}>
{(service) => (
<button type="button"
onClick={() => selectService(service)}
class={`p-3 text-left rounded-lg border transition-all ${
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
formData().service === service
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
<div class="font-medium text-xs text-gray-800 dark:text-gray-200">
{serviceName(service)}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
<div class="text-[11px] text-gray-600 dark:text-gray-400 mt-1">
{service === 'generic' ? 'Custom webhook endpoint' :
service === 'discord' ? 'Discord server webhook' :
service === 'slack' ? 'Slack incoming webhook' :
@@ -320,21 +322,19 @@ export function WebhookConfig(props: WebhookConfigProps) {
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentTemplate()!.instructions}
</pre>
{currentTemplate()!.instructions}
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>
Name
</label>
<input
@@ -342,18 +342,18 @@ export function WebhookConfig(props: WebhookConfigProps) {
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder={currentTemplate()?.name || 'My Webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTTP Method
<div class={formField}>
<label class={labelClass()}>
HTTP method
</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
@@ -362,8 +362,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Webhook URL
</label>
<input
@@ -371,17 +371,17 @@ export function WebhookConfig(props: WebhookConfigProps) {
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder={currentTemplate()?.urlPattern || 'https://example.com/webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono"
class={controlClass('px-2 py-1.5 font-mono')}
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Payload Template (JSON)
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
Optional - Leave empty to use default
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom payload template (JSON)
<span class="text-xs text-gray-500 dark:text-gray-400">
Optional leave empty to use default
</span>
</label>
<textarea
@@ -394,26 +394,26 @@ export function WebhookConfig(props: WebhookConfigProps) {
"threshold": {{.Threshold}}
}`}
rows={8}
class="w-full px-3 py-2 text-xs font-mono border rounded-lg dark:bg-gray-700 dark:border-gray-600"
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p class={formHelpText + ' mt-1'}>
Available variables: {"{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}"}
</p>
</div>
</Show>
{/* Custom Headers Section */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Custom Headers
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom headers
<span class="text-xs text-gray-500 dark:text-gray-400">
Add authentication tokens or custom headers
</span>
</label>
<div class="space-y-2">
<div class="space-y-2 text-xs">
<Index each={headerInputs()}>
{(header, index) => (
<div class="flex gap-2">
<div class="flex gap-2 text-xs">
<input
type="text"
value={header().key}
@@ -425,8 +425,8 @@ export function WebhookConfig(props: WebhookConfigProps) {
return newInputs;
});
}}
placeholder="Header Name"
class="flex-1 px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="Header name"
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<input
type="text"
@@ -439,15 +439,15 @@ export function WebhookConfig(props: WebhookConfigProps) {
return newInputs;
});
}}
placeholder="Header Value"
class="flex-1 px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
placeholder="Header value"
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<button
type="button"
onClick={() => {
setHeaderInputs(inputs => inputs.filter((_, i) => i !== index));
}}
class="px-3 py-2 text-sm text-red-600 hover:text-red-700 dark:text-red-400 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
Remove
</button>
@@ -464,9 +464,9 @@ export function WebhookConfig(props: WebhookConfigProps) {
value: ''
}]);
}}
class="w-full py-2 text-sm border border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
+ Add Header
+ Add header
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
@@ -475,21 +475,21 @@ export function WebhookConfig(props: WebhookConfigProps) {
</div>
<div>
<label class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().enabled}
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Enable this webhook</span>
<span>Enable this webhook</span>
</label>
</div>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2 text-xs">
<button
onClick={cancelForm}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
class="px-3 py-1.5 border border-gray-300 rounded text-xs hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
@@ -509,7 +509,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
props.onTest(tempId, { ...formData(), headers });
}}
disabled={props.testing === (editingId() || 'temp-new-webhook')}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
class="px-3 py-1.5 border border-gray-300 rounded text-xs hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200"
>
{props.testing === (editingId() || 'temp-new-webhook') ? 'Testing...' : 'Test'}
</button>
@@ -517,7 +517,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId() ? 'Update' : 'Add'} Webhook
</button>
@@ -537,11 +537,11 @@ export function WebhookConfig(props: WebhookConfigProps) {
value: 'application/json'
}]);
}}
class="w-full py-2 text-sm border border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
+ Add Webhook
</button>
</Show>
</div>
);
}
}
@@ -1,4 +1,6 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
@@ -9,30 +11,35 @@ const Backups: Component = () => {
<div>
{/* Loading State */}
<Show when={connected() && !state.pveBackups && !state.pbs}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 mb-4">
<svg class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading backup information...</p>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<div class="inline-flex items-center justify-center w-12 h-12">
<svg class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
)}
title="Loading backup information..."
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">Unable to connect to the backend server. Attempting to reconnect...</p>
</div>
</div>
<Card padding="lg" tone="danger">
<EmptyState
icon={(
<svg class="h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
title="Connection lost"
description="Unable to connect to the backend server. Attempting to reconnect..."
tone="danger"
/>
</Card>
</Show>
{/* Main Content - Unified Backups View */}
@@ -43,4 +50,4 @@ const Backups: Component = () => {
);
};
export default Backups;
export default Backups;
@@ -1,4 +1,5 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
interface BackupsFilterProps {
@@ -16,7 +17,7 @@ interface BackupsFilterProps {
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
return (
<div class="backups-filter mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-3">
<Card class="backups-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1">
@@ -212,6 +213,6 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
</Show>
</div>
</div>
</div>
</Card>
);
};
@@ -6,6 +6,9 @@ import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { BackupsFilter } from './BackupsFilter';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
type BackupType = 'snapshot' | 'local' | 'remote';
type GuestType = 'VM' | 'LXC' | 'Host' | 'Template' | 'ISO';
@@ -968,24 +971,29 @@ const UnifiedBackups: Component = () => {
<div class="space-y-4">
{/* Empty State - No nodes at all configured */}
<Show when={!isLoading() && (state.nodes || []).length === 0 && (!state.pbs || state.pbs.length === 0)}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No backup sources configured</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">Add a Proxmox VE or PBS node in the Settings tab to start monitoring backups.</p>
<button type="button"
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
)}
title="No backup sources configured"
description="Add a Proxmox VE or PBS node in the Settings tab to start monitoring backups."
actions={(
<button
type="button"
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
)}
/>
</Card>
</Show>
{/* Unified Node Selector */}
@@ -1006,7 +1014,7 @@ const UnifiedBackups: Component = () => {
{/* Removed old PBS table */}
<Show when={false && sortedPBSInstances().length > 0}>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Card padding="none" class="overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
@@ -1145,18 +1153,16 @@ const UnifiedBackups: Component = () => {
</tbody>
</table>
</div>
</div>
</Card>
</Show>
{/* Main Content - show when any nodes or PBS are configured */}
<Show when={(state.nodes || []).length > 0 || (state.pbs && state.pbs.length > 0)}>
{/* Backup Frequency Chart - hide when no backups match the filter */}
<Show when={filteredData().length > 0}>
<div class="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Backup Frequency</h3>
</div>
<Card padding="md">
<div class="mb-3 flex items-start justify-between gap-3">
<SectionHeader title="Backup frequency" size="sm" class="flex-1" />
<div class="flex items-center gap-2 text-xs">
<div class="flex items-center gap-1">
<button type="button"
@@ -1218,8 +1224,18 @@ const UnifiedBackups: Component = () => {
<Show
when={chartData().data.length > 0}
fallback={
<div class="h-full flex items-center justify-center">
<p class="text-sm text-gray-500 dark:text-gray-400">No backup data for selected time range</p>
<div class="flex h-full items-center justify-center">
<EmptyState
class="max-w-xs"
align="center"
icon={(
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 19h16M7 10h2v9H7zm4-5h2v14h-2zm4 8h2v6h-2z" />
</svg>
)}
title="No backup data"
description="Adjust filters or expand the time range to see activity."
/>
</div>
}
>
@@ -1545,7 +1561,7 @@ const UnifiedBackups: Component = () => {
</span>
</div>
</div>
</div>
</Card>
</Show>
{/* Backups Filter */}
@@ -1563,7 +1579,7 @@ const UnifiedBackups: Component = () => {
/>
{/* Table */}
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
@@ -1582,22 +1598,34 @@ const UnifiedBackups: Component = () => {
<Show
when={!isLoading()}
fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center gap-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
<p class="text-lg">Loading backup data...</p>
<p class="text-sm">This may take up to 20 seconds on first load</p>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
title="Loading backup data..."
description="This may take up to 20 seconds on the first load."
/>
</Card>
}
>
<Show
when={groupedData().length > 0}
fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p class="text-lg">No backups found</p>
<p class="text-sm mt-2">No backups, snapshots, or remote backups match your filters</p>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)}
title="No backups match your filters"
description="Try adjusting filters or selecting a different time range."
/>
</Card>
}
>
{/* Mobile Card View - Compact */}
@@ -1610,7 +1638,7 @@ const UnifiedBackups: Component = () => {
</div>
<For each={group.items}>
{(item) => (
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded p-2 hover:shadow-sm transition-shadow">
<Card padding="sm" class="hover:shadow-sm transition-shadow">
{/* Compact header row */}
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0 flex-1">
@@ -1666,7 +1694,7 @@ const UnifiedBackups: Component = () => {
</span>
</Show>
</div>
</div>
</Card>
)}
</For>
</div>
@@ -1939,7 +1967,7 @@ const UnifiedBackups: Component = () => {
</Show>
</Show>
</div>
</div>
</Card>
{/* Tooltip */}
<Show when={tooltip()}>
@@ -1961,4 +1989,4 @@ const UnifiedBackups: Component = () => {
);
};
export default UnifiedBackups;
export default UnifiedBackups;
@@ -4,6 +4,7 @@ import { formatUptime } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
interface CompactNodeCardProps {
node: Node;
@@ -57,14 +58,18 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
if (props.variant === 'ultra-compact') {
// Single line format for 10+ nodes
return (
<div
class={`flex items-center gap-2 px-3 py-1.5 bg-white dark:bg-gray-800 rounded border ${
<Card
padding="none"
border={false}
hoverable
class={`flex items-center gap-2 px-3 py-1.5 ${
props.isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' :
!isOnline() ? 'border-red-500' :
alertStyles.hasAlert ? 'border-orange-500' :
'border-gray-200 dark:border-gray-700'
} hover:shadow-sm transition-all cursor-pointer hover:scale-[1.01]`}
onClick={props.onClick}>
} border transition-all cursor-pointer hover:scale-[1.01]`}
onClick={props.onClick}
>
{/* Status dot */}
<span class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
@@ -114,23 +119,27 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
</div>
{/* Uptime */}
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto">
<span class="ml-auto text-xs text-gray-500 dark:text-gray-400">
{formatUptime(props.node.uptime)}
</span>
</div>
</Card>
);
}
// Compact bar format for 5-9 nodes
return (
<div
class={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border ${
<Card
padding="sm"
border={false}
hoverable
class={`border ${
props.isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' :
!isOnline() ? 'border-red-500' :
alertStyles.hasAlert ? 'border-orange-500' :
'border-gray-200 dark:border-gray-700'
} p-3 cursor-pointer transition-all hover:scale-[1.02]`}
onClick={props.onClick}>
} cursor-pointer transition-all hover:scale-[1.02]`}
onClick={props.onClick}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class={`w-2 h-2 rounded-full ${
@@ -190,8 +199,8 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
</span>
</div>
</div>
</div>
</Card>
);
};
export default CompactNodeCard;
export default CompactNodeCard;
@@ -13,6 +13,9 @@ import { formatBytes, formatUptime } from '@/utils/format';
import { DashboardFilter } from './DashboardFilter';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface DashboardProps {
vms: VM[];
@@ -491,7 +494,7 @@ export function Dashboard(props: DashboardProps) {
{/* Removed old node table - keeping the rest unchanged */}
<Show when={false}>
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Card padding="none" class="mb-4">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
@@ -617,7 +620,7 @@ export function Dashboard(props: DashboardProps) {
</tbody>
</table>
</div>
</div>
</Card>
</Show>
{/* Dashboard Filter */}
@@ -640,77 +643,79 @@ export function Dashboard(props: DashboardProps) {
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading dashboard data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">
{reconnecting() ? 'Reconnecting to monitoring service...' : 'Connecting to monitoring service'}
</p>
<Show when={!connected() && !reconnecting()}>
<button
onClick={() => reconnect()}
class="mt-3 px-4 py-2 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Retry Connection
</button>
</Show>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="mx-auto h-12 w-12 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
title="Loading dashboard data..."
description={reconnecting() ? 'Reconnecting to monitoring service…' : 'Connecting to monitoring service'}
/>
</Card>
</Show>
{/* Empty State - No PVE Nodes Configured */}
<Show when={connected() && initialDataReceived() && props.nodes.filter(n => n.type === 'pve').length === 0 && props.vms.length === 0 && props.containers.length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No Proxmox VE nodes configured</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure.</p>
<button type="button"
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
)}
title="No Proxmox VE nodes configured"
description="Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure."
actions={(
<button
type="button"
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
)}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">
{reconnecting() ? 'Attempting to reconnect...' : 'Unable to connect to the backend server'}
</p>
<Show when={!reconnecting()}>
<button
onClick={() => reconnect()}
class="mt-3 px-4 py-2 text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Reconnect Now
</button>
</Show>
</div>
</div>
<Card padding="lg" tone="danger">
<EmptyState
icon={(
<svg class="h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
title="Connection lost"
description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'}
tone="danger"
actions={
!reconnecting()
? (
<button
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Reconnect now
</button>
)
: undefined
}
/>
</Card>
</Show>
{/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table">
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<Card padding="none" class="mb-4 overflow-hidden">
<ScrollableTable
minWidth="900px"
>
@@ -834,24 +839,26 @@ export function Dashboard(props: DashboardProps) {
</tbody>
</table>
</ScrollableTable>
</div>
</Card>
</ComponentErrorBoundary>
</Show>
<Show when={connected() && initialDataReceived() && filteredGuests().length === 0 && (props.vms.length > 0 || props.containers.length > 0)}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 mb-4">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No guests found</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">
{search() && search().trim() !== ''
? `No guests match your search "${search()}"`
: 'No guests match your current filters'}
</p>
</div>
</div>
<Card padding="lg" class="mb-4">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)}
title="No guests found"
description={
search() && search().trim() !== ''
? `No guests match your search "${search()}"`
: 'No guests match your current filters'
}
/>
</Card>
</Show>
{/* Stats */}
@@ -876,4 +883,4 @@ export function Dashboard(props: DashboardProps) {
<TooltipComponent />
</div>
);
}
}
@@ -1,4 +1,5 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
interface DashboardFilterProps {
search: () => string;
@@ -17,7 +18,7 @@ interface DashboardFilterProps {
export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
return (
<div class="dashboard-filter mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-3">
<Card class="dashboard-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1">
@@ -185,6 +186,6 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</Show>
</div>
</div>
</div>
</Card>
);
};
@@ -1,5 +1,7 @@
import { Component, createMemo, Show } from 'solid-js';
import type { PhysicalDisk } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface DiskHealthSummaryProps {
disks: PhysicalDisk[];
@@ -50,11 +52,14 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
return (
<Show when={diskStats().total > 0}>
<div class={`rounded-lg p-4 border ${healthBg()}`}>
<Card padding="md" border={false} class={`${healthBg()}`}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Disk Health Summary
</h3>
<SectionHeader
title="Disk health summary"
size="sm"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<span class={`text-2xl font-bold ${healthColor()}`}>
{diskStats().healthy}/{diskStats().total}
</span>
@@ -123,7 +128,7 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
</div>
</div>
</div>
</div>
</Card>
</Show>
);
};
};
@@ -4,6 +4,7 @@ import { formatUptime } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
interface NodeCardProps {
node: Node;
@@ -15,9 +16,9 @@ const NodeCard: Component<NodeCardProps> = (props) => {
// Early return if node data is incomplete
if (!props.node || !props.node.memory || !props.node.disk) {
return (
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-3 border border-gray-200 dark:border-gray-700 flex flex-col gap-1 w-[180px]">
<Card padding="sm" class="flex w-[180px] flex-col gap-1">
<div class="text-sm text-gray-500">Loading node data...</div>
</div>
</Card>
);
}
@@ -107,20 +108,20 @@ const NodeCard: Component<NodeCardProps> = (props) => {
const getBorderClass = () => {
// Selected nodes get blue ring
if (props.isSelected) {
return 'ring-2 ring-blue-500 border border-gray-200 dark:border-gray-700';
return 'ring-2 ring-blue-500 border-blue-200 dark:border-blue-500';
}
// Offline nodes get red ring
if (!isOnline()) {
return 'ring-2 ring-red-500 border border-gray-200 dark:border-gray-700';
return 'ring-2 ring-red-500 border-red-200 dark:border-red-600';
}
// Alert nodes get colored ring based on severity
if (alertStyles.hasAlert) {
return alertStyles.severity === 'critical'
? 'ring-2 ring-red-500 border border-gray-200 dark:border-gray-700'
: 'ring-2 ring-orange-500 border border-gray-200 dark:border-gray-700';
? 'ring-2 ring-red-500 border-red-200 dark:border-red-600'
: 'ring-2 ring-orange-500 border-orange-200 dark:border-orange-500';
}
// Normal nodes get standard border
return 'border border-gray-200 dark:border-gray-700';
return '';
};
// Get background class from alert styles but remove the border-l-4 part
@@ -131,7 +132,11 @@ const NodeCard: Component<NodeCardProps> = (props) => {
};
return (
<div class={`bg-white dark:bg-gray-800 shadow-md rounded-lg p-3 flex flex-col gap-2 w-[180px] ${getBorderClass()} ${getBackgroundClass()}`}>
<Card
padding="sm"
class={`flex w-[180px] flex-col gap-2 ${getBorderClass()} ${getBackgroundClass()}`.trim()}
hoverable
>
{/* Header */}
<div class="flex justify-between items-center">
<h3 class="text-xs font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-1">
@@ -180,8 +185,8 @@ const NodeCard: Component<NodeCardProps> = (props) => {
<span title={`Uptime: ${formatUptime(props.node.uptime)}`}>{formatUptime(props.node.uptime)}</span>
<span title={`Load: ${normalizedLoad()}`}>{normalizedLoad()}</span>
</div>
</div>
</Card>
);
};
export default NodeCard;
export default NodeCard;
@@ -1,6 +1,8 @@
import { Component, Show, createMemo } from 'solid-js';
import type { PBSInstance } from '@/types/api';
import { formatUptime, formatBytes } from '@/utils/format';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface PBSCardProps {
instance: PBSInstance;
@@ -125,9 +127,13 @@ const PBSCard: Component<PBSCardProps> = (props) => {
};
return (
<div class={`bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 flex flex-col gap-1 w-[250px] ${getBorderClass()}`}>
<Card
padding="none"
border={false}
class={`shadow-md p-2 flex flex-col gap-1 w-[250px] ${getBorderClass()}`}
>
{/* Header */}
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-2">
<a
href={props.instance.host}
@@ -254,8 +260,8 @@ const PBSCard: Component<PBSCardProps> = (props) => {
</div>
</Show>
</Show>
</div>
</Card>
);
};
export default PBSCard;
export default PBSCard;
@@ -1,5 +1,6 @@
import { Component, JSX, createSignal, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import { logError } from '@/utils/logger';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface ErrorBoundaryProps {
children: JSX.Element;
@@ -19,12 +20,13 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
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>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
An unexpected error occurred
</p>
<SectionHeader
title="Something went wrong"
description="An unexpected error occurred"
size="md"
titleClass="text-gray-900 dark:text-gray-100"
descriptionClass="text-sm text-gray-600 dark:text-gray-400"
/>
</div>
</div>
@@ -107,9 +109,11 @@ export const ComponentErrorBoundary: Component<{
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error in {props.name}
</h3>
<SectionHeader
title={`Error in ${props.name}`}
size="sm"
titleClass="text-red-800 dark:text-red-200"
/>
</div>
<p class="text-xs text-red-700 dark:text-red-300 mb-2">
{error.message}
@@ -129,4 +133,4 @@ export const ComponentErrorBoundary: Component<{
{props.children}
</ErrorBoundary>
);
};
};
@@ -2,6 +2,7 @@ import { Component, createSignal, Show, onMount } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { STORAGE_KEYS } from '@/constants';
import { SectionHeader } from '@/components/shared/SectionHeader';
export const FirstRunSetup: Component = () => {
const [username, setUsername] = createSignal('admin');
@@ -211,9 +212,12 @@ IMPORTANT: Keep these credentials secure!
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
<Show when={!showCredentials()}>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-6">
Initial Security Setup
</h2>
<SectionHeader
title="Initial security setup"
size="lg"
class="mb-6"
titleClass="text-gray-800 dark:text-gray-100"
/>
<div class="space-y-6">
{/* Username */}
@@ -343,9 +347,11 @@ IMPORTANT: Keep these credentials secure!
{/* Info Box */}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-2">
<h3 class="font-medium text-gray-800 dark:text-gray-200">
What happens next:
</h3>
<SectionHeader
title="What happens next"
size="sm"
titleClass="text-gray-800 dark:text-gray-200"
/>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
@@ -386,9 +392,13 @@ IMPORTANT: Keep these credentials secure!
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Setup Complete!
</h2>
<SectionHeader
title="Setup complete!"
size="lg"
class="mb-2"
align="center"
titleClass="text-gray-800 dark:text-gray-100"
/>
<p class="text-gray-600 dark:text-gray-400">
Save your credentials now - they won't be shown again
</p>
@@ -1,5 +1,6 @@
import { Component, createSignal, Show, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface SecurityStatus {
hasAuthentication: boolean;
@@ -125,11 +126,12 @@ export const SecurityWarning: Component = () => {
<span class="text-2xl">{getScoreEmoji(status()!.score, status()!.maxScore)}</span>
<div>
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Security Score: <span class={getScoreColor(status()!.score, status()!.maxScore)}>
{status()!.score}/{status()!.maxScore}
</span>
</h3>
<SectionHeader
title={<span>Security score: <span class={getScoreColor(status()!.score, status()!.maxScore)}>{status()!.score}/{status()!.maxScore}</span></span>}
size="sm"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<button type="button"
onClick={() => setShowDetails(!showDetails())}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
@@ -235,4 +237,4 @@ export const SecurityWarning: Component = () => {
</div>
</Portal>
);
};
};
@@ -3,6 +3,8 @@ import { Portal } from 'solid-js/web';
import { showSuccess, showError } from '@/utils/toast';
import { NodesAPI } from '@/api/nodes';
import type { NodeConfig } from '@/types/nodes';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
interface DiscoveredServer {
ip: string;
@@ -118,9 +120,11 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Add {props.servers.length} Discovered Servers
</h3>
<SectionHeader
title={`Add ${props.servers.length} discovered servers`}
size="md"
class="flex-1"
/>
<Show when={!isAdding()}>
<button type="button"
onClick={props.onClose}
@@ -195,8 +199,8 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
{/* Token Auth Fields */}
<Show when={authType() === 'token'}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Token ID <span class="text-red-500">*</span>
</label>
<input
@@ -205,15 +209,15 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
onInput={(e) => setTokenName(e.currentTarget.value)}
placeholder="user@realm!tokenname"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
class={controlClass('font-mono')}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class={`${formHelpText} mt-1`}>
Example: pulse-monitor@pam!pulse-token or pulse-monitor@pbs!pulse-token
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Token Value <span class="text-red-500">*</span>
</label>
<input
@@ -222,7 +226,7 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
onInput={(e) => setTokenValue(e.currentTarget.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
class={controlClass('font-mono')}
/>
</div>
</div>
@@ -231,8 +235,8 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
{/* Password Auth Fields */}
<Show when={authType() === 'password'}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Username <span class="text-red-500">*</span>
</label>
<input
@@ -241,12 +245,12 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
onInput={(e) => setUsername(e.currentTarget.value)}
placeholder="root@pam or admin@pbs"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Password <span class="text-red-500">*</span>
</label>
<input
@@ -255,7 +259,7 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
</div>
</div>
@@ -314,4 +318,4 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
</Show>
</Portal>
);
};
};
@@ -1,6 +1,8 @@
import { Component, createSignal, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { showSuccess, showError } from '@/utils/toast';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
interface ChangePasswordModalProps {
isOpen: boolean;
@@ -113,9 +115,7 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4" style="z-index: 9999">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Change Password
</h2>
<SectionHeader title="Change password" size="lg" class="flex-1" />
<button type="button"
onClick={handleClose}
disabled={loading()}
@@ -128,8 +128,8 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
</div>
<form onSubmit={handleSubmit} class="p-6 space-y-4">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label for="current-password" class={labelClass()}>
Current Password
</label>
<input
@@ -137,14 +137,14 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
type="password"
value={currentPassword()}
onInput={(e) => setCurrentPassword(e.currentTarget.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class={controlClass('shadow-sm')}
required
disabled={loading()}
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label for="new-password" class={labelClass()}>
New Password
</label>
<input
@@ -152,18 +152,18 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
type="password"
value={newPassword()}
onInput={(e) => setNewPassword(e.currentTarget.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class={controlClass('shadow-sm')}
required
disabled={loading()}
minLength={8}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p class={`${formHelpText} mt-1`}>
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label for="confirm-password" class={labelClass()}>
Confirm New Password
</label>
<input
@@ -171,7 +171,7 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
type="password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class={controlClass('shadow-sm')}
required
disabled={loading()}
/>
@@ -204,4 +204,4 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
</Portal>
</Show>
);
};
};
@@ -1,6 +1,7 @@
import { Component, Show, createSignal, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { showSuccess, showError } from '@/utils/toast';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface DiscoveredServer {
ip: string;
@@ -206,9 +207,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<div class="relative w-full max-w-3xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Network Discovery
</h3>
<SectionHeader title="Network discovery" size="md" class="flex-1" />
<button type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
@@ -400,4 +399,4 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
</Show>
</Portal>
);
};
};
@@ -2,6 +2,8 @@ import { Component, createSignal, Show, createEffect } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { apiFetch } from '@/utils/apiClient';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { formField, labelClass, formHelpText } from '@/components/shared/Form';
interface GenerateAPITokenProps {
currentTokenHint?: string;
@@ -77,7 +79,7 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</code>
</div>
</Show>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
<p class={`${formHelpText} mb-4`}>
An API token is configured for this instance. Use it with the X-API-Token header for automation.
</p>
@@ -103,17 +105,21 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Your New API Token</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{newToken()}
</code>
<button type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
<div class={formField}>
<label class={labelClass('text-xs')}>
Your new API token
</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{newToken()}
</code>
<button type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
@@ -145,9 +151,11 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Generate New API Token?
</h3>
<SectionHeader
title="Generate new API token?"
size="md"
class="mb-4"
/>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
This will generate a new API token and <span class="font-semibold text-red-600 dark:text-red-400">immediately invalidate the current token</span>.
Any scripts or integrations using the old token will stop working.
@@ -171,4 +179,4 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</Show>
</div>
);
};
};
@@ -4,6 +4,7 @@ import { showSuccess, showError } from '@/utils/toast';
import type { VM, Container } from '@/types/api';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface GuestURLsProps {
@@ -185,12 +186,11 @@ export function GuestURLs(props: GuestURLsProps) {
return (
<div class="space-y-6">
{/* Header */}
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">Guest URL Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Configure custom URLs for accessing guest web interfaces. These URLs will be clickable from the dashboard.
</p>
</div>
<SectionHeader
title="Guest URL management"
description="Configure custom URLs for accessing guest web interfaces. These URLs appear as shortcuts from the dashboard."
size="md"
/>
{/* Search */}
<div class="relative">
@@ -365,4 +365,4 @@ export function GuestURLs(props: GuestURLsProps) {
</div>
</div>
);
}
}
@@ -5,6 +5,8 @@ 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;
@@ -298,9 +300,11 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<form onSubmit={handleSubmit}>
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{props.editingNode ? 'Edit' : 'Add'} {props.nodeType === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server'} Node
</h3>
<SectionHeader
title={`${props.editingNode ? 'Edit' : 'Add'} ${props.nodeType === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server'} 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"
@@ -316,35 +320,40 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<div class="p-6 space-y-6">
{/* Basic Information */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Basic Information</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Node Name <span class="text-gray-400">(optional)</span>
<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="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<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" : "https://backup.example.com:8007"}
placeholder={props.nodeType === 'pve' ? 'https://proxmox.example.com:8006' : 'https://backup.example.com:8007'}
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
<Show when={props.nodeType === 'pbs'}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">PBS requires HTTPS (not HTTP). Default port is 8007</p>
<p class={formHelpText}>PBS requires HTTPS (not HTTP). Default port is 8007.</p>
</Show>
</div>
</div>
@@ -352,7 +361,12 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{/* Authentication */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Authentication</h4>
<SectionHeader
title="Authentication"
size="sm"
class="mb-4"
titleClass="text-gray-900 dark:text-gray-100"
/>
{/* Auth Type Selector */}
<div class="mb-4">
@@ -384,27 +398,30 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{/* Password Auth Fields */}
<Show when={formData().authType === 'password'}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<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" : "admin@pbs"}
placeholder={props.nodeType === 'pve' ? 'root@pam' : 'admin@pbs'}
required={formData().authType === 'password'}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
<Show when={props.nodeType === 'pbs'}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Must include realm (e.g., admin@pbs)</p>
<p class={formHelpText}>Must include realm (e.g., admin@pbs).</p>
</Show>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password {!props.editingNode && <span class="text-red-500">*</span>}
<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"
@@ -412,7 +429,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
onInput={(e) => updateField('password', e.currentTarget.value)}
placeholder={props.editingNode ? 'Leave blank to keep existing' : 'Password'}
required={formData().authType === 'password' && !props.editingNode}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
</div>
</div>
@@ -466,7 +483,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
type="checkbox"
checked={formData().enableBackupManagement}
onChange={(e) => setFormData({ ...formData(), enableBackupManagement: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600"
class={formCheckbox}
/>
<span class="text-gray-700 dark:text-gray-300">
Enable storage permissions for backup visibility
@@ -1103,9 +1120,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</div>
{/* Token Input Fields */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<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
@@ -1114,14 +1131,17 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
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="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
class={controlClass('font-mono')}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full token ID from Proxmox (user@realm!tokenname)</p>
<p class={formHelpText}>Full token ID from Proxmox (user@realm!tokenname).</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token Value {!props.editingNode && <span class="text-red-500">*</span>}
<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"
@@ -1129,9 +1149,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
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="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
class={controlClass('font-mono')}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The secret value shown when creating the token</p>
<p class={formHelpText}>The secret value shown when creating the token.</p>
</div>
</div>
</div>
@@ -1140,125 +1160,133 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{/* SSL Settings */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">SSL Settings</h4>
<SectionHeader
title="SSL settings"
size="sm"
class="mb-4"
titleClass="text-gray-900 dark:text-gray-100"
/>
<div class="space-y-3">
<div class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
id="verifySSL"
checked={formData().verifySSL}
onChange={(e) => updateField('verifySSL', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<label for="verifySSL" class="text-sm text-gray-700 dark:text-gray-300">
Verify SSL Certificate
</label>
</div>
Verify SSL certificate
</label>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SSL Fingerprint (Optional)
<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="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
class={controlClass('font-mono')}
/>
<p class={formHelpText}>Useful when connecting to servers with self-signed certificates.</p>
</div>
</div>
</div>
{/* Monitoring Options */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Monitoring Options</h4>
<SectionHeader
title="Monitoring options"
size="sm"
class="mb-4"
titleClass="text-gray-900 dark:text-gray-100"
/>
<div class="space-y-2">
{props.nodeType === 'pve' ? (
<>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorVMs}
onChange={(e) => updateField('monitorVMs', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Virtual Machines</span>
<span>Monitor Virtual Machines</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorContainers}
onChange={(e) => updateField('monitorContainers', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Containers</span>
<span>Monitor Containers</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorStorage}
onChange={(e) => updateField('monitorStorage', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Storage</span>
<span>Monitor Storage</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorBackups}
onChange={(e) => updateField('monitorBackups', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Backups</span>
<span>Monitor Backups</span>
</label>
</>
) : (
<>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorDatastores}
onChange={(e) => updateField('monitorDatastores', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Datastores</span>
<span>Monitor Datastores</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorSyncJobs}
onChange={(e) => updateField('monitorSyncJobs', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Sync Jobs</span>
<span>Monitor Sync Jobs</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorVerifyJobs}
onChange={(e) => updateField('monitorVerifyJobs', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Verify Jobs</span>
<span>Monitor Verify Jobs</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorPruneJobs}
onChange={(e) => updateField('monitorPruneJobs', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Prune Jobs</span>
<span>Monitor Prune Jobs</span>
</label>
<label class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorGarbageJobs}
onChange={(e) => updateField('monitorGarbageJobs', e.currentTarget.checked)}
class="mr-2"
class={formCheckbox}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Garbage Collection Jobs</span>
<span>Monitor Garbage Collection Jobs</span>
</label>
</>
)}
@@ -1349,4 +1377,4 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</Show>
</Portal>
);
};
};
@@ -1,6 +1,8 @@
import { Component, createSignal, Show } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
interface SecurityCredentials {
username: string;
@@ -159,10 +161,13 @@ Important:
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Quick Security Setup</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
Enable authentication with one click. This will:
</p>
<SectionHeader
title="Quick security setup"
description="Enable authentication with one click. This will:"
size="sm"
titleClass="text-gray-900 dark:text-gray-100"
descriptionClass="!text-xs text-gray-600 dark:text-gray-400"
/>
<ul class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
@@ -186,7 +191,7 @@ Important:
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class={labelClass()}>
Password Setup
</label>
<div class="flex items-center space-x-2">
@@ -215,39 +220,39 @@ Important:
<Show when={useCustomPassword()}>
<div class="space-y-2">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Username
</label>
<input
type="text"
value={customUsername()}
onInput={(e) => setCustomUsername(e.currentTarget.value)}
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 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class={controlClass()}
placeholder="admin"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Password (min 8 characters)
</label>
<input
type="password"
value={customPassword()}
onInput={(e) => setCustomPassword(e.currentTarget.value)}
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 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class={controlClass()}
placeholder="Enter password"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm Password
<div class={formField}>
<label class={labelClass()}>
Confirm password
</label>
<input
type="password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
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 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
class={controlClass()}
placeholder="Confirm password"
/>
</div>
@@ -255,7 +260,7 @@ Important:
</Show>
<Show when={!useCustomPassword()}>
<p class="text-xs text-gray-600 dark:text-gray-400">
<p class={formHelpText}>
A secure 16-character password will be generated for you
</p>
</Show>
@@ -298,9 +303,12 @@ Important:
<Show when={showCredentials() && credentials()}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
🎉 Security Enabled Successfully!
</h4>
<SectionHeader
title="🎉 Security enabled successfully!"
size="md"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<button type="button"
onClick={downloadCredentials}
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
@@ -317,8 +325,10 @@ Important:
<div class="space-y-3">
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<div class="flex items-center space-x-2">
<label class={labelClass('text-xs')}>
Username
</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700">
{credentials()!.username}
</code>
@@ -332,8 +342,10 @@ Important:
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div class="flex items-center space-x-2">
<label class={labelClass('text-xs')}>
Password
</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.password}
</code>
@@ -347,8 +359,10 @@ Important:
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">API Token</label>
<div class="flex items-center space-x-2">
<label class={labelClass('text-xs')}>
API token
</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.apiToken}
</code>
@@ -359,11 +373,11 @@ Important:
{copied() === 'token' ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Use this token with X-API-Token header for automation.
<p class={formHelpText + ' mt-2'}>
Use this token with the X-API-Token header for automation.
</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-1 font-semibold">
⚠️ This token will NEVER be shown again. Save it now!
<p class="mt-1 text-xs font-semibold text-red-600 dark:text-red-400">
⚠️ This token will never be shown again. Save it now!
</p>
</div>
</div>
@@ -400,4 +414,4 @@ Important:
</Show>
</div>
);
};
};
@@ -8,6 +8,10 @@ import { GuestURLs } from './GuestURLs';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import type { NodeConfig } from '@/types/nodes';
import type { UpdateInfo, VersionInfo } from '@/api/updates';
import { eventBus } from '@/stores/events';
@@ -718,14 +722,13 @@ const Settings: Component = () => {
<>
<div class="space-y-4">
{/* Header with better styling */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div>
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Configuration Settings</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage Proxmox nodes and system configuration
</p>
</div>
</div>
<Card padding="md">
<SectionHeader
title="Configuration settings"
description="Manage Proxmox nodes and system configuration"
size="lg"
/>
</Card>
{/* Save notification bar - only show when there are unsaved changes */}
<Show when={hasUnsavedChanges() && (activeTab() === 'pve' || activeTab() === 'pbs' || activeTab() === 'system')}>
@@ -760,7 +763,7 @@ const Settings: Component = () => {
</Show>
{/* Tab Navigation - modern style */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<Card padding="none">
<div class="p-1">
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5 w-full overflow-x-auto scrollbar-hide" style="-webkit-overflow-scrolling: touch;">
<For each={tabs}>
@@ -793,48 +796,41 @@ const Settings: Component = () => {
</Show>
<Show when={initialLoadComplete()}>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 dark:text-gray-200">Proxmox VE Nodes</h3>
<SectionHeader title="Proxmox VE nodes" size="md" class="flex-1" />
<div class="flex flex-wrap gap-2 items-center justify-end">
{/* Discovery toggle */}
<label class="flex items-center gap-1 sm:gap-2 cursor-pointer" title="Enable automatic discovery of Proxmox servers on your network">
<div class="flex items-center gap-2 sm:gap-3" title="Enable automatic discovery of Proxmox servers on your network">
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Discovery</span>
<div class="relative inline-flex items-center">
<input
type="checkbox"
checked={discoveryEnabled()}
onChange={async (e) => {
if (!envOverrides().discoveryEnabled) {
const newValue = e.currentTarget.checked;
setDiscoveryEnabled(newValue);
// Save discovery setting immediately
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: newValue,
discoverySubnet: discoverySubnet()
});
if (newValue) {
// Trigger discovery when enabled
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
}
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
// Revert on error
setDiscoveryEnabled(!newValue);
}
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled) {
return;
}
const newValue = e.currentTarget.checked;
setDiscoveryEnabled(newValue);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: newValue,
discoverySubnet: discoverySubnet()
});
if (newValue) {
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
}
}}
disabled={envOverrides().discoveryEnabled}
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:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</div>
</label>
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(!newValue);
}
}}
disabled={envOverrides().discoveryEnabled}
containerClass="gap-2"
label={<span class="text-xs font-medium text-gray-600 dark:text-gray-400">{discoveryEnabled() ? 'On' : 'Off'}</span>}
/>
</div>
<Show when={discoveryEnabled()}>
<button type="button"
@@ -1069,48 +1065,41 @@ const Settings: Component = () => {
</Show>
<Show when={initialLoadComplete()}>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<h3 class="text-base sm:text-lg font-semibold text-gray-800 dark:text-gray-200">Proxmox Backup Server Nodes</h3>
<SectionHeader title="Proxmox Backup Server nodes" size="md" class="flex-1" />
<div class="flex flex-wrap gap-2 items-center justify-end">
{/* Discovery toggle */}
<label class="flex items-center gap-2 cursor-pointer" title="Enable automatic discovery of PBS servers on your network">
<div class="flex items-center gap-2" title="Enable automatic discovery of PBS servers on your network">
<span class="text-sm text-gray-600 dark:text-gray-400">Discovery</span>
<div class="relative inline-flex items-center">
<input
type="checkbox"
checked={discoveryEnabled()}
onChange={async (e) => {
if (!envOverrides().discoveryEnabled) {
const newValue = e.currentTarget.checked;
setDiscoveryEnabled(newValue);
// Save discovery setting immediately
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: newValue,
discoverySubnet: discoverySubnet()
});
if (newValue) {
// Trigger discovery when enabled
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
}
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
// Revert on error
setDiscoveryEnabled(!newValue);
}
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled) {
return;
}
const newValue = e.currentTarget.checked;
setDiscoveryEnabled(newValue);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: newValue,
discoverySubnet: discoverySubnet()
});
if (newValue) {
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
}
}}
disabled={envOverrides().discoveryEnabled}
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:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</div>
</label>
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(!newValue);
}
}}
disabled={envOverrides().discoveryEnabled}
containerClass="gap-2"
label={<span class="text-xs font-medium text-gray-600 dark:text-gray-400">{discoveryEnabled() ? 'On' : 'Off'}</span>}
/>
</div>
<Show when={discoveryEnabled()}>
<button type="button"
@@ -1299,7 +1288,7 @@ const Settings: Component = () => {
<Show when={activeTab() === 'system'}>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">System Configuration</h3>
<SectionHeader title="System configuration" size="md" class="mb-4" />
{/* Environment Variable Info */}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg">
@@ -1625,11 +1614,13 @@ const Settings: Component = () => {
</div>
{/* Backup & Restore - Moved from Security tab */}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Backup & Restore</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Backup your node configurations and credentials or restore from a previous backup
</p>
<Card padding="lg" border={false} class="border border-gray-200 dark:border-gray-700">
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}
@@ -1704,7 +1695,7 @@ const Settings: Component = () => {
</div>
</div>
</div>
</div>
</Card>
</div>
</Show>
@@ -1737,7 +1728,7 @@ const Settings: Component = () => {
<li> Or authentication hasn't been configured yet</li>
</ul>
</div>
<div class="mt-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700">
<Card tone="muted" padding="sm" class="mt-3 border border-amber-200 dark:border-amber-700">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">
To enable authentication:
</p>
@@ -1747,7 +1738,7 @@ const Settings: Component = () => {
<li>3. Restart Pulse service</li>
<li>4. Complete the security setup wizard on first access</li>
</ol>
</div>
</Card>
</div>
</div>
</div>
@@ -1755,7 +1746,7 @@ const Settings: Component = () => {
{/* Authentication */}
<Show when={!securityStatusLoading() && (securityStatus()?.hasAuthentication || securityStatus()?.apiTokenConfigured)}>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
{/* Header */}
<div class="bg-gradient-to-r from-gray-50 to-gray-50 dark:from-gray-900/20 dark:to-gray-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
@@ -1764,10 +1755,12 @@ const Settings: Component = () => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Authentication</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Manage your login credentials</p>
</div>
<SectionHeader
title="Authentication"
description="Manage your login credentials"
size="sm"
class="flex-1"
/>
</div>
</div>
@@ -1797,7 +1790,7 @@ const Settings: Component = () => {
</div>
</div>
</div>
</Card>
</Show>
{/* Show pending restart message if configured but not loaded */}
@@ -1884,7 +1877,7 @@ const Settings: Component = () => {
{/* API Token - Show always to allow API access even when auth is disabled */}
<Show when={!securityStatusLoading()}>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
@@ -1893,10 +1886,12 @@ const Settings: Component = () => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">API Token</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">For automation and integrations</p>
</div>
<SectionHeader
title="API token"
description="For automation and integrations"
size="sm"
class="flex-1"
/>
</div>
</div>
@@ -1904,15 +1899,15 @@ const Settings: Component = () => {
<div class="p-6">
{/* Show explanation when auth is disabled */}
<Show when={!securityStatus()?.hasAuthentication}>
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Card tone="info" padding="sm" class="mb-4 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>API Access Control:</strong> Even though authentication is disabled, you can still use API tokens to protect API access for automation and integrations.
</p>
</div>
</Card>
</Show>
<GenerateAPIToken currentTokenHint={securityStatus()?.apiTokenHint} />
</div>
</div>
</Card>
</Show>
{/* Advanced - Only show if auth is enabled */}
@@ -1924,7 +1919,7 @@ const Settings: Component = () => {
<Show when={activeTab() === 'diagnostics'}>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">System Diagnostics</h3>
<SectionHeader title="System diagnostics" size="md" class="mb-4" />
<div class="space-y-4">
{/* Live Connection Diagnostics */}
@@ -1956,7 +1951,7 @@ const Settings: Component = () => {
<Show when={diagnosticsData()}>
<div class="mt-4 space-y-3">
{/* System Info */}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3">
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">System</h5>
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div>Version: {diagnosticsData()?.version || 'Unknown'}</div>
@@ -1964,11 +1959,11 @@ const Settings: Component = () => {
<div>Runtime: {diagnosticsData()?.runtime || 'Unknown'}</div>
<div>Memory: {Math.round((diagnosticsData()?.system?.memory?.alloc || 0) / 1024 / 1024)} MB</div>
</div>
</div>
</Card>
{/* Nodes Status */}
<Show when={diagnosticsData()?.nodes && diagnosticsData()!.nodes.length > 0}>
<div class="bg-white dark:bg-gray-800 rounded-lg p-3">
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">PVE Nodes</h5>
<For each={diagnosticsData()?.nodes || []}>
{(node) => (
@@ -1991,12 +1986,12 @@ const Settings: Component = () => {
</div>
)}
</For>
</div>
</Card>
</Show>
{/* PBS Status */}
<Show when={diagnosticsData()?.pbs && diagnosticsData()!.pbs.length > 0}>
<div class="bg-white dark:bg-gray-800 rounded-lg p-3">
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">PBS Instances</h5>
<For each={diagnosticsData()?.pbs || []}>
{(pbs) => (
@@ -2015,7 +2010,7 @@ const Settings: Component = () => {
</div>
)}
</For>
</div>
</Card>
</Show>
</div>
</Show>
@@ -2349,7 +2344,7 @@ const Settings: Component = () => {
/>
</Show>
</div>
</div>
</Card>
{/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */}
<Show when={showNodeModal() && currentNodeType() === 'pve'}>
@@ -2472,8 +2467,8 @@ const Settings: Component = () => {
{/* Export Dialog */}
<Show when={showExportDialog()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Export Configuration</h3>
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Export configuration" size="md" class="mb-4" />
<div class="space-y-4">
{/* Password Choice Section - Only show if auth is enabled */}
@@ -2521,8 +2516,8 @@ const Settings: Component = () => {
</Show>
{/* Show password input based on selection */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
{securityStatus()?.hasAuthentication
? (useCustomPassphrase() ? 'Custom Passphrase' : 'Enter Your Login Password')
: 'Encryption Passphrase'}
@@ -2533,18 +2528,18 @@ const Settings: Component = () => {
onInput={(e) => setExportPassphrase(e.currentTarget.value)}
placeholder={
securityStatus()?.hasAuthentication
? (useCustomPassphrase() ? "Enter a strong passphrase" : "Enter your Pulse login password")
: "Enter a strong passphrase for encryption"
? (useCustomPassphrase() ? 'Enter a strong passphrase' : 'Enter your Pulse login password')
: 'Enter a strong passphrase for encryption'
}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
<Show when={!securityStatus()?.hasAuthentication || useCustomPassphrase()}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class={`${formHelpText} mt-1`}>
You'll need this passphrase to restore the backup.
</p>
</Show>
<Show when={securityStatus()?.hasAuthentication && !useCustomPassphrase()}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class={`${formHelpText} mt-1`}>
You'll use this same password when restoring the backup
</p>
</Show>
@@ -2583,25 +2578,23 @@ const Settings: Component = () => {
</button>
</div>
</div>
</div>
</Card>
</div>
</Show>
{/* API Token Modal */}
<Show when={showApiTokenModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">
API Token Required
</h3>
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="API token required" size="md" class="mb-4" />
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
This Pulse instance requires an API token for export/import operations. Please enter the API token configured on the server.
</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
API Token
</label>
<input
@@ -2609,7 +2602,7 @@ const Settings: Component = () => {
value={apiTokenInput()}
onInput={(e) => setApiTokenInput(e.currentTarget.value)}
placeholder="Enter API token"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-200"
class={controlClass()}
/>
</div>
@@ -2653,19 +2646,19 @@ const Settings: Component = () => {
Authenticate
</button>
</div>
</div>
</Card>
</div>
</Show>
{/* Import Dialog */}
<Show when={showImportDialog()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Import Configuration</h3>
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Import configuration" size="md" class="mb-4" />
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Configuration File
</label>
<input
@@ -2675,12 +2668,12 @@ const Settings: Component = () => {
const file = e.currentTarget.files?.[0];
if (file) setImportFile(file);
}}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
class={controlClass('cursor-pointer')}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<div class={formField}>
<label class={labelClass()}>
Backup Password
</label>
<input
@@ -2688,9 +2681,9 @@ const Settings: Component = () => {
value={importPassphrase()}
onInput={(e) => setImportPassphrase(e.currentTarget.value)}
placeholder="Enter the password used when creating this backup"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class={controlClass()}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class={`${formHelpText} mt-1`}>
This is usually your Pulse login password, unless you used a custom passphrase
</p>
</div>
@@ -2721,7 +2714,7 @@ const Settings: Component = () => {
</button>
</div>
</div>
</div>
</Card>
</div>
</Show>
@@ -2737,4 +2730,4 @@ const Settings: Component = () => {
);
};
export default Settings;
export default Settings;
@@ -1,4 +1,5 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { formatBytes } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { PhysicalDisk } from '@/types/api';
@@ -86,17 +87,15 @@ export const DiskList: Component<DiskListProps> = (props) => {
return (
<div>
<Show when={filteredDisks().length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center text-gray-500">
No physical disks found
{props.selectedNode && ` for node ${props.selectedNode}`}
{props.searchTerm && ` matching "${props.searchTerm}"`}
</div>
</div>
<Card padding="lg" class="text-center text-gray-500">
No physical disks found
{props.selectedNode && ` for node ${props.selectedNode}`}
{props.searchTerm && ` matching "${props.searchTerm}"`}
</Card>
</Show>
<Show when={filteredDisks().length > 0}>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Card padding="none" class="overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
@@ -192,7 +191,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
</tbody>
</table>
</div>
</div>
</Card>
</Show>
</div>
);
@@ -8,6 +8,9 @@ import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { StorageFilter } from './StorageFilter';
import { DiskList } from './DiskList';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { SectionHeader } from '@/components/shared/SectionHeader';
const Storage: Component = () => {
@@ -263,7 +266,7 @@ const Storage: Component = () => {
{/* Show simple search for disks */}
<Show when={tabView() === 'disks'}>
<div class="mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-3">
<Card class="mb-3" padding="sm">
<div class="relative">
<input
type="text"
@@ -279,64 +282,75 @@ const Storage: Component = () => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</Card>
</Show>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading storage data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Connecting to monitoring service</p>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<div class="mx-auto flex h-12 w-12 items-center justify-center">
<svg class="h-8 w-8 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
)}
title="Loading storage data..."
description="Connecting to monitoring service"
/>
</Card>
</Show>
{/* Helpful hint for no PVE nodes but still show content */}
<Show when={connected() && initialDataReceived() && (state.nodes || []).filter((n) => n.type === 'pve').length === 0 && sortedStorage().length === 0 && searchTerm().trim() === ''}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No storage configured</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">Add a Proxmox VE or PBS node in the Settings tab to start monitoring storage.</p>
<button type="button"
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
)}
title="No storage configured"
description="Add a Proxmox VE or PBS node in the Settings tab to start monitoring storage."
actions={(
<button
type="button"
onClick={() => {
const settingsTab = document.querySelector('[role=\"tab\"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
)}
/>
</Card>
</Show>
{/* Conditional rendering based on tab */}
<Show when={tabView() === 'pools'}>
{/* No results found message for storage pools */}
<Show when={connected() && initialDataReceived() && sortedStorage().length === 0 && searchTerm().trim() !== ''}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No storage found</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">No storage matches your search "{searchTerm()}"</p>
</div>
</div>
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)}
title="No storage found"
description={`No storage matches your search "${searchTerm()}"`}
/>
</Card>
</Show>
{/* Storage Table - shows for both PVE and PBS storage */}
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table">
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
@@ -547,7 +561,7 @@ const Storage: Component = () => {
</tbody>
</table>
</div>
</div>
</Card>
</ComponentErrorBoundary>
</Show>
</Show>
@@ -565,4 +579,4 @@ const Storage: Component = () => {
);
};
export default Storage;
export default Storage;
@@ -1,4 +1,5 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
interface StorageFilterProps {
@@ -13,7 +14,7 @@ interface StorageFilterProps {
export const StorageFilter: Component<StorageFilterProps> = (props) => {
return (
<div class="storage-filter mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-3">
<Card class="storage-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1">
@@ -118,6 +119,6 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
</Show>
</div>
</div>
</div>
</Card>
);
};
@@ -0,0 +1,47 @@
import { JSX, splitProps, mergeProps } from 'solid-js';
type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger';
type Padding = 'none' | 'sm' | 'md' | 'lg';
type CardProps = {
tone?: Tone;
padding?: Padding;
hoverable?: boolean;
border?: boolean;
} & JSX.HTMLAttributes<HTMLDivElement>;
const toneClassMap: Record<Tone, string> = {
default: 'bg-white dark:bg-gray-800',
muted: 'bg-gray-50 dark:bg-gray-800/80',
info: 'bg-blue-50/70 dark:bg-blue-900/20',
success: 'bg-green-50/70 dark:bg-green-900/20',
warning: 'bg-amber-50/80 dark:bg-amber-900/20',
danger: 'bg-red-50/80 dark:bg-red-900/20'
};
const paddingClassMap: Record<Padding, string> = {
none: 'p-0',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
};
export function Card(props: CardProps) {
const merged = mergeProps({ tone: 'default' as Tone, padding: 'md' as Padding, hoverable: false, border: true }, props);
const [local, rest] = splitProps(merged, ['tone', 'padding', 'hoverable', 'border', 'class']);
const baseClass = 'rounded-lg shadow-sm transition-shadow duration-200';
const toneClass = toneClassMap[local.tone];
const paddingClass = paddingClassMap[local.padding];
const borderClass = local.border ? 'border border-gray-200 dark:border-gray-700' : '';
const hoverClass = local.hoverable ? 'hover:shadow-md' : '';
return (
<div
class={`${baseClass} ${toneClass} ${paddingClass} ${borderClass} ${hoverClass} ${local.class ?? ''}`.trim()}
{...rest}
/>
);
}
export default Card;
@@ -0,0 +1,68 @@
import { JSX, Show, mergeProps, splitProps } from 'solid-js';
import { SectionHeader } from '@/components/shared/SectionHeader';
type EmptyStateTone = 'default' | 'info' | 'success' | 'warning' | 'danger';
type EmptyStateProps = {
icon?: JSX.Element;
title: JSX.Element;
description?: JSX.Element;
actions?: JSX.Element;
tone?: EmptyStateTone;
align?: 'center' | 'left';
} & JSX.HTMLAttributes<HTMLDivElement>;
const titleToneClass: Record<EmptyStateTone, string> = {
default: '',
info: 'text-blue-700 dark:text-blue-300',
success: 'text-green-700 dark:text-green-300',
warning: 'text-amber-700 dark:text-amber-300',
danger: 'text-red-700 dark:text-red-300'
};
const descriptionToneClass: Record<EmptyStateTone, string> = {
default: '',
info: 'text-blue-600 dark:text-blue-300',
success: 'text-green-600 dark:text-green-300',
warning: 'text-amber-600 dark:text-amber-300',
danger: 'text-red-600 dark:text-red-300'
};
export function EmptyState(props: EmptyStateProps) {
const merged = mergeProps({ tone: 'default' as EmptyStateTone, align: 'center' as const }, props);
const [local, others] = splitProps(merged, ['icon', 'title', 'description', 'actions', 'tone', 'align', 'class']);
const alignment = local.align;
const tone = local.tone;
const containerClass = [
'flex flex-col gap-3',
alignment === 'center' ? 'items-center text-center' : 'items-start text-left',
local.class ?? ''
].join(' ').trim();
return (
<div class={containerClass} {...others}>
<Show when={local.icon}>
<div class={alignment === 'center' ? 'flex justify-center' : ''}>
{local.icon}
</div>
</Show>
<SectionHeader
align={alignment}
title={local.title}
description={local.description}
size={alignment === 'center' ? 'sm' : 'md'}
class={alignment === 'center' ? 'items-center' : 'items-start'}
titleClass={titleToneClass[tone]}
descriptionClass={`text-xs ${descriptionToneClass[tone]}`.trim()}
/>
<Show when={local.actions}>
<div class={alignment === 'center' ? 'mt-2 flex flex-col items-center gap-2' : 'mt-2 flex flex-col gap-2'}>
{local.actions}
</div>
</Show>
</div>
);
}
export default EmptyState;
@@ -0,0 +1,57 @@
const baseField = 'flex flex-col gap-1';
const baseLabel = 'text-sm font-medium text-gray-700 dark:text-gray-300';
const baseHelp = 'text-xs text-gray-500 dark:text-gray-400';
const baseControl = [
'w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
].join(' ');
const baseCheckbox = 'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:focus:ring-blue-400';
const join = (base: string, extra?: string) => (extra ? `${base} ${extra}`.trim() : base);
export const formSection = 'space-y-6';
export const formField = baseField;
export const formFieldInline = 'flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3';
export const formLabel = baseLabel;
export const formHelpText = baseHelp;
export const formControl = baseControl;
export const formCheckbox = baseCheckbox;
export const formControlDense = join(baseControl, 'py-1.5 px-2');
export const formControlMono = join(baseControl, 'font-mono');
export const formSelect = join(baseControl, 'pr-8 appearance-none');
export const formTextarea = join(baseControl, 'min-h-[120px] resize-vertical');
export const formLabelMuted = join(baseLabel, 'text-gray-500 dark:text-gray-400 font-normal');
export function labelClass(extra?: string) {
return join(baseLabel, extra);
}
export function controlClass(extra?: string) {
return join(baseControl, extra);
}
export function helpTextClass(extra?: string) {
return join(baseHelp, extra);
}
export default {
formSection,
formField,
formFieldInline,
formLabel,
formLabelMuted,
formHelpText,
formControl,
formControlDense,
formControlMono,
formSelect,
formTextarea,
formCheckbox,
labelClass,
controlClass,
helpTextClass
};
@@ -4,6 +4,7 @@ import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { Card } from '@/components/shared/Card';
interface NodeSummaryTableProps {
nodes: Node[];
@@ -102,7 +103,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
// This prevents the table from disappearing on refresh while data loads
return (
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[600px] border-collapse">
<thead>
@@ -309,6 +310,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</tbody>
</table>
</div>
</div>
</Card>
);
};
};
@@ -0,0 +1,48 @@
import { JSX, Show, splitProps, mergeProps } from 'solid-js';
type SectionHeaderProps = {
label?: JSX.Element;
title: JSX.Element;
description?: JSX.Element;
align?: 'left' | 'center';
size?: 'sm' | 'md' | 'lg';
titleClass?: string;
descriptionClass?: string;
} & JSX.HTMLAttributes<HTMLDivElement>;
export function SectionHeader(props: SectionHeaderProps) {
const merged = mergeProps({ align: 'left' as const, size: 'md' as const, titleClass: '', descriptionClass: '' }, props);
const [local, rest] = splitProps(merged, ['label', 'title', 'description', 'align', 'size', 'titleClass', 'descriptionClass', 'class']);
const alignmentClass = local.align === 'center' ? 'text-center items-center' : 'text-left items-start';
const sizeClass = () => {
switch (local.size) {
case 'sm':
return 'text-base';
case 'lg':
return 'text-2xl';
default:
return 'text-lg';
}
};
return (
<div class={`flex flex-col gap-1 ${alignmentClass} ${local.class ?? ''}`.trim()} {...rest}>
<Show when={local.label}>
<span class="text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{local.label}
</span>
</Show>
<h2 class={`${sizeClass()} font-semibold text-gray-900 dark:text-gray-100 ${local.titleClass ?? ''}`.trim()}>
{local.title}
</h2>
<Show when={local.description}>
<p class={`text-sm text-gray-600 dark:text-gray-400 ${local.descriptionClass ?? ''}`.trim()}>
{local.description}
</p>
</Show>
</div>
);
}
export default SectionHeader;
@@ -0,0 +1,42 @@
import { JSX, Show, splitProps } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
type SettingsPanelProps = {
title: JSX.Element;
description?: JSX.Element;
action?: JSX.Element;
bodyClass?: string;
tone?: 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger';
padding?: 'none' | 'sm' | 'md' | 'lg';
} & JSX.HTMLAttributes<HTMLDivElement>;
export function SettingsPanel(props: SettingsPanelProps) {
const [local, rest] = splitProps(props, ['title', 'description', 'action', 'bodyClass', 'children', 'class', 'tone', 'padding']);
return (
<Card
padding={local.padding ?? 'lg'}
tone={local.tone ?? 'default'}
class={`space-y-6 ${local.class ?? ''}`.trim()}
{...rest}
>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<SectionHeader
title={local.title}
description={local.description}
size="sm"
class="flex-1"
/>
<Show when={local.action}>
<div class="md:ml-6">{local.action}</div>
</Show>
</div>
<div class={local.bodyClass ?? 'space-y-4'}>
{local.children}
</div>
</Card>
);
}
export default SettingsPanel;
@@ -0,0 +1,56 @@
import { JSX, mergeProps, splitProps } from 'solid-js';
export type ToggleProps = {
label?: JSX.Element;
description?: JSX.Element;
containerClass?: string;
} & JSX.InputHTMLAttributes<HTMLInputElement>;
export function Toggle(props: ToggleProps) {
const merged = mergeProps({ containerClass: '' }, props);
const [local, rest] = splitProps(merged, ['label', 'description', 'containerClass', 'class', 'disabled']);
const isDisabled = () => Boolean(local.disabled ?? rest.disabled);
const isChecked = () => {
const value = rest.checked as unknown;
if (typeof value === 'function') {
try {
return Boolean((value as () => unknown)());
} catch {
return false;
}
}
return Boolean(value);
};
return (
<label class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}>
<span class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<input type="checkbox" class="sr-only" {...rest} />
<span
class={`absolute inset-0 rounded-full transition ${
isChecked()
? 'bg-blue-600 dark:bg-blue-500'
: isDisabled()
? 'bg-gray-300 dark:bg-gray-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
/>
<span
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow transition-transform dark:bg-gray-100"
style={{ transform: isChecked() ? 'translateX(20px)' : 'translateX(0)' }}
/>
</span>
{(local.label || local.description) && (
<span class="flex flex-col text-sm text-gray-700 dark:text-gray-300">
{local.label}
<span class="text-xs text-gray-500 dark:text-gray-400">
{local.description}
</span>
</span>
)}
</label>
);
}
export default Toggle;
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -1,7 +1,7 @@
// Event bus for cross-component communication
// Event types
export type EventType = 'node_auto_registered' | 'refresh_nodes' | 'discovery_updated' | 'theme_changed';
export type EventType = 'node_auto_registered' | 'refresh_nodes' | 'discovery_updated' | 'discovery_status' | 'theme_changed';
// Event data types
export interface NodeAutoRegisteredData {
@@ -17,6 +17,9 @@ export interface NodeAutoRegisteredData {
}
export interface DiscoveryUpdatedData {
scanning?: boolean;
cached?: boolean;
timestamp?: number;
servers: Array<{
ip: string;
port: number;
@@ -26,16 +29,22 @@ export interface DiscoveryUpdatedData {
release?: string;
}>;
errors?: string[];
timestamp?: number;
immediate?: boolean;
discoveredNodes?: number;
}
export interface DiscoveryStatusData {
scanning: boolean;
subnet?: string;
timestamp?: number;
}
// Map event types to their data types
export type EventDataMap = {
'node_auto_registered': NodeAutoRegisteredData;
'refresh_nodes': void;
'discovery_updated': DiscoveryUpdatedData;
'discovery_status': DiscoveryStatusData;
'theme_changed': string; // 'light' or 'dark'
}
@@ -69,4 +78,4 @@ class EventBus {
}
}
export const eventBus = new EventBus();
export const eventBus = new EventBus();
+11
View File
@@ -347,6 +347,17 @@ export function createWebSocketStore(url: string) {
} else if (message.type === 'discovery_update') {
// Discovery scan completed with new results
eventBus.emit('discovery_updated', message.data);
} else if (message.type === 'discovery_started') {
eventBus.emit('discovery_status', {
scanning: true,
subnet: message.data?.subnet,
timestamp: message.data?.timestamp
});
} else if (message.type === 'discovery_complete') {
eventBus.emit('discovery_status', {
scanning: false,
timestamp: message.data?.timestamp
});
} else if (message.type === 'settingsUpdate') {
// Settings have been updated (e.g., theme change)
if (message.data?.theme) {
File diff suppressed because it is too large Load Diff
+63 -15
View File
@@ -5,7 +5,11 @@ import (
"io"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"github.com/rs/zerolog/log"
)
@@ -15,6 +19,42 @@ import (
//go:embed all:frontend-modern/dist
var embeddedFrontend embed.FS
var (
devProxyOnce sync.Once
devProxy *httputil.ReverseProxy
devProxyErr error
devProxyURL string
)
func getFrontendDevProxy() (*httputil.ReverseProxy, error) {
devProxyOnce.Do(func() {
devURL := strings.TrimSpace(os.Getenv("FRONTEND_DEV_SERVER"))
if devURL == "" {
return
}
target, err := url.Parse(devURL)
if err != nil {
devProxyErr = err
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("Frontend dev proxy error")
w.WriteHeader(http.StatusBadGateway)
}
devProxy = proxy
devProxyURL = target.String()
log.Warn().Str("frontend_dev_server", devProxyURL).Msg("Serving frontend via development proxy")
})
if devProxyErr != nil {
return nil, devProxyErr
}
return devProxy, nil
}
// getFrontendFS returns the embedded frontend filesystem
func getFrontendFS() (http.FileSystem, error) {
// Strip the prefix to serve files from root
@@ -27,16 +67,24 @@ func getFrontendFS() (http.FileSystem, error) {
// serveFrontendHandler returns a handler for serving the embedded frontend
func serveFrontendHandler() http.HandlerFunc {
if proxy, err := getFrontendDevProxy(); err != nil {
log.Error().Err(err).Msg("Failed to initialize frontend dev proxy, falling back to embedded assets")
} else if proxy != nil {
return func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
}
// Get the embedded filesystem
fsys, err := getFrontendFS()
if err != nil {
log.Fatal().Err(err).Msg("Failed to get embedded frontend")
}
return func(w http.ResponseWriter, r *http.Request) {
// Clean the path
p := r.URL.Path
// Handle root path specially to avoid FileServer's directory redirect
// Issue #334: Serve index.html directly without using FileServer for root
if p == "/" || p == "" {
@@ -47,21 +95,21 @@ func serveFrontendHandler() http.HandlerFunc {
return
}
defer file.Close()
// Check that it's not a directory
_, err = file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
// Read the file content
content, err := io.ReadAll(file)
if err != nil {
http.NotFound(w, r)
return
}
// Serve the content with cache-busting headers
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
@@ -70,15 +118,15 @@ func serveFrontendHandler() http.HandlerFunc {
w.Write(content)
return
}
// Remove leading slash for filesystem lookup
lookupPath := strings.TrimPrefix(p, "/")
// Check if file exists in embedded FS
file, err := fsys.Open(lookupPath)
if err == nil {
defer file.Close()
// Get file info
stat, err := file.Stat()
if err == nil && !stat.IsDir() {
@@ -102,18 +150,18 @@ func serveFrontendHandler() http.HandlerFunc {
} else if strings.HasSuffix(lookupPath, ".svg") {
contentType = "image/svg+xml"
}
w.Header().Set("Content-Type", contentType)
w.Write(content)
return
}
}
}
// For SPA routing, serve index.html for non-API routes
if !strings.HasPrefix(p, "/api/") &&
!strings.HasPrefix(p, "/ws") &&
!strings.HasPrefix(p, "/socket.io/") {
if !strings.HasPrefix(p, "/api/") &&
!strings.HasPrefix(p, "/ws") &&
!strings.HasPrefix(p, "/socket.io/") {
// Serve index.html for client-side routing
indexFile, err := fsys.Open("index.html")
if err == nil {
@@ -129,8 +177,8 @@ func serveFrontendHandler() http.HandlerFunc {
}
}
}
// Not found
http.NotFound(w, r)
}
}
}
+13 -2
View File
@@ -1,15 +1,22 @@
package config
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
// CreateProxmoxConfig creates a proxmox.ClientConfig from a PVEInstance
func CreateProxmoxConfig(node *PVEInstance) proxmox.ClientConfig {
user := node.User
if node.TokenName == "" && node.TokenValue == "" && user != "" && !strings.Contains(user, "@") {
user = user + "@pam"
}
return proxmox.ClientConfig{
Host: node.Host,
User: node.User,
User: user,
Password: node.Password,
TokenName: node.TokenName,
TokenValue: node.TokenValue,
@@ -33,6 +40,10 @@ func CreatePBSConfig(node *PBSInstance) pbs.ClientConfig {
// CreateProxmoxConfigFromFields creates a proxmox.ClientConfig from individual fields
func CreateProxmoxConfigFromFields(host, user, password, tokenName, tokenValue, fingerprint string, verifySSL bool) proxmox.ClientConfig {
if tokenName == "" && tokenValue == "" && user != "" && !strings.Contains(user, "@") {
user = user + "@pam"
}
return proxmox.ClientConfig{
Host: host,
User: user,
@@ -55,4 +66,4 @@ func CreatePBSConfigFromFields(host, user, password, tokenName, tokenValue, fing
VerifySSL: verifySSL,
Fingerprint: fingerprint,
}
}
}
+62 -57
View File
@@ -32,12 +32,12 @@ func IsPasswordHashed(password string) bool {
if !strings.HasPrefix(password, "$2") {
return false
}
length := len(password)
if length == 60 {
return true // Perfect bcrypt hash
}
// Warn about truncated or invalid hashes
if length >= 55 && length < 60 {
log.Error().
@@ -46,7 +46,7 @@ func IsPasswordHashed(password string) bool {
Msg("Bcrypt hash appears truncated! Should be 60 characters. Password will be treated as plaintext.")
return false // Treat as plaintext to force user to fix it
}
return false
}
@@ -54,13 +54,13 @@ func IsPasswordHashed(password string) bool {
// NOTE: The envconfig tags are legacy and not used - configuration is loaded from encrypted JSON files
type Config struct {
// Server settings
BackendHost string `envconfig:"BACKEND_HOST" default:"0.0.0.0"`
BackendPort int `envconfig:"BACKEND_PORT" default:"3000"`
FrontendHost string `envconfig:"FRONTEND_HOST" default:"0.0.0.0"`
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
BackendHost string `envconfig:"BACKEND_HOST" default:"0.0.0.0"`
BackendPort int `envconfig:"BACKEND_PORT" default:"3000"`
FrontendHost string `envconfig:"FRONTEND_HOST" default:"0.0.0.0"`
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
// Proxmox VE connections
PVEInstances []PVEInstance
@@ -92,14 +92,14 @@ type Config struct {
DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"`
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
// Proxy authentication settings
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
ProxyAuthUserHeader string `envconfig:"PROXY_AUTH_USER_HEADER"`
ProxyAuthRoleHeader string `envconfig:"PROXY_AUTH_ROLE_HEADER"`
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
ProxyAuthUserHeader string `envconfig:"PROXY_AUTH_USER_HEADER"`
ProxyAuthRoleHeader string `envconfig:"PROXY_AUTH_ROLE_HEADER"`
ProxyAuthRoleSeparator string `envconfig:"PROXY_AUTH_ROLE_SEPARATOR" default:"|"`
ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"`
ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"`
ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"`
ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"`
// HTTPS/TLS settings
HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"`
TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""`
@@ -110,15 +110,15 @@ type Config struct {
AutoUpdateEnabled bool `envconfig:"AUTO_UPDATE_ENABLED" default:"false"`
AutoUpdateCheckInterval time.Duration `envconfig:"AUTO_UPDATE_CHECK_INTERVAL" default:"24h"`
AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"`
// Discovery settings
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"`
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
// Deprecated - for backward compatibility
Port int `envconfig:"PORT"` // Maps to BackendPort
Debug bool `envconfig:"DEBUG" default:"false"`
// Track which settings are overridden by environment variables
EnvOverrides map[string]bool `json:"-"`
}
@@ -126,7 +126,7 @@ type Config struct {
// PVEInstance represents a Proxmox VE connection
type PVEInstance struct {
Name string
Host string // Primary endpoint (user-provided)
Host string // Primary endpoint (user-provided)
User string
Password string
TokenName string
@@ -137,10 +137,10 @@ type PVEInstance struct {
MonitorContainers bool
MonitorStorage bool
MonitorBackups bool
// Cluster support
IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable
IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes
}
@@ -182,7 +182,7 @@ func Load() (*Config, error) {
if dir := os.Getenv("PULSE_DATA_DIR"); dir != "" {
dataDir = dir
}
// Load .env file if it exists (for deployment overrides)
envFile := filepath.Join(dataDir, ".env")
if _, err := os.Stat(envFile); err == nil {
@@ -192,17 +192,17 @@ func Load() (*Config, error) {
log.Info().Str("file", envFile).Msg("Loaded .env file for deployment overrides")
}
}
// Also try loading from current directory for development
if err := godotenv.Load(); err == nil {
log.Info().Msg("Loaded configuration from .env in current directory")
}
// Initialize config with defaults
cfg := &Config{
BackendHost: "0.0.0.0",
BackendPort: 3000,
FrontendHost: "0.0.0.0",
FrontendHost: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
@@ -222,7 +222,7 @@ func Load() (*Config, error) {
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
}
// Initialize persistence
persistence := NewConfigPersistence(dataDir)
if persistence != nil {
@@ -239,14 +239,14 @@ func Load() (*Config, error) {
} else if err != nil {
log.Warn().Err(err).Msg("Failed to load nodes configuration")
}
// Load system configuration
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
// Load PBS polling interval if configured
if systemSettings.PBSPollingInterval > 0 {
cfg.PBSPollingInterval = time.Duration(systemSettings.PBSPollingInterval) * time.Second
}
if systemSettings.UpdateChannel != "" {
cfg.UpdateChannel = systemSettings.UpdateChannel
}
@@ -288,16 +288,16 @@ func Load() (*Config, error) {
}
}
}
// Ensure PBS polling interval has default if not set
// Note: PVE polling is hardcoded to 10s in monitor.go
if cfg.PBSPollingInterval == 0 {
cfg.PBSPollingInterval = 60 * time.Second
}
// Limited environment variable support
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
if p, err := strconv.Atoi(frontendPort); err == nil {
@@ -344,12 +344,12 @@ func Load() (*Config, error) {
} else {
log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DISABLE_AUTH not set, DisableAuth remains")
}
// Load proxy authentication settings
if proxyAuthSecret := os.Getenv("PROXY_AUTH_SECRET"); proxyAuthSecret != "" {
cfg.ProxyAuthSecret = proxyAuthSecret
log.Info().Msg("Proxy authentication secret configured")
// Load other proxy auth settings
if userHeader := os.Getenv("PROXY_AUTH_USER_HEADER"); userHeader != "" {
cfg.ProxyAuthUserHeader = userHeader
@@ -399,8 +399,7 @@ func Load() (*Config, error) {
log.Debug().Msg("Loaded pre-hashed password from env var")
}
}
// HTTPS/TLS configuration from environment
if httpsEnabled := os.Getenv("HTTPS_ENABLED"); httpsEnabled != "" {
cfg.HTTPSEnabled = httpsEnabled == "true" || httpsEnabled == "1"
@@ -414,11 +413,18 @@ func Load() (*Config, error) {
cfg.TLSKeyFile = tlsKeyFile
log.Debug().Str("key_file", tlsKeyFile).Msg("TLS key file from env var")
}
// REMOVED: Update channel, auto-update, connection timeout, and allowed origins env vars
// These settings now ONLY come from system.json to prevent confusion
// Only keeping essential deployment/infrastructure env vars
// Normalize PVE user fields for password authentication
for i := range cfg.PVEInstances {
if cfg.PVEInstances[i].TokenName == "" && cfg.PVEInstances[i].TokenValue == "" && cfg.PVEInstances[i].User != "" && !strings.Contains(cfg.PVEInstances[i].User, "@") {
cfg.PVEInstances[i].User = cfg.PVEInstances[i].User + "@pam"
}
}
if cfg.AllowedOrigins == "" {
// If not configured and we're in development mode (different ports for frontend/backend)
// allow localhost for development convenience
@@ -470,7 +476,7 @@ func Load() (*Config, error) {
log.Info().Str("url", detectedURL).Msg("Auto-detected public URL for webhook notifications")
}
}
// Set log level
switch cfg.LogLevel {
case "debug":
@@ -482,12 +488,12 @@ func Load() (*Config, error) {
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel) // Default to info level
}
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return cfg, nil
}
@@ -496,12 +502,12 @@ func SaveConfig(cfg *Config) error {
if globalPersistence == nil {
return fmt.Errorf("config persistence not initialized")
}
// Save nodes configuration
if err := globalPersistence.SaveNodesConfig(cfg.PVEInstances, cfg.PBSInstances); err != nil {
return fmt.Errorf("failed to save nodes config: %w", err)
}
// Save system configuration
systemSettings := SystemSettings{
// Note: PVE polling is hardcoded to 10s
@@ -519,11 +525,10 @@ func SaveConfig(cfg *Config) error {
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
return fmt.Errorf("failed to save system config: %w", err)
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server settings
@@ -533,13 +538,13 @@ func (c *Config) Validate() error {
if c.FrontendPort <= 0 || c.FrontendPort > 65535 {
return fmt.Errorf("invalid frontend port: %d", c.FrontendPort)
}
// Validate monitoring settings
// Note: PVE polling is hardcoded to 10s
if c.ConnectionTimeout < time.Second {
return fmt.Errorf("connection timeout must be at least 1 second")
}
// Validate PVE instances
for i, pve := range c.PVEInstances {
if pve.Host == "" {
@@ -594,12 +599,12 @@ func detectPublicURL(port int) string {
}
}
}
// Method 2: Try to get the primary network interface IP
if ip := getOutboundIP(); ip != "" {
return fmt.Sprintf("http://%s:%d", ip, port)
}
// Method 3: Check if running in Docker (check for .dockerenv)
if _, err := os.Stat("/.dockerenv"); err == nil {
// In Docker, try to get the host IP from default route
@@ -617,7 +622,7 @@ func detectPublicURL(port int) string {
}
}
}
// Method 4: Get all non-loopback IPs and use the first private one
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, addr := range addrs {
@@ -625,9 +630,9 @@ func detectPublicURL(port int) string {
if ipnet.IP.To4() != nil {
ip := ipnet.IP.String()
// Prefer private IPs (RFC1918)
if strings.HasPrefix(ip, "192.168.") ||
strings.HasPrefix(ip, "10.") ||
strings.HasPrefix(ip, "172.") {
if strings.HasPrefix(ip, "192.168.") ||
strings.HasPrefix(ip, "10.") ||
strings.HasPrefix(ip, "172.") {
return fmt.Sprintf("http://%s:%d", ip, port)
}
}
@@ -642,7 +647,7 @@ func detectPublicURL(port int) string {
}
}
}
return ""
}
@@ -658,7 +663,7 @@ func getOutboundIP() string {
}
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP.String()
}
}
+4
View File
@@ -185,6 +185,10 @@ func (n *NotificationManager) GetWebhooks() []WebhookConfig {
n.mu.RLock()
defer n.mu.RUnlock()
if len(n.webhooks) == 0 {
return []WebhookConfig{}
}
webhooks := make([]WebhookConfig, len(n.webhooks))
copy(webhooks, n.webhooks)
return webhooks
+1
View File
@@ -0,0 +1 @@
go1.25.1
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
FRONTEND_DEV_HOST=${FRONTEND_DEV_HOST:-127.0.0.1}
FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-5173}
FRONTEND_DEV_SERVER=${FRONTEND_DEV_SERVER:-http://${FRONTEND_DEV_HOST}:${FRONTEND_DEV_PORT}}
BACKEND_CMD=${BACKEND_CMD:-go run ./cmd/pulse}
VITE_ARGS=${VITE_ARGS:-}
cleanup() {
local exit_code=${1:-$?}
trap - EXIT INT TERM
if [[ -n ${VITE_PID:-} ]] && kill -0 "$VITE_PID" >/dev/null 2>&1; then
kill "$VITE_PID" >/dev/null 2>&1 || true
fi
if [[ -n ${BACKEND_PID:-} ]] && kill -0 "$BACKEND_PID" >/dev/null 2>&1; then
kill "$BACKEND_PID" >/dev/null 2>&1 || true
fi
wait >/dev/null 2>&1 || true
exit "$exit_code"
}
trap cleanup EXIT INT TERM
export FRONTEND_DEV_SERVER
printf "[dev-hot] Starting Vite dev server at %s\n" "$FRONTEND_DEV_SERVER"
(
cd frontend-modern
npm run dev -- --host "$FRONTEND_DEV_HOST" --port "$FRONTEND_DEV_PORT" $VITE_ARGS
) &
VITE_PID=$!
# Give Vite a moment to boot up before starting the backend proxy.
sleep 2
printf "[dev-hot] Starting Pulse backend with FRONTEND_DEV_SERVER=%s\n" "$FRONTEND_DEV_SERVER"
${BACKEND_CMD} &
BACKEND_PID=$!
# Wait for either process to exit and propagate the status code.
wait -n "$VITE_PID" "$BACKEND_PID"
EXIT_STATUS=$?
cleanup "$EXIT_STATUS"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION_FILE="${SCRIPT_DIR}/.go-version"
DEFAULT_VERSION="go1.25.1"
TARGET_ROOT="/opt/toolchains/go"
DOWNLOAD_ROOT="https://dl.google.com/go"
GOPATH_DIR="/var/lib/pulse/go"
CACHE_DIR="/var/cache/pulse/go-build"
TMP_DIR="/var/cache/pulse/tmp"
if [[ ! -f "$VERSION_FILE" ]]; then
echo "$DEFAULT_VERSION" | sudo tee "$VERSION_FILE" >/dev/null
fi
VERSION="$(tr -d '
' < "$VERSION_FILE")"
ARCHIVE="${VERSION}.linux-amd64.tar.gz"
DOWNLOAD_DIR="${TMPDIR:-/tmp}/go-install"
ARCHIVE_PATH="$DOWNLOAD_DIR/$ARCHIVE"
SHA_PATH="$ARCHIVE_PATH.sha256"
mkdir -p "$DOWNLOAD_DIR"
if [[ ! -f "$ARCHIVE_PATH" ]]; then
curl -fsSL "$DOWNLOAD_ROOT/$ARCHIVE" -o "$ARCHIVE_PATH"
fi
curl -fsSL "$DOWNLOAD_ROOT/$ARCHIVE.sha256" -o "$SHA_PATH"
CHECKSUM="$(tr -d '
' < "$SHA_PATH")"
printf '%s %s
' "$CHECKSUM" "$ARCHIVE_PATH" | sha256sum -c -
sudo mkdir -p "$TARGET_ROOT"
sudo rm -rf "$TARGET_ROOT/$VERSION"
sudo tar -C "$TARGET_ROOT" -xzf "$ARCHIVE_PATH"
sudo mv "$TARGET_ROOT/go" "$TARGET_ROOT/$VERSION"
sudo ln -sfn "$TARGET_ROOT/$VERSION" "$TARGET_ROOT/current"
sudo ln -sfn /opt/toolchains/go/current /usr/local/go
sudo mkdir -p "$GOPATH_DIR" "$GOPATH_DIR/bin" "$GOPATH_DIR/pkg"
sudo chown -R pulse:pulse "$GOPATH_DIR"
sudo mkdir -p "$CACHE_DIR" "$TMP_DIR"
sudo chown -R pulse:pulse "$CACHE_DIR" "$TMP_DIR"
sudo ln -sfn /opt/toolchains/go/current/bin/go /usr/local/bin/go
sudo ln -sfn /opt/toolchains/go/current/bin/gofmt /usr/local/bin/gofmt