From 655fec2225b13ee1429afdb0b2f541b3c8d26e70 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 24 Oct 2025 14:59:50 +0000 Subject: [PATCH] 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. --- frontend-modern/public/install-host-agent.sh | 80 ++- frontend-modern/src/App.tsx | 12 - .../src/components/Docker/DockerHosts.tsx | 39 +- .../src/components/Settings/DockerAgents.tsx | 38 -- .../src/components/Settings/HostAgents.tsx | 505 +++++------------- .../components/Settings/HostsSectionNav.tsx | 77 +++ .../src/components/Settings/Settings.tsx | 13 +- .../src/components/shared/CopyButton.tsx | 49 +- scripts/install-host-agent.sh | 79 ++- 9 files changed, 424 insertions(+), 468 deletions(-) create mode 100644 frontend-modern/src/components/Settings/HostsSectionNav.tsx diff --git a/frontend-modern/public/install-host-agent.sh b/frontend-modern/public/install-host-agent.sh index 233c4ba..cb7029e 100755 --- a/frontend-modern/public/install-host-agent.sh +++ b/frontend-modern/public/install-host-agent.sh @@ -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 # Add token to Keychain - if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then - log_success "Token stored securely in macOS Keychain" - USE_KEYCHAIN=true + KEYCHAIN_SERVICE="pulse-host-agent" + KEYCHAIN_ACCOUNT="$USER" + + 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 log_warn "Failed to store token in Keychain, will use plist instead" 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 if [[ "$USE_KEYCHAIN" == true ]]; then 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 # 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 -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 PULSE_TOKEN @@ -435,7 +466,23 @@ export PULSE_TOKEN exec /usr/local/bin/pulse-host-agent "$@" 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" # Create plist using wrapper (token not in plist!) @@ -502,8 +549,23 @@ EOF # Set restrictive permissions on plist chmod 600 "$LAUNCHD_PLIST" - launchctl load "$LAUNCHD_PLIST" - log_success "Launchd service enabled and started" + LAUNCH_TARGET="gui/$(id -u)" + + # 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 log_warn "Automatic service setup not available for this platform" log_info "To run the agent manually:" diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index e44d17d..ab6fa1e 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -999,18 +999,6 @@ function AppLayout(props: { > {platform.icon} {platform.label} - - - ); }} diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 3f90cbd..89760f3 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -663,19 +663,9 @@ export const DockerHosts: Component = (props) => { {/* Left: Host List - Only show if more than 1 host */} 1}> -
-
-

Docker Hosts

-

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

-
- +
+

Docker Hosts

+

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

@@ -1054,29 +1044,6 @@ export const DockerHosts: Component = (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. } - actions={ - <> - - Copy install command - - - Read the Docker monitoring guide - - - } /> diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 614a25a..3be762c 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -12,7 +12,6 @@ import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); - const [showInstructions, setShowInstructions] = createSignal(true); let hasLoggedSecurityStatusError = false; @@ -367,22 +366,6 @@ WantedBy=multi-user.target`; return (
-
-
-

Setup & Management

-

Deploy agents or manage existing Docker hosts

-
- -
- - {/* Deployment Instructions */} -

Add a Docker host

@@ -391,26 +374,6 @@ WantedBy=multi-user.target`;

-
- - What exactly gets installed? - - - - -
-
    -
  • A single self-contained Go binary (pulse-docker-agent, ~7 MB)
  • -
  • A systemd unit on Linux or Unraid startup script so the agent restarts after reboots
  • -
  • No extra dependencies: it talks directly to /var/run/docker.sock and sends metrics over HTTPS
  • -
  • Every report includes a control handshake so Pulse can issue constrained commands (e.g. stop) without running arbitrary shell
  • -
-

- 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. -

-
-
-
@@ -573,7 +536,6 @@ WantedBy=multi-user.target`;
- {/* Remove Docker Host Modal */} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index ba2bd62..9e26105 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -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 { useWebSocket } from '@/App'; import type { Host } from '@/types/api'; import { Card } from '@/components/shared/Card'; -import CopyButton from '@/components/shared/CopyButton'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; -import { showTokenReveal } from '@/stores/tokenReveal'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import type { SecurityStatus } from '@/types/config'; import type { APITokenRecord } from '@/api/security'; -import { useScopedTokenManager } from '@/hooks/useScopedTokenManager'; -import SquareTerminal from 'lucide-solid/icons/square-terminal'; +import { SecurityAPI } from '@/api/security'; -type HostAgentVariant = 'all' | 'linux' | 'macos' | 'windows'; +type HostAgentVariant = 'linux' | 'macos' | 'windows'; interface HostAgentsProps { 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: ( - - - - ), - }, - { - id: 'windows', - label: 'Windows', - description: 'Native Windows service with automatic startup. PowerShell script handles binary download and service installation.', - icon: ( - - - - ), - }, -]; - const TOKEN_PLACEHOLDER = ''; const pulseUrl = () => { if (typeof window === 'undefined') return 'http://localhost:7655'; @@ -57,23 +23,14 @@ const pulseUrl = () => { 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 = { - 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: ( - - The script downloads the agent binary from Pulse and sets up systemd (Linux) or launchd (macOS) for automatic startup. - - ), - }, - ], - }, linux: { title: 'Install on Linux', description: @@ -133,49 +90,51 @@ const commandsByVariant: Record = { - all: null, +const platformFilters: Record = { linux: ['linux'], macos: ['macos'], windows: ['windows'], }; export const HostAgents: Component = (props) => { - const variant: HostAgentVariant = props.variant ?? 'all'; + const variant = () => props.variant ?? 'linux'; const { state } = useWebSocket(); let hasLoggedSecurityStatusError = false; - const [showInstructions, setShowInstructions] = createSignal(true); const [securityStatus, setSecurityStatus] = createSignal(null); - const [showGenerateTokenModal, setShowGenerateTokenModal] = createSignal(false); - const [newTokenName, setNewTokenName] = createSignal(''); - const [generateError, setGenerateError] = createSignal(null); const [latestRecord, setLatestRecord] = createSignal(null); - const [stepTwoComplete, setStepTwoComplete] = createSignal(false); - - const { - token: apiToken, - setToken: setApiToken, - isGeneratingToken, - generateToken, - } = useScopedTokenManager({ - scope: HOST_AGENT_SCOPE, - storageKey: 'hostAgentToken', - legacyKeys: ['apiToken'], - }); + const [tokenName, setTokenName] = createSignal(''); + const [confirmedNoToken, setConfirmedNoToken] = createSignal(false); + const [currentToken, setCurrentToken] = createSignal(null); + const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); createEffect(() => { - if (!apiToken()) { - setStepTwoComplete(false); + if (requiresToken()) { + 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 filters = platformFilters[variant]; - const filtered = filters ? list.filter((host) => filters.includes((host.platform ?? '').toLowerCase())) : list; - return [...filtered].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); + return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); }); const renderTags = (host: Host) => { @@ -184,15 +143,7 @@ export const HostAgents: Component = (props) => { return tags.join(', '); }; - const [selectedPlatform, setSelectedPlatform] = createSignal('linux'); - - const effectiveVariant = createMemo(() => - 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`; + const installMeta = createMemo(() => commandsByVariant[variant()]); onMount(() => { if (typeof window === 'undefined') { @@ -225,52 +176,38 @@ export const HostAgents: Component = (props) => { return true; }; - const tokenReady = () => !requiresToken() || Boolean(apiToken()); - const commandsUnlocked = () => tokenReady() && stepTwoComplete(); + const hasToken = () => Boolean(currentToken()); + const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); - const acknowledgeTokenUse = () => { - if (!requiresToken()) { - setStepTwoComplete(true); + const acknowledgeNoToken = () => { + if (requiresToken()) { + notificationStore.info('Generate or select a token before continuing.', 4000); return; } - if (apiToken()) { - setStepTwoComplete(true); - notificationStore.success('Token ready to embed in the install commands.', 3500); - } else { - notificationStore.info('Generate or select a token before continuing.', 4000); - } + setCurrentToken(null); + setLatestRecord(null); + setConfirmedNoToken(true); + notificationStore.success('Confirmed install commands without an API token.', 3500); }; - const openGenerateTokenModal = () => { - setGenerateError(null); - const defaultName = `Host agent ${new Date().toISOString().slice(0, 10)}`; - setNewTokenName(defaultName); - setShowGenerateTokenModal(true); - setStepTwoComplete(false); - }; - - const handleCreateToken = async () => { + const handleGenerateToken = async () => { if (isGeneratingToken()) return; - setGenerateError(null); + setIsGeneratingToken(true); try { - const desiredName = newTokenName().trim() || `Host agent ${new Date().toISOString().slice(0, 10)}`; - const { token, record } = await generateToken(desiredName); + const desiredName = tokenName().trim() || buildDefaultTokenName(); + const { token, record } = await SecurityAPI.createToken(desiredName, [HOST_AGENT_SCOPE]); - setShowGenerateTokenModal(false); - setNewTokenName(''); + setCurrentToken(token); setLatestRecord(record); - showTokenReveal({ - token, - record, - 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); + setTokenName(''); + setConfirmedNoToken(false); + notificationStore.success('Token generated and inserted into the command below.', 4000); } catch (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 API token', 6000); + notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000); + } finally { + setIsGeneratingToken(false); } }; @@ -303,152 +240,67 @@ export const HostAgents: Component = (props) => { }; const resolvedToken = () => { - if (!requiresToken()) { - return 'disabled'; + if (requiresToken()) { + return currentToken() || TOKEN_PLACEHOLDER; } - return apiToken() || TOKEN_PLACEHOLDER; + return currentToken() || 'disabled'; }; return (
-
-
-

{installMeta().title}

-

{installMeta().description}

-
- +
+

Add a host agent

+

Run this command on your host to start monitoring.

+

{installMeta().description}

- -
- -
-
-

Step 1 · Choose the operating system

-

- Pick the platform you are onboarding. The install commands adapt automatically. -

-
-
- - {(option) => { - const isActive = () => selectedPlatform() === option.id; - const Icon = option.icon; - return ( - - ); - }} - -
+
+ +
+
+

Generate API token

+

+ Create a fresh token scoped to {HOST_AGENT_SCOPE}. +

- - - -
-
-

{tokenStepLabel()}

-

- Generate a scoped token for this host. Tokens minted here grant the {HOST_AGENT_SCOPE} permission only. -

-

- Need additional scopes? Visit Security → API tokens to create a bespoke credential. -

-
- - -
- {generateError()} -
-
- - -
- - - - - Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Copy the full value from the pop-up and store it securely—this is the only time it is shown. - -
-
+
+ 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" + /> - - -
-
- {stepTwoComplete() - ? 'Token inserted. Proceed to the install commands below.' - : 'Stored token detected. Press confirm to insert it into each command.'} -
- -
-
- + + +
+ + + + + Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Commands below now include this credential. + +
+
+ +
+
@@ -457,147 +309,84 @@ export const HostAgents: Component = (props) => {
-
-

{commandStepLabel()}

- -
+

Install command

- {(snippet) => ( -
-
-
{snippet.label}
- - Copy command - + {(snippet) => { + const copyCommand = () => + snippet.command.replace( + TOKEN_PLACEHOLDER, + resolvedToken(), + ); + + return ( +
+
+
{snippet.label}
+ +
+
+                            {copyCommand()}
+                          
+ +

{snippet.note}

+
-
-                          
-                            {snippet.command.replace(
-                              TOKEN_PLACEHOLDER,
-                              resolvedToken(),
-                            )}
-                          
-                        
- -

{snippet.note}

-
-
- )} + ); + }}
- +

- Generate a new token or confirm the stored one to unlock the install commands. + Generate a new token to unlock the install commands.

- +

Confirm the no-token setup to continue.

-
-
- - - -
-
-
-

Generate a new host agent token

-

- 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. -

-
-
- - 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" - /> -

- Friendly names make it easier to audit tokens later (e.g. host-lab-01). -

-
-
- - -
-
-
+

Reporting hosts

- {hosts().length} connected + {allHosts().length} connected
0} + when={allHosts().length > 0} fallback={

- {variant === 'windows' - ? '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.'} + No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here.

} > @@ -615,7 +404,7 @@ export const HostAgents: Component = (props) => { - + {(host) => { const [isDeleting, setIsDeleting] = createSignal(false); diff --git a/frontend-modern/src/components/Settings/HostsSectionNav.tsx b/frontend-modern/src/components/Settings/HostsSectionNav.tsx new file mode 100644 index 0000000..b13a95a --- /dev/null +++ b/frontend-modern/src/components/Settings/HostsSectionNav.tsx @@ -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 = () => ( + + + +); + +const WindowsIcon = () => ( + + + +); + +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 = (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 ( +
+ + {(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 ( + + ); + }} + +
+ ); +}; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index ea76ecb..1b1a99f 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -14,6 +14,7 @@ import { QuickSecuritySetup } from './QuickSecuritySetup'; import { SecurityPostureSummary } from './SecurityPostureSummary'; import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables'; import { SettingsSectionNav } from './SettingsSectionNav'; +import { HostsSectionNav } from './HostsSectionNav'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; @@ -395,6 +396,7 @@ const Settings: Component = (props) => { const activeTab = () => currentTab(); const [selectedAgent, setSelectedAgent] = createSignal('pve'); + const [selectedHostPlatform, setSelectedHostPlatform] = createSignal<'linux' | 'macos' | 'windows'>('linux'); const agentPaths: Record = { pve: '/settings/pve', @@ -417,6 +419,10 @@ const Settings: Component = (props) => { } }; + const handleSelectHostPlatform = (platform: 'linux' | 'macos' | 'windows') => { + setSelectedHostPlatform(platform); + }; + const setActiveTab = (tab: SettingsTab) => { if (tab === 'proxmox' && selectedAgent() === 'podman') { setSelectedAgent('pve'); @@ -2936,7 +2942,12 @@ const Settings: Component = (props) => { {/* Servers Platform Tab */} - + + {/* Podman Tab */} diff --git a/frontend-modern/src/components/shared/CopyButton.tsx b/frontend-modern/src/components/shared/CopyButton.tsx index 86ad571..8272c75 100644 --- a/frontend-modern/src/components/shared/CopyButton.tsx +++ b/frontend-modern/src/components/shared/CopyButton.tsx @@ -4,13 +4,47 @@ import type { JSX } from 'solid-js'; interface CopyButtonProps extends JSX.ButtonHTMLAttributes { text: string; children: JSX.Element; + onCopied?: () => void; } 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); let resetTimeout: number | undefined; + const copyText = async (text: string): Promise => { + 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 handler = local.onClick as | ((event: MouseEvent) => void) @@ -26,13 +60,18 @@ export function CopyButton(props: CopyButtonProps) { return; } - try { - await navigator.clipboard.writeText(local.text); + const success = await copyText(local.text); + if (success) { setCopied(true); window.clearTimeout(resetTimeout); resetTimeout = window.setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy to clipboard', error); + if (typeof local.onCopied === 'function') { + try { + local.onCopied(); + } catch (error) { + console.error('onCopied handler failed', error); + } + } } }; diff --git a/scripts/install-host-agent.sh b/scripts/install-host-agent.sh index 233c4ba..248d428 100755 --- a/scripts/install-host-agent.sh +++ b/scripts/install-host-agent.sh @@ -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 # Add token to Keychain - if security add-generic-password -s "pulse-host-agent" -a "$USER" -w "$PULSE_TOKEN" -U 2>/dev/null; then - log_success "Token stored securely in macOS Keychain" - USE_KEYCHAIN=true + KEYCHAIN_SERVICE="pulse-host-agent" + KEYCHAIN_ACCOUNT="$USER" + + 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 log_warn "Failed to store token in Keychain, will use plist instead" 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 if [[ "$USE_KEYCHAIN" == true ]]; then 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 # 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 -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 PULSE_TOKEN @@ -435,7 +466,22 @@ export PULSE_TOKEN exec /usr/local/bin/pulse-host-agent "$@" 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" # Create plist using wrapper (token not in plist!) @@ -502,8 +548,23 @@ EOF # Set restrictive permissions on plist chmod 600 "$LAUNCHD_PLIST" - launchctl load "$LAUNCHD_PLIST" - log_success "Launchd service enabled and started" + LAUNCH_TARGET="gui/$(id -u)" + + # 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 log_warn "Automatic service setup not available for this platform" log_info "To run the agent manually:"