refactor: unify agent hub layout and copy

This commit is contained in:
rcourtman 2025-10-23 18:09:09 +00:00
parent 30b78784e0
commit f800d7d59d
4 changed files with 144 additions and 77 deletions

View file

@ -1,5 +1,4 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow } from './GuestRow';
@ -42,15 +41,8 @@ export function Dashboard(props: DashboardProps) {
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [guestMetadata, setGuestMetadata] = createSignal<Record<string, GuestMetadata>>({});
// Stable guest store using reconcile to prevent row remounting during websocket updates
const guestKey = (g: VM | Container) =>
g.id ?? (g.instance === g.node ? `${g.node}-${g.vmid}` : `${g.instance}-${g.node}-${g.vmid}`);
const [guestStore, setGuestStore] = createStore<(VM | Container)[]>([]);
// Reconcile guests whenever props change
createEffect(() => {
setGuestStore(reconcile([...props.vms, ...props.containers], { key: guestKey, merge: true }));
});
// Combine VMs and containers into a single list for filtering
const allGuests = createMemo<(VM | Container)[]>(() => [...props.vms, ...props.containers]);
// Initialize from localStorage with proper type checking
const [viewMode, setViewMode] = usePersistentSignal<ViewMode>('dashboardViewMode', 'all', {
@ -248,9 +240,6 @@ export function Dashboard(props: DashboardProps) {
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Use the stable guest store
const allGuests = createMemo(() => guestStore);
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();

View file

@ -0,0 +1,34 @@
import type { Component, JSX } from 'solid-js';
import { Show } from 'solid-js';
interface AgentStepSectionProps {
step: string;
title: string;
description?: string;
children: JSX.Element;
}
export const AgentStepSection: Component<AgentStepSectionProps> = (props) => {
return (
<section class="space-y-3">
<header class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-300">
{props.step}
</p>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
{props.title}
</h3>
</div>
<Show when={props.description}>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
{props.description}
</p>
</Show>
</header>
<div>{props.children}</div>
</section>
);
};
export default AgentStepSection;

View file

@ -46,8 +46,8 @@ export const DockerAgents: Component = () => {
const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const tokenStepLabel = 'Step 1 · Generate API token';
const commandStepLabel = 'Step 2 · Install command';
const tokenStepLabel = 'Step 2 · Generate API token';
const commandStepLabel = 'Step 3 · Install command';
const {
token: apiToken,

View file

@ -20,6 +20,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import { AgentStepSection } from './AgentStepSection';
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Mail from 'lucide-solid/icons/mail';
@ -448,7 +449,6 @@ const Settings: Component<SettingsProps> = (props) => {
],
},
];
const agentCards = agentGroups.flatMap((group) => group.agents);
const agentPaths: Record<AgentKey, string> = {
pve: '/settings/pve',
@ -2106,66 +2106,85 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="p-3 sm:p-6 overflow-x-auto">
<Show when={activeTab() === 'agent-hub'}>
<Card padding="lg" class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
Step 1 · Choose a platform
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Select the integration you want to deploy or manage.
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={agentCards}>
{(card) => {
const isActive = () => selectedAgent() === card.id;
return (
<button
type="button"
disabled={card.disabled}
class={`flex flex-col items-start gap-3 rounded-xl border transition-colors p-4 text-left shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
card.disabled
? 'cursor-not-allowed opacity-60 border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'
: isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
}`}
onClick={() => {
if (card.disabled) return;
handleSelectAgent(card.id);
}}
>
<div class="flex items-center gap-3">
<div
class={`rounded-md p-2 ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{card.icon}
</div>
<div class="flex-1">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{card.label}
<AgentStepSection
step="Step 1"
title="Choose a platform"
description="Pick the integration you want to configure. Cards switch the guidance shown in the next step."
>
<Card padding="lg" class="space-y-6">
<For each={agentGroups}>
{(group) => (
<section class="space-y-3">
<header class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{group.title}
</h4>
<Show when={group.description}>
<p class="text-xs text-gray-600 dark:text-gray-400">
{group.description}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{card.description}
</p>
</div>
</Show>
</div>
</button>
);
}}
</header>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={group.agents}>
{(card) => {
const isActive = () => selectedAgent() === card.id;
return (
<button
type="button"
disabled={card.disabled}
class={`flex flex-col items-start gap-3 rounded-xl border transition-colors p-4 text-left shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
card.disabled
? 'cursor-not-allowed opacity-60 border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'
: isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
}`}
onClick={() => {
if (card.disabled) return;
handleSelectAgent(card.id);
}}
>
<div class="flex items-center gap-3">
<div
class={`rounded-md p-2 ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{card.icon}
</div>
<div class="flex-1">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{card.label}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{card.description}
</p>
</div>
</div>
</button>
);
}}
</For>
</div>
</section>
)}
</For>
</div>
</Card>
</Card>
</AgentStepSection>
</Show>
{/* PVE Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pve'}>
<div class="space-y-4">
<AgentStepSection
step="Step 2"
title="Connect Proxmox VE"
description="Link Pulse to your Proxmox VE cluster with a dedicated API token. Quick setup can scaffold users, roles, and permissions for you."
>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
@ -2682,11 +2701,17 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
</div>
</Show>
</AgentStepSection>
</Show>
{/* PBS Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pbs'}>
<div class="space-y-4">
<AgentStepSection
step="Step 2"
title="Connect Proxmox Backup Server"
description="Provide a PBS API token with backup read access. Use quick setup to generate the token and grant the minimum privileges."
>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
@ -3092,11 +3117,17 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
</div>
</Show>
</AgentStepSection>
</Show>
{/* PMG Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pmg'}>
<div class="space-y-4">
<AgentStepSection
step="Step 2"
title="Connect Proxmox Mail Gateway"
description="Onboard each Mail Gateway with a limited API token so Pulse can read queue depth and quarantine metrics."
>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
@ -3492,11 +3523,18 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
</div>
</Show>
</AgentStepSection>
</Show>
{/* Docker Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'docker'}>
<DockerAgents />
<AgentStepSection
step="Step 2"
title="Deploy the Docker agent"
description="Mint a scoped reporting token and copy the install commands for the Docker host you are onboarding."
>
<DockerAgents />
</AgentStepSection>
</Show>
{/* Podman Tab */}
@ -3517,7 +3555,13 @@ const Settings: Component<SettingsProps> = (props) => {
{/* Host Agents */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'host'}>
<HostAgents />
<AgentStepSection
step="Step 2"
title="Install the Pulse host agent"
description="Pick the operating system, generate a scoped token, and copy the tailored commands for Linux, macOS, or Windows."
>
<HostAgents />
</AgentStepSection>
</Show>
{/* System General Tab */}