refactor: streamline host and Docker agent setup UI

Simplifies the onboarding flow by removing verbose instructions and toggles, consolidating navigation elements, and cleaning up the settings interface. Improves the macOS host agent installer with better Keychain access control and launchd service management.
This commit is contained in:
rcourtman 2025-10-24 14:59:50 +00:00
parent 8f553a93e9
commit 655fec2225
9 changed files with 424 additions and 468 deletions

View file

@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true
# Add token to Keychain # Add token to Keychain
if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then KEYCHAIN_SERVICE="pulse-host-agent"
log_success "Token stored securely in macOS Keychain" KEYCHAIN_ACCOUNT="$USER"
USE_KEYCHAIN=true
KEYCHAIN_APPS=(
"/usr/local/bin/pulse-host-agent"
"/usr/local/bin/pulse-host-agent-wrapper.sh"
"/usr/bin/security"
)
KEYCHAIN_ARGS=()
for app in "${KEYCHAIN_APPS[@]}"; do
KEYCHAIN_ARGS+=(-T "$app")
done
if security add-generic-password \
-s "$KEYCHAIN_SERVICE" \
-a "$KEYCHAIN_ACCOUNT" \
-w "$PULSE_TOKEN" \
-U \
"${KEYCHAIN_ARGS[@]}" 2>/dev/null; then
if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
else
log_warn "Token saved but Keychain denied non-interactive read access"
log_info "Will fall back to embedding token in the launchd plist"
USE_KEYCHAIN=false
fi
else else
log_warn "Failed to store token in Keychain, will use plist instead" log_warn "Failed to store token in Keychain, will use plist instead"
log_info "You may need to grant Keychain access permissions" log_info "You may need to grant Keychain access permissions"
@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
# Create wrapper script if using Keychain # Create wrapper script if using Keychain
if [[ "$USE_KEYCHAIN" == true ]]; then if [[ "$USE_KEYCHAIN" == true ]]; then
WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh" WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh"
TMP_WRAPPER=$(mktemp)
cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF' cat > "$TMP_WRAPPER" <<'WRAPPER_EOF'
#!/bin/bash #!/bin/bash
# Pulse Host Agent Wrapper - Reads token from Keychain # Pulse Host Agent Wrapper - Reads token from Keychain
set -e set -u
LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log"
mkdir -p "$(dirname "$LOG_FILE")"
# Read token from Keychain # Read token from Keychain
PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "") if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then
echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE"
PULSE_TOKEN=""
fi
# Export for agent to use # Export for agent to use
export PULSE_TOKEN export PULSE_TOKEN
@ -435,7 +466,23 @@ export PULSE_TOKEN
exec /usr/local/bin/pulse-host-agent "$@" exec /usr/local/bin/pulse-host-agent "$@"
WRAPPER_EOF WRAPPER_EOF
chmod +x "$WRAPPER_SCRIPT" if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then
if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then
rm -f "$TMP_WRAPPER"
log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo."
exit 1
fi
fi
if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then
log_error "Failed to set execute permissions on $WRAPPER_SCRIPT."
exit 1
fi
if command -v chown &>/dev/null; then
sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true
fi
log_success "Created Keychain wrapper script" log_success "Created Keychain wrapper script"
# Create plist using wrapper (token not in plist!) # Create plist using wrapper (token not in plist!)
@ -502,8 +549,23 @@ EOF
# Set restrictive permissions on plist # Set restrictive permissions on plist
chmod 600 "$LAUNCHD_PLIST" chmod 600 "$LAUNCHD_PLIST"
launchctl load "$LAUNCHD_PLIST" LAUNCH_TARGET="gui/$(id -u)"
log_success "Launchd service enabled and started"
# Attempt to unload any existing service instance
if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then
log_info "Replaced existing launchd service definition"
fi
if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then
launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
log_success "Launchd service enabled and started"
else
log_error "Failed to load launchd service. Try running:"
echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST"
echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent"
exit 1
fi
else else
log_warn "Automatic service setup not available for this platform" log_warn "Automatic service setup not available for this platform"
log_info "To run the agent manually:" log_info "To run the agent manually:"

View file

@ -999,18 +999,6 @@ function AppLayout(props: {
> >
{platform.icon} {platform.icon}
<span>{platform.label}</span> <span>{platform.label}</span>
<Show when={!platform.live}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
navigate(platform.settingsRoute);
}}
class="ml-1 text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-600 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-0"
>
Add host
</button>
</Show>
</div> </div>
); );
}} }}

View file

@ -663,19 +663,9 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
{/* Left: Host List - Only show if more than 1 host */} {/* Left: Host List - Only show if more than 1 host */}
<Show when={sortedHosts().length > 1}> <Show when={sortedHosts().length > 1}>
<Card padding="none" class="w-72 flex-shrink-0 overflow-hidden"> <Card padding="none" class="w-72 flex-shrink-0 overflow-hidden">
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between gap-2"> <div class="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div> <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Hosts</h3>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Hosts</h3> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}</p>
</div>
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-1 rounded border border-blue-200 dark:border-blue-500/40 bg-white dark:bg-blue-500/10 px-2 py-1 text-[11px] font-medium text-blue-600 dark:text-blue-300 shadow-sm hover:bg-blue-50 dark:hover:bg-blue-500/20 transition-colors"
>
<span class="flex items-center justify-center">+</span>
<span>Add Host</span>
</button>
</div> </div>
<div class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedHosts()}> <For each={sortedHosts()}>
@ -1054,29 +1044,6 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically. Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically.
</span> </span>
} }
actions={
<>
<CopyButton
text={`docker run -d \ \
--name pulse-docker-agent \ \
-e PULSE_URL="http://<pulse-server>:8080" \ \
-e PULSE_TOKEN="<your-api-token>" \ \
-v /var/run/docker.sock:/var/run/docker.sock \ \
ghcr.io/rcourtman/pulse-docker-agent:latest`}
class="w-full sm:w-auto"
>
Copy install command
</CopyButton>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/DOCKER_MONITORING.md"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Read the Docker monitoring guide
</a>
</>
}
/> />
</Card> </Card>
</Show> </Show>

View file

@ -12,7 +12,6 @@ import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
export const DockerAgents: Component = () => { export const DockerAgents: Component = () => {
const { state } = useWebSocket(); const { state } = useWebSocket();
const [showInstructions, setShowInstructions] = createSignal(true);
let hasLoggedSecurityStatusError = false; let hasLoggedSecurityStatusError = false;
@ -367,22 +366,6 @@ WantedBy=multi-user.target`;
return ( return (
<div class="space-y-8"> <div class="space-y-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Setup & Management</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">Deploy agents or manage existing Docker hosts</p>
</div>
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{showInstructions() ? 'Hide' : 'Show'} deployment instructions
</button>
</div>
{/* Deployment Instructions */}
<Show when={showInstructions()}>
<Card class="space-y-5"> <Card class="space-y-5">
<div> <div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a Docker host</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a Docker host</h3>
@ -391,26 +374,6 @@ WantedBy=multi-user.target`;
</p> </p>
</div> </div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
<summary class="flex cursor-pointer items-center justify-between font-semibold text-gray-800 dark:text-gray-100">
<span>What exactly gets installed?</span>
<svg class="h-4 w-4 text-gray-500 transition-transform group-open:-rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="mt-3 space-y-2">
<ul class="list-disc space-y-1 pl-5 leading-snug">
<li>A single self-contained Go binary (<code class="font-mono text-[11px]">pulse-docker-agent</code>, ~7&nbsp;MB)</li>
<li>A systemd unit on Linux or Unraid startup script so the agent restarts after reboots</li>
<li>No extra dependencies: it talks directly to <code class="font-mono text-[11px]">/var/run/docker.sock</code> and sends metrics over HTTPS</li>
<li>Every report includes a control handshake so Pulse can issue constrained commands (e.g. stop) without running arbitrary shell</li>
</ul>
<p class="text-xs text-gray-500 dark:text-gray-400">
Removing a host tears down the service and autostart hook automatically; keeping the binary for quick reinstalls is optional and called out in the dialog.
</p>
</div>
</details>
<Show when={requiresToken()}> <Show when={requiresToken()}>
<div class="space-y-3"> <div class="space-y-3">
<div class="space-y-1"> <div class="space-y-1">
@ -573,7 +536,6 @@ WantedBy=multi-user.target`;
</div> </div>
</details> </details>
</Card> </Card>
</Show>
{/* Remove Docker Host Modal */} {/* Remove Docker Host Modal */}
<Show when={showRemoveModal()}> <Show when={showRemoveModal()}>

View file

@ -1,55 +1,21 @@
import { type Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'; import { type Component, For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js';
import type { JSX } from 'solid-js'; import type { JSX } from 'solid-js';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import type { Host } from '@/types/api'; import type { Host } from '@/types/api';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import CopyButton from '@/components/shared/CopyButton';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications'; import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes';
import type { SecurityStatus } from '@/types/config'; import type { SecurityStatus } from '@/types/config';
import type { APITokenRecord } from '@/api/security'; import type { APITokenRecord } from '@/api/security';
import { useScopedTokenManager } from '@/hooks/useScopedTokenManager'; import { SecurityAPI } from '@/api/security';
import SquareTerminal from 'lucide-solid/icons/square-terminal';
type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows'; type HostAgentVariant = 'linux' | 'macos' | 'windows';
interface HostAgentsProps { interface HostAgentsProps {
variant?: HostAgentVariant; variant?: HostAgentVariant;
} }
type HostPlatform = 'linux' | 'macos' | 'windows';
const hostPlatformOptions: { id: HostPlatform; label: string; description: string; icon: typeof SquareTerminal | JSX.Element }[] = [
{
id: 'linux',
label: 'Linux',
description: 'Download the static binary and enable the systemd service on Debian, Ubuntu, RHEL, Arch, and more.',
icon: SquareTerminal,
},
{
id: 'macos',
label: 'macOS',
description: 'Use the universal binary with launchd to keep desktops and hosts reporting in the background.',
icon: (
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.94 5.19A4.38 4.38 0 0 0 16 2a4.44 4.44 0 0 0-3 1.52 4.17 4.17 0 0 0-1 3.09 3.69 3.69 0 0 0 2.94-1.42zm2.52 7.44a4.51 4.51 0 0 1 2.16-3.81 4.66 4.66 0 0 0-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87A4.92 4.92 0 0 0 4.69 9.39C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 0 0 1.38-2.85 4.41 4.41 0 0 1-2.68-4.08z" />
</svg>
),
},
{
id: 'windows',
label: 'Windows',
description: 'Native Windows service with automatic startup. PowerShell script handles binary download and service installation.',
icon: (
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg>
),
},
];
const TOKEN_PLACEHOLDER = '<api-token>'; const TOKEN_PLACEHOLDER = '<api-token>';
const pulseUrl = () => { const pulseUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:7655'; if (typeof window === 'undefined') return 'http://localhost:7655';
@ -57,23 +23,14 @@ const pulseUrl = () => {
return `${protocol}//${hostname}${port ? `:${port}` : ''}`; return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}; };
const buildDefaultTokenName = () => {
const now = new Date();
const iso = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM
const stamp = iso.replace('T', ' ').replace(/:/g, '-');
return `Host agent ${stamp}`;
};
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = { const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
all: {
title: 'Installation',
description:
'Run the installer script to automatically download and configure the host agent on any supported platform.',
snippets: [
{
label: 'Install host agent',
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
note: (
<span>
The script downloads the agent binary from Pulse and sets up systemd (Linux) or launchd (macOS) for automatic startup.
</span>
),
},
],
},
linux: { linux: {
title: 'Install on Linux', title: 'Install on Linux',
description: description:
@ -133,49 +90,51 @@ const commandsByVariant: Record<HostAgentVariant, { title: string; description:
}, },
}; };
const platformFilters: Record<HostAgentVariant, string[] | null> = { const platformFilters: Record<HostAgentVariant, string[]> = {
all: null,
linux: ['linux'], linux: ['linux'],
macos: ['macos'], macos: ['macos'],
windows: ['windows'], windows: ['windows'],
}; };
export const HostAgents: Component<HostAgentsProps> = (props) => { export const HostAgents: Component<HostAgentsProps> = (props) => {
const variant: HostAgentVariant = props.variant ?? 'all'; const variant = () => props.variant ?? 'linux';
const { state } = useWebSocket(); const { state } = useWebSocket();
let hasLoggedSecurityStatusError = false; let hasLoggedSecurityStatusError = false;
const [showInstructions, setShowInstructions] = createSignal(true);
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null); const [securityStatus, setSecurityStatus] = createSignal<SecurityStatus | null>(null);
const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false);
const [newTokenName, setNewTokenName] = createSignal('');
const [generateError, setGenerateError] = createSignal<string | null>(null);
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null); const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
const [stepTwoComplete, setStepTwoComplete] = createSignal(false); const [tokenName, setTokenName] = createSignal('');
const [confirmedNoToken, setConfirmedNoToken] = createSignal(false);
const { const [currentToken, setCurrentToken] = createSignal<string | null>(null);
token: apiToken, const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
setToken: setApiToken,
isGeneratingToken,
generateToken,
} = useScopedTokenManager({
scope: HOST_AGENT_SCOPE,
storageKey: 'hostAgentToken',
legacyKeys: ['apiToken'],
});
createEffect(() => { createEffect(() => {
if (!apiToken()) { if (requiresToken()) {
setStepTwoComplete(false); setConfirmedNoToken(false);
} else {
setCurrentToken(null);
setLatestRecord(null);
} }
}); });
const hosts = createMemo(() => {
createEffect(
on(
variant,
() => {
setLatestRecord(null);
setCurrentToken(null);
setConfirmedNoToken(false);
setTokenName('');
},
{ defer: true },
),
);
const allHosts = createMemo(() => {
const list = state.hosts ?? []; const list = state.hosts ?? [];
const filters = platformFilters[variant]; return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list;
return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
}); });
const renderTags = (host: Host) => { const renderTags = (host: Host) => {
@ -184,15 +143,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return tags.join(', '); return tags.join(', ');
}; };
const [selectedPlatform, setSelectedPlatform] = createSignal<HostPlatform>('linux'); const installMeta = createMemo(() => commandsByVariant[variant()]);
const effectiveVariant = createMemo<HostAgentVariant>(() =>
variant === 'all' ? selectedPlatform() : variant,
);
const installMeta = createMemo(() => commandsByVariant[effectiveVariant()]);
const tokenStepLabel = () => `${variant === 'all' ? 'Step 2' : 'Step 1'} · Choose an API token`;
const commandStepLabel = () => `${variant === 'all' ? 'Step 3' : 'Step 2'} · Installation commands`;
onMount(() => { onMount(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@ -225,52 +176,38 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
return true; return true;
}; };
const tokenReady = () => !requiresToken() || Boolean(apiToken()); const hasToken = () => Boolean(currentToken());
const commandsUnlocked = () => tokenReady() && stepTwoComplete(); const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
const acknowledgeTokenUse = () => { const acknowledgeNoToken = () => {
if (!requiresToken()) { if (requiresToken()) {
setStepTwoComplete(true); notificationStore.info('Generate or select a token before continuing.', 4000);
return; return;
} }
if (apiToken()) { setCurrentToken(null);
setStepTwoComplete(true); setLatestRecord(null);
notificationStore.success('Token ready to embed in the install commands.', 3500); setConfirmedNoToken(true);
} else { notificationStore.success('Confirmed install commands without an API token.', 3500);
notificationStore.info('Generate or select a token before continuing.', 4000);
}
}; };
const openGenerateTokenModal = () => { const handleGenerateToken = async () => {
setGenerateError(null);
const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`;
setNewTokenName(defaultName);
setShowGenerateTokenModal(true);
setStepTwoComplete(false);
};
const handleCreateToken = async () => {
if (isGeneratingToken()) return; if (isGeneratingToken()) return;
setGenerateError(null); setIsGeneratingToken(true);
try { try {
const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`; const desiredName = tokenName().trim() || buildDefaultTokenName();
const { token, record } = await generateToken(desiredName); const { token, record } = await SecurityAPI.createToken(desiredName, [HOST_AGENT_SCOPE]);
setShowGenerateTokenModal(false); setCurrentToken(token);
setNewTokenName('');
setLatestRecord(record); setLatestRecord(record);
showTokenReveal({ setTokenName('');
token, setConfirmedNoToken(false);
record, notificationStore.success('Token generated and inserted into the command below.', 4000);
source: 'host-agent',
note: `Copy this token into the host agent install command. Scope: ${HOST_AGENT_SCOPE}.`,
});
notificationStore.success('Created host agent API token with reporting scope.', 6000);
} catch (err) { } catch (err) {
console.error('Failed to generate host agent token', err); console.error('Failed to generate host agent token', err);
setGenerateError('Failed to generate host agent token. Confirm you are signed in as an administrator.'); notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000);
notificationStore.error('Failed to generate API token', 6000); } finally {
setIsGeneratingToken(false);
} }
}; };
@ -303,152 +240,67 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
}; };
const resolvedToken = () => { const resolvedToken = () => {
if (!requiresToken()) { if (requiresToken()) {
return 'disabled'; return currentToken() || TOKEN_PLACEHOLDER;
} }
return apiToken() || TOKEN_PLACEHOLDER; return currentToken() || 'disabled';
}; };
return ( return (
<div class="space-y-6"> <div class="space-y-6">
<Card padding="lg" class="space-y-5"> <Card padding="lg" class="space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="space-y-1">
<div> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a host agent</h3>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{installMeta().title}</h3> <p class="text-sm text-gray-600 dark:text-gray-400">Run this command on your host to start monitoring.</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{installMeta().description}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{installMeta().description}</p>
</div>
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{showInstructions() ? 'Hide' : 'Show'} instructions
</button>
</div> </div>
<Show when={showInstructions()}> <div class="space-y-5">
<div class="space-y-5"> <Show when={requiresToken()}>
<Show when={variant === 'all'}> <div class="space-y-3">
<div class="space-y-4"> <div class="space-y-1">
<div class="space-y-1"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Step 1 · Choose the operating system</p> <p class="text-sm text-gray-600 dark:text-gray-400">
<p class="text-sm text-gray-600 dark:text-gray-400"> Create a fresh token scoped to <code>{HOST_AGENT_SCOPE}</code>.
Pick the platform you are onboarding. The install commands adapt automatically. </p>
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={hostPlatformOptions}>
{(option) => {
const isActive = () => selectedPlatform() === option.id;
const Icon = option.icon;
return (
<button
type="button"
class={`flex flex-col items-start gap-3 rounded-lg border transition-colors p-4 text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
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={() => {
setSelectedPlatform(option.id);
setGenerateError(null);
setLatestRecord(null);
setApiToken(null);
setStepTwoComplete(false);
}}
>
<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'
}`}
>
{typeof Icon === 'function' ? (
<Icon size={20} stroke-width={2} />
) : (
Icon
)}
</div>
<div class="flex-1">
<p class="font-semibold text-gray-900 dark:text-gray-100">{option.label}</p>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">{option.description}</p>
</button>
);
}}
</For>
</div>
</div> </div>
</Show>
<Show when={requiresToken()}>
<div class="space-y-4">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{tokenStepLabel()}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate a scoped token for this host. Tokens minted here grant the <code>{HOST_AGENT_SCOPE}</code> permission only.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Need additional scopes? Visit <a href="/settings/security" class="text-blue-600 dark:text-blue-300 underline hover:no-underline font-medium">Security API tokens</a> to create a bespoke credential.
</p>
</div>
<Show when={generateError()}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-xs text-red-800 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200">
{generateError()}
</div>
</Show>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securelythis is the only time it is shown.
</span>
</div>
</Show>
<div class="flex gap-2">
<input
type="text"
value={tokenName()}
onInput={(event) => setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isGeneratingToken()) {
handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
/>
<button <button
type="button" type="button"
onClick={openGenerateTokenModal} onClick={handleGenerateToken}
disabled={isGeneratingToken()} disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60" class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isGeneratingToken() ? 'Generating…' : 'Generate token'} {isGeneratingToken() ? 'Generating…' : hasToken() ? 'Generate another' : 'Generate token'}
</button> </button>
<Show when={apiToken()}>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class={`flex-1 rounded-lg border px-4 py-2 text-xs ${
stepTwoComplete()
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'
}`}>
{stepTwoComplete()
? 'Token inserted. Proceed to the install commands below.'
: 'Stored token detected. Press confirm to insert it into each command.'}
</div>
<button
type="button"
onClick={acknowledgeTokenUse}
disabled={stepTwoComplete()}
class={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete()
? 'bg-green-600 text-white cursor-default'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
}`}
>
{stepTwoComplete() ? 'Token inserted' : 'Insert token into commands'}
</button>
</div>
</Show>
</div> </div>
</Show>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created ({latestRecord()?.prefix}{latestRecord()?.suffix}). Commands below now include this credential.
</span>
</div>
</Show>
</div>
</Show>
<Show when={!requiresToken()}> <Show when={!requiresToken()}>
<div class="space-y-3"> <div class="space-y-3">
@ -457,147 +309,84 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</div> </div>
<button <button
type="button" type="button"
onClick={acknowledgeTokenUse} onClick={acknowledgeNoToken}
disabled={stepTwoComplete()} disabled={confirmedNoToken()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${ class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
stepTwoComplete() confirmedNoToken()
? 'bg-green-600 text-white cursor-default' ? 'bg-green-600 text-white cursor-default'
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white' : 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`} }`}
> >
{stepTwoComplete() ? 'No token confirmed' : 'Confirm without token'} {confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button> </button>
</div> </div>
</Show> </Show>
<Show when={commandsUnlocked()}> <Show when={commandsUnlocked()}>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Install command</h4>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{commandStepLabel()}</h4>
<button
type="button"
onClick={async () => {
const firstSnippet = installMeta().snippets[0];
if (!firstSnippet) return;
const command = firstSnippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
const success = await copyToClipboard(command);
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy first command
</button>
</div>
<div class="space-y-3"> <div class="space-y-3">
<For each={installMeta().snippets}> <For each={installMeta().snippets}>
{(snippet) => ( {(snippet) => {
<div class="space-y-2"> const copyCommand = () =>
<div class="flex items-center justify-between gap-3"> snippet.command.replace(
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5> TOKEN_PLACEHOLDER,
<CopyButton resolvedToken(),
text={snippet.command.replace( );
TOKEN_PLACEHOLDER,
resolvedToken(), return (
)} <div class="space-y-2">
> <div class="flex items-center justify-between gap-3">
Copy command <h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5>
</CopyButton> <button
type="button"
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>{copyCommand()}</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div> </div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100"> );
<code> }}
{snippet.command.replace(
TOKEN_PLACEHOLDER,
resolvedToken(),
)}
</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
)}
</For> </For>
</div> </div>
</div> </div>
</Show> </Show>
<Show when={requiresToken() && (!apiToken() || !stepTwoComplete())}> <Show when={requiresToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Generate a new token or confirm the stored one to unlock the install commands. Generate a new token to unlock the install commands.
</p> </p>
</Show> </Show>
<Show when={!requiresToken() && !stepTwoComplete()}> <Show when={!requiresToken() && !confirmedNoToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p> <p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show> </Show>
</div>
</Show>
</Card>
<Show when={showGenerateTokenModal()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<div class="space-y-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Generate a new host agent token</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pulse will create a scoped token for this host and automatically insert it into the install commands. You can manage or revoke tokens anytime from Security settings.
</p>
</div>
<div class="mt-4 space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300" for="host-agent-new-token-name">
Token name
</label>
<input
id="host-agent-new-token-name"
type="text"
value={newTokenName()}
onInput={(event) => setNewTokenName(event.currentTarget.value)}
class="w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
placeholder="Host agent token"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Friendly names make it easier to audit tokens later (e.g. <code class="font-mono text-xs">host-lab-01</code>).
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowGenerateTokenModal(false);
setNewTokenName('');
setGenerateError(null);
}}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={handleCreateToken}
disabled={isGeneratingToken()}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
</div>
</div>
</div> </div>
</Show> </Card>
<Card padding="lg" class="space-y-5"> <Card padding="lg" class="space-y-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{hosts().length} connected</span> <span class="text-sm text-gray-500 dark:text-gray-400">{allHosts().length} connected</span>
</div> </div>
<Show <Show
when={hosts().length > 0} when={allHosts().length > 0}
fallback={ fallback={
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
{variant === 'windows' No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here.
? 'No Windows hosts have reported yet. Run the PowerShell installation script above as Administrator to deploy the agent.'
: 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'}
</p> </p>
} }
> >
@ -615,7 +404,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800"> <tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<For each={hosts()}> <For each={allHosts()}>
{(host) => { {(host) => {
const [isDeleting, setIsDeleting] = createSignal(false); const [isDeleting, setIsDeleting] = createSignal(false);

View file

@ -0,0 +1,77 @@
import type { Component } from 'solid-js';
import { For, type JSX } from 'solid-js';
import SquareTerminal from 'lucide-solid/icons/square-terminal';
type HostsSection = 'linux' | 'macos' | 'windows';
interface HostsSectionNavProps {
current: HostsSection;
onSelect: (section: HostsSection) => void;
class?: string;
}
const AppleIcon = () => (
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.94 5.19A4.38 4.38 0 0 0 16 2a4.44 4.44 0 0 0-3 1.52 4.17 4.17 0 0 0-1 3.09 3.69 3.69 0 0 0 2.94-1.42zm2.52 7.44a4.51 4.51 0 0 1 2.16-3.81 4.66 4.66 0 0 0-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87A4.92 4.92 0 0 0 4.69 9.39C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 0 0 1.38-2.85 4.41 4.41 0 0 1-2.68-4.08z" />
</svg>
);
const WindowsIcon = () => (
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg>
);
const allSections: Array<{
id: HostsSection;
label: string;
icon: Component<{ size?: number; 'stroke-width'?: number }>;
}> = [
{
id: 'linux',
label: 'Linux',
icon: SquareTerminal,
},
{
id: 'macos',
label: 'macOS',
icon: AppleIcon,
},
{
id: 'windows',
label: 'Windows',
icon: WindowsIcon,
},
];
export const HostsSectionNav: Component<HostsSectionNavProps> = (props) => {
const baseClasses =
'inline-flex items-center gap-2 px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900';
return (
<div class={`flex flex-wrap items-center gap-3 sm:gap-4 ${props.class ?? ''}`} aria-label="Host platform sections">
<For each={allSections}>
{(section) => {
const isActive = () => section.id === props.current;
const classes = () => isActive()
? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400`
: `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`;
const Icon = section.icon;
return (
<button
type="button"
class={classes()}
onClick={() => props.onSelect(section.id)}
aria-pressed={isActive()}
>
<Icon size={16} stroke-width={2} />
<span>{section.label}</span>
</button>
);
}}
</For>
</div>
);
};

View file

@ -14,6 +14,7 @@ import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary'; import { SecurityPostureSummary } from './SecurityPostureSummary';
import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables'; import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables';
import { SettingsSectionNav } from './SettingsSectionNav'; import { SettingsSectionNav } from './SettingsSectionNav';
import { HostsSectionNav } from './HostsSectionNav';
import { SettingsAPI } from '@/api/settings'; import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes'; import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates'; import { UpdatesAPI } from '@/api/updates';
@ -395,6 +396,7 @@ const Settings: Component<SettingsProps> = (props) => {
const activeTab = () => currentTab(); const activeTab = () => currentTab();
const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve'); const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve');
const [selectedHostPlatform, setSelectedHostPlatform] = createSignal<'linux' | 'macos' | 'windows'>('linux');
const agentPaths: Record<AgentKey, string> = { const agentPaths: Record<AgentKey, string> = {
pve: '/settings/pve', pve: '/settings/pve',
@ -417,6 +419,10 @@ const Settings: Component<SettingsProps> = (props) => {
} }
}; };
const handleSelectHostPlatform = (platform: 'linux' | 'macos' | 'windows') => {
setSelectedHostPlatform(platform);
};
const setActiveTab = (tab: SettingsTab) => { const setActiveTab = (tab: SettingsTab) => {
if (tab === 'proxmox' && selectedAgent() === 'podman') { if (tab === 'proxmox' && selectedAgent() === 'podman') {
setSelectedAgent('pve'); setSelectedAgent('pve');
@ -2936,7 +2942,12 @@ const Settings: Component<SettingsProps> = (props) => {
{/* Servers Platform Tab */} {/* Servers Platform Tab */}
<Show when={activeTab() === 'hosts'}> <Show when={activeTab() === 'hosts'}>
<HostAgents variant="all" /> <HostsSectionNav
current={selectedHostPlatform()}
onSelect={handleSelectHostPlatform}
class="mb-6"
/>
<HostAgents variant={selectedHostPlatform()} />
</Show> </Show>
{/* Podman Tab */} {/* Podman Tab */}

View file

@ -4,13 +4,47 @@ import type { JSX } from 'solid-js';
interface CopyButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { interface CopyButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
text: string; text: string;
children: JSX.Element; children: JSX.Element;
onCopied?: () => void;
} }
export function CopyButton(props: CopyButtonProps) { export function CopyButton(props: CopyButtonProps) {
const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick']); const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick', 'onCopied']);
const [copied, setCopied] = createSignal(false); const [copied, setCopied] = createSignal(false);
let resetTimeout: number | undefined; let resetTimeout: number | undefined;
const copyText = async (text: string): Promise<boolean> => {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.warn('Clipboard API copy failed, attempting fallback copy.', error);
}
}
if (typeof document === 'undefined') {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand('copy');
} catch (error) {
console.error('Fallback copy failed', error);
return false;
} finally {
document.body.removeChild(textarea);
}
};
const handleClick = async (event: MouseEvent) => { const handleClick = async (event: MouseEvent) => {
const handler = local.onClick as const handler = local.onClick as
| ((event: MouseEvent) => void) | ((event: MouseEvent) => void)
@ -26,13 +60,18 @@ export function CopyButton(props: CopyButtonProps) {
return; return;
} }
try { const success = await copyText(local.text);
await navigator.clipboard.writeText(local.text); if (success) {
setCopied(true); setCopied(true);
window.clearTimeout(resetTimeout); window.clearTimeout(resetTimeout);
resetTimeout = window.setTimeout(() => setCopied(false), 2000); resetTimeout = window.setTimeout(() => setCopied(false), 2000);
} catch (error) { if (typeof local.onCopied === 'function') {
console.error('Failed to copy to clipboard', error); try {
local.onCopied();
} catch (error) {
console.error('onCopied handler failed', error);
}
}
} }
}; };

View file

@ -404,9 +404,33 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true security delete-generic-password -s "pulse-host-agent" -a "$USER" 2>/dev/null || true
# Add token to Keychain # Add token to Keychain
if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then KEYCHAIN_SERVICE="pulse-host-agent"
log_success "Token stored securely in macOS Keychain" KEYCHAIN_ACCOUNT="$USER"
USE_KEYCHAIN=true
KEYCHAIN_APPS=(
"/usr/local/bin/pulse-host-agent"
"/usr/local/bin/pulse-host-agent-wrapper.sh"
"/usr/bin/security"
)
KEYCHAIN_ARGS=()
for app in "${KEYCHAIN_APPS[@]}"; do
KEYCHAIN_ARGS+=(-T "$app")
done
if security add-generic-password \
-s "$KEYCHAIN_SERVICE" \
-a "$KEYCHAIN_ACCOUNT" \
-w "$PULSE_TOKEN" \
-U \
"${KEYCHAIN_ARGS[@]}" 2>/dev/null; then
if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then
log_success "Token stored securely in macOS Keychain"
USE_KEYCHAIN=true
else
log_warn "Token saved but Keychain denied non-interactive read access"
log_info "Will fall back to embedding token in the launchd plist"
USE_KEYCHAIN=false
fi
else else
log_warn "Failed to store token in Keychain, will use plist instead" log_warn "Failed to store token in Keychain, will use plist instead"
log_info "You may need to grant Keychain access permissions" log_info "You may need to grant Keychain access permissions"
@ -419,14 +443,21 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
# Create wrapper script if using Keychain # Create wrapper script if using Keychain
if [[ "$USE_KEYCHAIN" == true ]]; then if [[ "$USE_KEYCHAIN" == true ]]; then
WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh" WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh"
TMP_WRAPPER=$(mktemp)
cat > "$WRAPPER_SCRIPT" <<'WRAPPER_EOF' cat > "$TMP_WRAPPER" <<'WRAPPER_EOF'
#!/bin/bash #!/bin/bash
# Pulse Host Agent Wrapper - Reads token from Keychain # Pulse Host Agent Wrapper - Reads token from Keychain
set -e set -u
LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log"
mkdir -p "$(dirname "$LOG_FILE")"
# Read token from Keychain # Read token from Keychain
PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -w 2>/dev/null || echo "") if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then
echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE"
PULSE_TOKEN=""
fi
# Export for agent to use # Export for agent to use
export PULSE_TOKEN export PULSE_TOKEN
@ -435,7 +466,22 @@ export PULSE_TOKEN
exec /usr/local/bin/pulse-host-agent "$@" exec /usr/local/bin/pulse-host-agent "$@"
WRAPPER_EOF WRAPPER_EOF
chmod +x "$WRAPPER_SCRIPT" if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then
if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then
rm -f "$TMP_WRAPPER"
log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo."
exit 1
fi
fi
if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then
log_error "Failed to set execute permissions on $WRAPPER_SCRIPT."
exit 1
fi
if command -v chown &>/dev/null; then
sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true
fi
log_success "Created Keychain wrapper script" log_success "Created Keychain wrapper script"
# Create plist using wrapper (token not in plist!) # Create plist using wrapper (token not in plist!)
@ -502,8 +548,23 @@ EOF
# Set restrictive permissions on plist # Set restrictive permissions on plist
chmod 600 "$LAUNCHD_PLIST" chmod 600 "$LAUNCHD_PLIST"
launchctl load "$LAUNCHD_PLIST" LAUNCH_TARGET="gui/$(id -u)"
log_success "Launchd service enabled and started"
# Attempt to unload any existing service instance
if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then
log_info "Replaced existing launchd service definition"
fi
if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then
launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true
log_success "Launchd service enabled and started"
else
log_error "Failed to load launchd service. Try running:"
echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST"
echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent"
exit 1
fi
else else
log_warn "Automatic service setup not available for this platform" log_warn "Automatic service setup not available for this platform"
log_info "To run the agent manually:" log_info "To run the agent manually:"