diff --git a/frontend-modern/public/install-host-agent.sh b/frontend-modern/public/install-host-agent.sh index cb7029ed0..3144bf7e8 100755 --- a/frontend-modern/public/install-host-agent.sh +++ b/frontend-modern/public/install-host-agent.sh @@ -109,6 +109,14 @@ MACOS_LOG_FILE="$MACOS_LOG_DIR/host-agent.log" LINUX_LOG_DIR="/var/log/pulse" LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" +SERVICE_MODE="manual" +MANUAL_START_CMD="" +MANUAL_START_WRAPPED="" +UNRAID=false +if [[ -f /etc/unraid-version ]]; then + UNRAID=true +fi + # Uninstall function if [[ "$UNINSTALL" == "true" ]]; then log_warn "The --uninstall flag is deprecated." @@ -386,8 +394,9 @@ EOF sudo systemctl daemon-reload sudo systemctl enable pulse-host-agent - sudo systemctl start pulse-host-agent - log_success "Systemd service enabled and started" + sudo systemctl restart pulse-host-agent + log_success "Systemd service enabled and restarted" + SERVICE_MODE="systemd" elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then log_info "Setting up launchd service..." @@ -482,7 +491,6 @@ WRAPPER_EOF 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!) @@ -566,10 +574,25 @@ EOF echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent" exit 1 fi + SERVICE_MODE="launchd" else log_warn "Automatic service setup not available for this platform" + sudo mkdir -p "$LINUX_LOG_DIR" log_info "To run the agent manually:" - log_info " $AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" + MANUAL_START_CMD="$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" + MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" + log_info " $MANUAL_START_CMD" + log_info "" + log_info "To keep the agent running persistently:" + log_info " $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + log_info "" + log_info "On Unraid, add the wrapped command to /boot/config/go so it starts on boot." + else + log_info "" + log_info "On systems without systemd, add the wrapped command to /etc/rc.local (or similar) to start on boot." + fi + SERVICE_MODE="manual" fi # Validate installation @@ -580,7 +603,7 @@ VALIDATION_SUCCESS=false SERVICE_RUNNING=false # Check if service is running -if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then +if [[ "$SERVICE_MODE" == "systemd" ]]; then SERVICE_STATUS=$(systemctl is-active pulse-host-agent 2>/dev/null || echo "inactive") if [[ "$SERVICE_STATUS" == "active" ]]; then SERVICE_RUNNING=true @@ -589,7 +612,7 @@ if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then log_warn "Service status: $SERVICE_STATUS" log_info "Check logs with: sudo journalctl -u pulse-host-agent -n 50" fi -elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then +elif [[ "$SERVICE_MODE" == "launchd" ]]; then if launchctl list | grep -q "com.pulse.host-agent"; then SERVICE_RUNNING=true log_success "Service is running successfully!" @@ -597,10 +620,12 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then log_warn "Service may not be running properly" log_info "Check logs with: tail -20 $MACOS_LOG_FILE" fi +else + log_info "Skipping automated service validation – start the agent manually using the commands above." fi # Try to verify with API endpoint that agent is reporting -if [[ "$SERVICE_RUNNING" == true ]]; then +if [[ "$SERVICE_MODE" != "manual" && "$SERVICE_RUNNING" == true ]]; then log_info "Verifying agent registration with Pulse server..." # Get hostname for verification @@ -628,42 +653,65 @@ if [[ "$SERVICE_RUNNING" == true ]]; then fi fi -if [[ "$VALIDATION_SUCCESS" == true ]]; then +if [[ "$SERVICE_MODE" == "manual" ]]; then + log_warn "Service validation requires starting the agent manually." + log_info "Run the following to launch the agent in the background:" + log_info " $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + log_info "Add the same line to /boot/config/go to auto-start on boot." + else + log_info "Add the same line to /etc/rc.local (or equivalent) to auto-start on boot." + fi +elif [[ "$VALIDATION_SUCCESS" == true ]]; then log_info "Check your Pulse dashboard at: $PULSE_URL" else log_error "Service validation failed" echo "" log_info "Troubleshooting:" echo "" - if [[ "$PLATFORM" == "linux" ]]; then + if [[ "$SERVICE_MODE" == "systemd" ]]; then echo " View logs: sudo journalctl -u pulse-host-agent -f" echo " Check status: sudo systemctl status pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" - elif [[ "$PLATFORM" == "darwin" ]]; then + elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " View logs: tail -f $MACOS_LOG_FILE" echo " Check status: launchctl list | grep pulse" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" + else + echo " Start agent: $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + echo " Persist: Add the wrapped command to /boot/config/go" + else + echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" + fi fi echo "" - echo " Manual run: $AGENT_PATH --url $PULSE_URL $(if [[ -n "$PULSE_TOKEN" ]]; then echo "--token ***"; fi) --interval $INTERVAL" + echo " Manual run: $MANUAL_START_CMD" echo "" fi print_footer log_info "Service Management Commands:" -if [[ "$PLATFORM" == "linux" ]]; then +if [[ "$SERVICE_MODE" == "systemd" ]]; then echo " Start: sudo systemctl start pulse-host-agent" echo " Stop: sudo systemctl stop pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" echo " Status: sudo systemctl status pulse-host-agent" echo " Logs: sudo journalctl -u pulse-host-agent -f" -elif [[ "$PLATFORM" == "darwin" ]]; then +elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " Start: launchctl load $LAUNCHD_PLIST" echo " Stop: launchctl unload $LAUNCHD_PLIST" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" echo " Status: launchctl list | grep pulse" echo " Logs: tail -f $MACOS_LOG_FILE" +else + echo " Start: $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + echo " Persist: Add the wrapped command to /boot/config/go so it starts on boot" + else + echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" + fi fi echo "" diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 89760f3be..2b5de33c7 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -1,12 +1,10 @@ import type { Component } from 'solid-js'; import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; import type { DockerHost, DockerContainer, Alert } from '@/types/api'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { Card } from '@/components/shared/Card'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { EmptyState } from '@/components/shared/EmptyState'; -import { CopyButton } from '@/components/shared/CopyButton'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { DockerFilter } from './DockerFilter'; import { getAlertStyles } from '@/utils/alerts'; @@ -389,7 +387,6 @@ const DockerContainerRow: Component<{ }; export const DockerHosts: Component = (props) => { - const navigate = useNavigate(); const { initialDataReceived, reconnecting, connected } = useWebSocket(); const isLoading = createMemo(() => { if (typeof initialDataReceived === 'function') { @@ -673,6 +670,10 @@ export const DockerHosts: Component = (props) => { const isSelected = () => selectedHostId() === host.id; const containerCount = (host.containers || []).length; const runningCount = (host.containers || []).filter(c => c.state?.toLowerCase() === 'running').length; + const tokenRevokedAt = host.tokenRevokedAt; + const tokenRevoked = typeof tokenRevokedAt === 'number'; + const tokenRevokedRelative = tokenRevokedAt ? formatRelativeTime(tokenRevokedAt) : ''; + const tokenRevokedTitle = tokenRevokedAt ? new Date(tokenRevokedAt).toLocaleString() : ''; // Check for alerts on this host's containers const hostAlerts = createMemo(() => { @@ -713,6 +714,10 @@ export const DockerHosts: Component = (props) => { base += ' hover:bg-blue-50 dark:hover:bg-blue-900/20'; } + if (tokenRevoked) { + base += ' opacity-60'; + } + return base; }; @@ -740,6 +745,14 @@ export const DockerHosts: Component = (props) => { {host.displayName} {renderDockerStatusBadge(host.status)} + + + + Token revoked + + = (props) => { {formatRelativeTime(host.lastSeen!)} + +
+ + Token revoked {tokenRevokedRelative} — data stale +
+
); }} @@ -833,6 +854,14 @@ export const DockerHosts: Component = (props) => { ({group.host.hostname}) {renderDockerStatusBadge(group.host.status)} + + + + Token revoked + + {group.containers.length} {group.containers.length === 1 ? 'container' : 'containers'} @@ -844,6 +873,14 @@ export const DockerHosts: Component = (props) => { Agent {group.host.agentVersion} + + + + Revoked {formatRelativeTime(group.host.tokenRevokedAt!)} + + @@ -888,6 +925,14 @@ export const DockerHosts: Component = (props) => { ({host().hostname}) {renderDockerStatusBadge(host().status)} + + + + Token revoked + + {selectedHostContainers().length} {selectedHostContainers().length === 1 ? 'container' : 'containers'} @@ -907,9 +952,29 @@ export const DockerHosts: Component = (props) => { Updated {formatRelativeTime(host().lastSeen!)} + + + + Revoked {formatRelativeTime(host().tokenRevokedAt!)} + + + +
+ +
+
Agent token was revoked {formatRelativeTime(host().tokenRevokedAt!)}
+
No new telemetry will arrive until the agent is reconfigured.
+
+
+
+ {/* Host Metrics */}
diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index c5ae8d166..180faafad 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -179,19 +179,19 @@ export const HostsOverview: Component = (props) => { - - - - - diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index eb134fb18..9b8e9d094 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -1,9 +1,9 @@ -import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { showError, showSuccess } from '@/utils/toast'; import { formatRelativeTime } from '@/utils/format'; import { useWebSocket } from '@/App'; -import type { DockerHost } from '@/types/api'; +import type { DockerHost, Host } from '@/types/api'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; @@ -29,28 +29,50 @@ const SCOPES_DOC_URL = const WILDCARD_SCOPE = '*'; export const APITokenManager: Component = (props) => { - const { state } = useWebSocket(); + const { state, markDockerHostsTokenRevoked, markHostsTokenRevoked } = useWebSocket(); const dockerHosts = createMemo(() => state.dockerHosts ?? []); + const hosts = createMemo(() => state.hosts ?? []); const dockerTokenUsage = createMemo(() => { - const usage = new Map(); + type UsageHost = { id: string; label: string }; + const usage = new Map(); for (const host of dockerHosts()) { const tokenId = host.tokenId; if (!tokenId) continue; - const displayName = host.displayName?.trim() || host.hostname || host.id; + const label = host.displayName?.trim() || host.hostname || host.id; const previous = usage.get(tokenId); if (previous) { usage.set(tokenId, { count: previous.count + 1, - hosts: [...previous.hosts, displayName], + hosts: [...previous.hosts, { id: host.id, label }], }); } else { - usage.set(tokenId, { count: 1, hosts: [displayName] }); + usage.set(tokenId, { count: 1, hosts: [{ id: host.id, label }] }); + } + } + return usage; + }); + const hostTokenUsage = createMemo(() => { + type UsageHost = { id: string; label: string }; + const usage = new Map(); + for (const host of hosts()) { + const tokenId = host.tokenId; + if (!tokenId) continue; + const label = host.displayName?.trim() || host.hostname || host.id; + const previous = usage.get(tokenId); + if (previous) { + usage.set(tokenId, { + count: previous.count + 1, + hosts: [...previous.hosts, { id: host.id, label }], + }); + } else { + usage.set(tokenId, { count: 1, hosts: [{ id: host.id, label }] }); } } return usage; }); const [tokens, setTokens] = createSignal([]); + const [tokensLoaded, setTokensLoaded] = createSignal(false); const sortedTokens = createMemo(() => [...tokens()].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), @@ -159,9 +181,11 @@ export const APITokenManager: Component = (props) => { const loadTokens = async () => { setLoading(true); + setTokensLoaded(false); try { const list = await SecurityAPI.listTokens(); setTokens(list); + setTokensLoaded(true); } catch (err) { console.error('Failed to load API tokens', err); showError('Failed to load API tokens'); @@ -174,6 +198,47 @@ export const APITokenManager: Component = (props) => { void loadTokens(); }); + createEffect(() => { + if (!tokensLoaded()) return; + const activeTokenIds = new Set(tokens().map((token) => token.id)); + const pendingDockerByToken = new Map(); + + for (const host of dockerHosts()) { + const tokenId = host.tokenId; + if (!tokenId) continue; + if (activeTokenIds.has(tokenId)) continue; + if (host.revokedTokenId === tokenId) continue; + + if (!pendingDockerByToken.has(tokenId)) { + pendingDockerByToken.set(tokenId, []); + } + pendingDockerByToken.get(tokenId)!.push(host.id); + } + + pendingDockerByToken.forEach((hostIds, tokenId) => { + if (hostIds.length === 0) return; + markDockerHostsTokenRevoked(tokenId, hostIds); + }); + + const pendingHostsByToken = new Map(); + for (const host of hosts()) { + const tokenId = host.tokenId; + if (!tokenId) continue; + if (activeTokenIds.has(tokenId)) continue; + if (host.revokedTokenId === tokenId && host.tokenRevokedAt) continue; + + if (!pendingHostsByToken.has(tokenId)) { + pendingHostsByToken.set(tokenId, []); + } + pendingHostsByToken.get(tokenId)!.push(host.id); + } + + pendingHostsByToken.forEach((hostIds, tokenId) => { + if (hostIds.length === 0) return; + markHostsTokenRevoked(tokenId, hostIds); + }); + }); + const handleGenerate = async () => { setIsGenerating(true); try { @@ -231,27 +296,53 @@ export const APITokenManager: Component = (props) => { }; const handleDelete = async (record: APITokenRecord) => { - const usage = dockerTokenUsage().get(record.id); + const dockerUsage = dockerTokenUsage().get(record.id); + const hostUsage = hostTokenUsage().get(record.id); const displayName = tokenNameForDialog(record); - let message = `Revoke token "${displayName}"? Any agents or integrations using it will stop working.`; - if (usage) { - const hostListPreview = usage.hosts.slice(0, 5).join(', '); - const extraCount = usage.hosts.length - 5; + const affectedDockerHostIds = dockerUsage ? dockerUsage.hosts.map((host) => host.id) : []; + const affectedHostAgentIds = hostUsage ? hostUsage.hosts.map((host) => host.id) : []; + let revokeMessage: string | undefined; + const messageChunks: string[] = []; + if (dockerUsage) { + const hostListPreview = dockerUsage.hosts + .slice(0, 5) + .map((host) => host.label) + .join(', '); + const extraCount = dockerUsage.hosts.length - 5; const hostSummary = extraCount > 0 ? `${hostListPreview}, +${extraCount} more` : hostListPreview; const hostCountLabel = - usage.count === 1 ? 'a Docker host' : `${usage.count} Docker hosts`; - message = `Token "${displayName}" is currently used by ${hostCountLabel}.\nHosts: ${hostSummary}\n\nRevoking it will cause those agents to stop reporting until you update them with a new token.\n\nContinue?`; + dockerUsage.count === 1 ? 'Docker host' : `${dockerUsage.count} Docker hosts`; + messageChunks.push(`${hostCountLabel}: ${hostSummary}`); + } + if (hostUsage) { + const agentListPreview = hostUsage.hosts + .slice(0, 5) + .map((host) => host.label) + .join(', '); + const agentExtra = hostUsage.hosts.length - 5; + const agentSummary = + agentExtra > 0 ? `${agentListPreview}, +${agentExtra} more` : agentListPreview; + const agentCountLabel = + hostUsage.count === 1 ? 'host agent' : `${hostUsage.count} host agents`; + messageChunks.push(`${agentCountLabel}: ${agentSummary}`); + } + if (messageChunks.length > 0) { + revokeMessage = `Token "${displayName}" was previously used by ${messageChunks.join(' • ')}. Update those agents with a new token.`; } - - if (!window.confirm(message)) return; try { await SecurityAPI.deleteToken(record.id); setTokens((prev) => prev.filter((token) => token.id !== record.id)); - showSuccess('Token revoked'); + showSuccess('Token revoked', revokeMessage); props.onTokensChanged?.(); + if (affectedDockerHostIds.length > 0) { + markDockerHostsTokenRevoked(record.id, affectedDockerHostIds); + } + if (affectedHostAgentIds.length > 0) { + markHostsTokenRevoked(record.id, affectedHostAgentIds); + } const current = newTokenRecord(); if (current && current.id === record.id) { @@ -467,9 +558,31 @@ export const APITokenManager: Component = (props) => { {(token) => { - const usage = dockerTokenUsage().get(token.id); - const hostSummary = - usage ? (usage.count === 1 ? usage.hosts[0] : `${usage.count} hosts`) : '—'; + const dockerUsageEntry = dockerTokenUsage().get(token.id); + const hostUsageEntry = hostTokenUsage().get(token.id); + const usageSegments: string[] = []; + const usageTitleSegments: string[] = []; + if (dockerUsageEntry) { + usageSegments.push( + dockerUsageEntry.count === 1 + ? dockerUsageEntry.hosts[0]?.label ?? 'Docker host' + : `${dockerUsageEntry.count} Docker hosts`, + ); + usageTitleSegments.push( + `Docker hosts: ${dockerUsageEntry.hosts.map((host) => host.label).join(', ')}`, + ); + } + if (hostUsageEntry) { + usageSegments.push( + hostUsageEntry.count === 1 + ? `${hostUsageEntry.hosts[0]?.label ?? 'Host agent'} (agent)` + : `${hostUsageEntry.count} host agents`, + ); + usageTitleSegments.push( + `Host agents: ${hostUsageEntry.hosts.map((host) => host.label).join(', ')}`, + ); + } + const hostSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—'; const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; const scopeBadges = rawScopes.includes('*') ? [{ value: '*', label: 'Full' }] @@ -514,7 +627,10 @@ export const APITokenManager: Component = (props) => { -
+ Host + Platform + CPU + Memory + Uptime
+ 0 ? usageTitleSegments.join('\n') : undefined} + > {hostSummary} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index 9e26105f3..53c7a2988 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -1,21 +1,15 @@ -import { type Component, For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'; +import { type Component, For, Show, createEffect, createMemo, createSignal, 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 { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; +import { formatBytes, formatRelativeTime, formatUptime, formatAbsoluteTime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import type { SecurityStatus } from '@/types/config'; import type { APITokenRecord } from '@/api/security'; import { SecurityAPI } from '@/api/security'; -type HostAgentVariant = 'linux' | 'macos' | 'windows'; - -interface HostAgentsProps { - variant?: HostAgentVariant; -} - const TOKEN_PLACEHOLDER = ''; const pulseUrl = () => { if (typeof window === 'undefined') return 'http://localhost:7655'; @@ -30,7 +24,16 @@ const buildDefaultTokenName = () => { return `Host agent ${stamp}`; }; -const commandsByVariant: Record = { +type HostAgentPlatform = 'linux' | 'macos' | 'windows'; + +const commandsByPlatform: Record< + HostAgentPlatform, + { + title: string; + description: string; + snippets: { label: string; command: string; note?: string | JSX.Element }[]; + } +> = { linux: { title: 'Install on Linux', description: @@ -90,14 +93,7 @@ const commandsByVariant: Record = { - linux: ['linux'], - macos: ['macos'], - windows: ['windows'], -}; - -export const HostAgents: Component = (props) => { - const variant = () => props.variant ?? 'linux'; +export const HostAgents: Component = () => { const { state } = useWebSocket(); let hasLoggedSecurityStatusError = false; @@ -119,19 +115,6 @@ export const HostAgents: Component = (props) => { }); - createEffect( - on( - variant, - () => { - setLatestRecord(null); - setCurrentToken(null); - setConfirmedNoToken(false); - setTokenName(''); - }, - { defer: true }, - ), - ); - const allHosts = createMemo(() => { const list = state.hosts ?? []; return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); @@ -143,7 +126,12 @@ export const HostAgents: Component = (props) => { return tags.join(', '); }; - const installMeta = createMemo(() => commandsByVariant[variant()]); + const commandSections = createMemo(() => + Object.entries(commandsByPlatform).map(([platform, meta]) => ({ + platform: platform as HostAgentPlatform, + ...meta, + })), + ); onMount(() => { if (typeof window === 'undefined') { @@ -251,8 +239,9 @@ export const HostAgents: Component = (props) => {

Add a host agent

-

Run this command on your host to start monitoring.

-

{installMeta().description}

+

+ Generate a token once, then run the matching command on Linux, macOS, or Windows to register new hosts. +

@@ -324,42 +313,56 @@ export const HostAgents: Component = (props) => {
-

Install command

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

{snippet.note}

-
+

Installation commands

+

+ Copy the command for the platform you are deploying. +

+
+ + {(section) => ( +
+
+
{section.title}
+

{section.description}

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

{snippet.note}

+
+
+ ); + }} +
+
+
+ )}
@@ -376,121 +379,217 @@ export const HostAgents: Component = (props) => {
- -
-

Reporting hosts

- {allHosts().length} connected -
+ +
+
+

Reporting hosts

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

- No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here. -

- } - > -
- - - - - - - - - - - - - - - {(host) => { - const [isDeleting, setIsDeleting] = createSignal(false); + 0} + fallback={ +
+
+ + + +
+

+ No host agents are reporting yet. +

+

+ Deploy the agent using the commands above to see hosts listed here. +

+
+ } + > +
+
HostnamePlatformUptimeMemoryLast seenTagsActions
+ + + + + + + + + + + + + + {(host) => { + const [isDeleting, setIsDeleting] = createSignal(false); + const tokenRevokedAt = host.tokenRevokedAt; + const tokenRevoked = typeof tokenRevokedAt === 'number'; + const tokenRevokedRelative = tokenRevokedAt ? formatRelativeTime(tokenRevokedAt) : ''; + const lastSeenMs = host.lastSeen ? new Date(host.lastSeen).getTime() : null; + const expectedIntervalMs = + (host.intervalSeconds && host.intervalSeconds > 0 ? host.intervalSeconds : 30) * 1000; + const staleThresholdMs = Math.max(expectedIntervalMs * 3, 60_000); + const isStale = + lastSeenMs === null || Date.now() - lastSeenMs >= staleThresholdMs; - const handleDelete = async () => { - if (!confirm(`Remove host "${host.displayName || host.hostname || host.id}"?\n\nThis will remove the host from Pulse monitoring. The host agent will re-register if it continues to report.`)) { - return; - } + const status = (host.status || 'unknown').toLowerCase(); + const isOnline = + status === 'online' || + status === 'running' || + status === 'healthy'; - setIsDeleting(true); - try { - const response = await fetch(`/api/agents/host/${host.id}`, { - method: 'DELETE', - credentials: 'include', - }); + const baseRowClass = isStale + ? 'bg-gray-50 dark:bg-gray-800/50 opacity-60' + : 'bg-white dark:bg-gray-900'; - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Failed to delete host'); + const rowClass = + tokenRevoked && !isStale ? `${baseRowClass} opacity-60` : baseRowClass; + + const handleDelete = async () => { + if (!confirm(`Remove host "${host.displayName || host.hostname || host.id}"?\n\nThis will remove the host from Pulse monitoring. The host agent will re-register if it continues to report.`)) { + return; } - notificationStore.success(`Host "${host.displayName || host.hostname}" removed`, 4000); - } catch (err) { - console.error('Failed to delete host:', err); - notificationStore.error( - err instanceof Error ? err.message : 'Failed to delete host. Please try again.', - 6000 - ); - } finally { - setIsDeleting(false); - } - }; + setIsDeleting(true); + try { + const response = await fetch(`/api/agents/host/${host.id}`, { + method: 'DELETE', + credentials: 'include', + }); - return ( - - - - - - - - - - ); - }} - + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to delete host'); + } + + notificationStore.success(`Host "${host.displayName || host.hostname}" removed`, 4000); + } catch (err) { + console.error('Failed to delete host:', err); + notificationStore.error( + err instanceof Error ? err.message : 'Failed to delete host. Please try again.', + 6000, + ); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + + + + + + + + ); + }} +
HostStatusPlatformUptimeMemoryLast SeenTags +
- {host.displayName || host.hostname || host.id} - - {host.platform || '—'} - - {host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'} - - {host.memory?.total - ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}` - : '—'} - - {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} - {renderTags(host)} - -
+
+ {host.displayName || host.hostname || host.id} +
+
+ {host.hostname} +
+ +
+ Agent {host.agentVersion} +
+
+
+
+ + {host.status || 'unknown'} + + + + + + + No recent data + + + + + + Token revoked + + +
+
+ {host.platform || '—'} + + {host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'} + + {host.memory?.total + ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}` + : '—'} + +
+ {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} +
+ +
+ {formatAbsoluteTime(host.lastSeen!)} +
+
+
+ {renderTags(host)} + + +
+
); diff --git a/frontend-modern/src/components/Settings/HostsSectionNav.tsx b/frontend-modern/src/components/Settings/HostsSectionNav.tsx index b13a95a17..d14bceb20 100644 --- a/frontend-modern/src/components/Settings/HostsSectionNav.tsx +++ b/frontend-modern/src/components/Settings/HostsSectionNav.tsx @@ -1,5 +1,5 @@ import type { Component } from 'solid-js'; -import { For, type JSX } from 'solid-js'; +import { For } from 'solid-js'; import SquareTerminal from 'lucide-solid/icons/square-terminal'; type HostsSection = 'linux' | 'macos' | 'windows'; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 1b1a99f55..47514c73c 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -14,7 +14,6 @@ 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'; @@ -396,7 +395,6 @@ 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', @@ -419,10 +417,6 @@ const Settings: Component = (props) => { } }; - const handleSelectHostPlatform = (platform: 'linux' | 'macos' | 'windows') => { - setSelectedHostPlatform(platform); - }; - const setActiveTab = (tab: SettingsTab) => { if (tab === 'proxmox' && selectedAgent() === 'podman') { setSelectedAgent('pve'); @@ -2942,12 +2936,7 @@ const Settings: Component = (props) => { {/* Servers Platform Tab */} - - + {/* Podman Tab */} diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index c9b00e495..129727fd7 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -8,6 +8,8 @@ import type { PVEBackups, VM, Container, + DockerHost, + Host, } from '@/types/api'; import type { ActivationState as ActivationStateType } from '@/types/alerts'; import { logger } from '@/utils/logger'; @@ -80,6 +82,78 @@ export function createWebSocketStore(url: string) { let consecutiveEmptyDockerUpdates = 0; let hasReceivedNonEmptyDockerHosts = false; + const mergeDockerHostRevocations = (incomingHosts: DockerHost[]) => { + if (!Array.isArray(incomingHosts) || incomingHosts.length === 0) { + return incomingHosts; + } + + const existingHosts = state.dockerHosts || []; + if (!Array.isArray(existingHosts) || existingHosts.length === 0) { + return incomingHosts; + } + + return incomingHosts.map((host) => { + const previous = existingHosts.find((entry) => entry.id === host.id); + if (!previous?.tokenRevokedAt || !previous.revokedTokenId) { + return host; + } + + const tokenChanged = + previous.revokedTokenId && + host.tokenId && + host.tokenId !== previous.revokedTokenId; + const tokenUsedAfterRevocation = + typeof host.tokenLastUsedAt === 'number' && + host.tokenLastUsedAt >= previous.tokenRevokedAt; + + if (tokenChanged || tokenUsedAfterRevocation) { + return host; + } + + return { + ...host, + revokedTokenId: previous.revokedTokenId, + tokenRevokedAt: previous.tokenRevokedAt, + }; + }); + }; + + const mergeHostRevocations = (incomingHosts: Host[]) => { + if (!Array.isArray(incomingHosts) || incomingHosts.length === 0) { + return incomingHosts; + } + + const existingHosts = state.hosts || []; + if (!Array.isArray(existingHosts) || existingHosts.length === 0) { + return incomingHosts; + } + + return incomingHosts.map((host) => { + const previous = existingHosts.find((entry) => entry.id === host.id); + if (!previous?.tokenRevokedAt || !previous.revokedTokenId) { + return host; + } + + const tokenChanged = + previous.revokedTokenId && + host.tokenId && + host.tokenId !== previous.revokedTokenId; + const tokenUsedAfterRevocation = + typeof host.tokenLastUsedAt === 'number' && + host.tokenLastUsedAt >= previous.tokenRevokedAt; + + if (tokenChanged || tokenUsedAfterRevocation) { + return host; + } + + return { + ...host, + revokedTokenId: previous.revokedTokenId, + tokenRevokedAt: previous.tokenRevokedAt, + }; + }); + }; + // Track alerts with pending acknowledgment changes to prevent race conditions const pendingAckChanges = new Map(); @@ -332,7 +406,7 @@ export function createWebSocketStore(url: string) { if (shouldApplyEmptyState) { console.log('[WebSocket] Updating dockerHosts:', incomingHosts.length, 'hosts'); - setState('dockerHosts', incomingHosts); + setState('dockerHosts', mergeDockerHostRevocations(incomingHosts)); } else { console.debug( '[WebSocket] Skipping transient empty dockerHosts payload', @@ -343,7 +417,7 @@ export function createWebSocketStore(url: string) { consecutiveEmptyDockerUpdates = 0; hasReceivedNonEmptyDockerHosts = true; console.log('[WebSocket] Updating dockerHosts:', incomingHosts.length, 'hosts'); - setState('dockerHosts', incomingHosts); + setState('dockerHosts', mergeDockerHostRevocations(incomingHosts)); } } else { console.warn('[WebSocket] Received non-array dockerHosts:', typeof message.data.dockerHosts); @@ -351,8 +425,14 @@ export function createWebSocketStore(url: string) { } else if (message.data.dockerHosts === null) { console.log('[WebSocket] Received null dockerHosts, ignoring'); } + if (message.data.hosts !== undefined && message.data.hosts !== null) { + if (Array.isArray(message.data.hosts)) { + setState('hosts', mergeHostRevocations(message.data.hosts)); + } else { + setState('hosts', message.data.hosts); + } + } if (message.data.storage !== undefined) setState('storage', message.data.storage); - if (message.data.hosts !== undefined) setState('hosts', message.data.hosts); if (message.data.cephClusters !== undefined) setState('cephClusters', message.data.cephClusters); if (message.data.pbs !== undefined) setState('pbs', message.data.pbs); @@ -562,6 +642,44 @@ export function createWebSocketStore(url: string) { reconnectAttempt = 0; // Reset attempts for manual reconnect connect(); }, + markDockerHostsTokenRevoked: (tokenId: string, hostIds: string[]) => { + if (!hostIds || hostIds.length === 0) { + return; + } + const timestamp = Date.now(); + setState( + 'dockerHosts', + produce((draft: DockerHost[]) => { + if (!Array.isArray(draft)) return; + hostIds.forEach((hostId) => { + const target = draft.find((host) => host.id === hostId); + if (target) { + target.revokedTokenId = tokenId; + target.tokenRevokedAt = timestamp; + } + }); + }), + ); + }, + markHostsTokenRevoked: (tokenId: string, hostIds: string[]) => { + if (!hostIds || hostIds.length === 0) { + return; + } + const timestamp = Date.now(); + setState( + 'hosts', + produce((draft: Host[]) => { + if (!Array.isArray(draft)) return; + hostIds.forEach((hostId) => { + const target = draft.find((host) => host.id === hostId); + if (target) { + target.revokedTokenId = tokenId; + target.tokenRevokedAt = timestamp; + } + }); + }), + ); + }, removeAlerts: (predicate: (alert: Alert) => boolean) => { const keysToRemove: string[] = []; Object.entries(activeAlerts).forEach(([alertId, alert]) => { diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 53b261250..7d78c1c83 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -139,6 +139,8 @@ export interface DockerHost { tokenName?: string; tokenHint?: string; tokenLastUsedAt?: number; + revokedTokenId?: string; + tokenRevokedAt?: number; hidden?: boolean; pendingUninstall?: boolean; command?: DockerHostCommand; @@ -219,6 +221,8 @@ export interface Host { tokenName?: string; tokenHint?: string; tokenLastUsedAt?: number; + revokedTokenId?: string; + tokenRevokedAt?: number; tags?: string[]; } diff --git a/scripts/install-host-agent.sh b/scripts/install-host-agent.sh index 248d42820..3144bf7e8 100755 --- a/scripts/install-host-agent.sh +++ b/scripts/install-host-agent.sh @@ -109,6 +109,14 @@ MACOS_LOG_FILE="$MACOS_LOG_DIR/host-agent.log" LINUX_LOG_DIR="/var/log/pulse" LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" +SERVICE_MODE="manual" +MANUAL_START_CMD="" +MANUAL_START_WRAPPED="" +UNRAID=false +if [[ -f /etc/unraid-version ]]; then + UNRAID=true +fi + # Uninstall function if [[ "$UNINSTALL" == "true" ]]; then log_warn "The --uninstall flag is deprecated." @@ -386,8 +394,9 @@ EOF sudo systemctl daemon-reload sudo systemctl enable pulse-host-agent - sudo systemctl start pulse-host-agent - log_success "Systemd service enabled and started" + sudo systemctl restart pulse-host-agent + log_success "Systemd service enabled and restarted" + SERVICE_MODE="systemd" elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then log_info "Setting up launchd service..." @@ -565,10 +574,25 @@ EOF echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent" exit 1 fi + SERVICE_MODE="launchd" else log_warn "Automatic service setup not available for this platform" + sudo mkdir -p "$LINUX_LOG_DIR" log_info "To run the agent manually:" - log_info " $AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" + MANUAL_START_CMD="$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" + MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" + log_info " $MANUAL_START_CMD" + log_info "" + log_info "To keep the agent running persistently:" + log_info " $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + log_info "" + log_info "On Unraid, add the wrapped command to /boot/config/go so it starts on boot." + else + log_info "" + log_info "On systems without systemd, add the wrapped command to /etc/rc.local (or similar) to start on boot." + fi + SERVICE_MODE="manual" fi # Validate installation @@ -579,7 +603,7 @@ VALIDATION_SUCCESS=false SERVICE_RUNNING=false # Check if service is running -if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then +if [[ "$SERVICE_MODE" == "systemd" ]]; then SERVICE_STATUS=$(systemctl is-active pulse-host-agent 2>/dev/null || echo "inactive") if [[ "$SERVICE_STATUS" == "active" ]]; then SERVICE_RUNNING=true @@ -588,7 +612,7 @@ if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then log_warn "Service status: $SERVICE_STATUS" log_info "Check logs with: sudo journalctl -u pulse-host-agent -n 50" fi -elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then +elif [[ "$SERVICE_MODE" == "launchd" ]]; then if launchctl list | grep -q "com.pulse.host-agent"; then SERVICE_RUNNING=true log_success "Service is running successfully!" @@ -596,10 +620,12 @@ elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then log_warn "Service may not be running properly" log_info "Check logs with: tail -20 $MACOS_LOG_FILE" fi +else + log_info "Skipping automated service validation – start the agent manually using the commands above." fi # Try to verify with API endpoint that agent is reporting -if [[ "$SERVICE_RUNNING" == true ]]; then +if [[ "$SERVICE_MODE" != "manual" && "$SERVICE_RUNNING" == true ]]; then log_info "Verifying agent registration with Pulse server..." # Get hostname for verification @@ -627,42 +653,65 @@ if [[ "$SERVICE_RUNNING" == true ]]; then fi fi -if [[ "$VALIDATION_SUCCESS" == true ]]; then +if [[ "$SERVICE_MODE" == "manual" ]]; then + log_warn "Service validation requires starting the agent manually." + log_info "Run the following to launch the agent in the background:" + log_info " $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + log_info "Add the same line to /boot/config/go to auto-start on boot." + else + log_info "Add the same line to /etc/rc.local (or equivalent) to auto-start on boot." + fi +elif [[ "$VALIDATION_SUCCESS" == true ]]; then log_info "Check your Pulse dashboard at: $PULSE_URL" else log_error "Service validation failed" echo "" log_info "Troubleshooting:" echo "" - if [[ "$PLATFORM" == "linux" ]]; then + if [[ "$SERVICE_MODE" == "systemd" ]]; then echo " View logs: sudo journalctl -u pulse-host-agent -f" echo " Check status: sudo systemctl status pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" - elif [[ "$PLATFORM" == "darwin" ]]; then + elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " View logs: tail -f $MACOS_LOG_FILE" echo " Check status: launchctl list | grep pulse" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" + else + echo " Start agent: $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + echo " Persist: Add the wrapped command to /boot/config/go" + else + echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" + fi fi echo "" - echo " Manual run: $AGENT_PATH --url $PULSE_URL $(if [[ -n "$PULSE_TOKEN" ]]; then echo "--token ***"; fi) --interval $INTERVAL" + echo " Manual run: $MANUAL_START_CMD" echo "" fi print_footer log_info "Service Management Commands:" -if [[ "$PLATFORM" == "linux" ]]; then +if [[ "$SERVICE_MODE" == "systemd" ]]; then echo " Start: sudo systemctl start pulse-host-agent" echo " Stop: sudo systemctl stop pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" echo " Status: sudo systemctl status pulse-host-agent" echo " Logs: sudo journalctl -u pulse-host-agent -f" -elif [[ "$PLATFORM" == "darwin" ]]; then +elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " Start: launchctl load $LAUNCHD_PLIST" echo " Stop: launchctl unload $LAUNCHD_PLIST" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" echo " Status: launchctl list | grep pulse" echo " Logs: tail -f $MACOS_LOG_FILE" +else + echo " Start: $MANUAL_START_WRAPPED" + if [[ "$UNRAID" == true ]]; then + echo " Persist: Add the wrapped command to /boot/config/go so it starts on boot" + else + echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" + fi fi echo ""