mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
feat: unify styling and improve cluster detection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+596
-379
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
go1.25.1
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+49
@@ -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
|
||||
Reference in New Issue
Block a user