mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
feat: add token revocation tracking and install script improvements
This commit adds comprehensive token revocation tracking across the UI and enhances the agent installation script for better platform support. Key changes: - Added token revocation warnings in Docker hosts and host agents UI with amber-colored indicators - Implemented automatic token revocation detection when tokens are deleted - Enhanced install scripts with Unraid detection and manual start instructions for non-systemd platforms - Improved service management with restart instead of start for systemd - Added visual indicators for revoked tokens with contextual warnings - Updated table column widths in hosts overview for better layout
This commit is contained in:
parent
655fec2225
commit
a6bf2c852b
10 changed files with 730 additions and 242 deletions
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DockerHostsProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
||||
const isLoading = createMemo(() => {
|
||||
if (typeof initialDataReceived === 'function') {
|
||||
|
|
@ -673,6 +670,10 @@ export const DockerHosts: Component<DockerHostsProps> = (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<DockerHostsProps> = (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<DockerHostsProps> = (props) => {
|
|||
{host.displayName}
|
||||
</span>
|
||||
{renderDockerStatusBadge(host.status)}
|
||||
<Show when={tokenRevoked}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Token revoked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={hostAlerts().hasAlerts}>
|
||||
<span
|
||||
|
|
@ -762,6 +775,14 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
<span class="text-[10px] text-gray-500 dark:text-gray-400">{formatRelativeTime(host.lastSeen!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={tokenRevoked}>
|
||||
<div class="mt-1 flex items-center gap-1 text-[10px] font-semibold text-amber-700 dark:text-amber-300" title={tokenRevokedTitle}>
|
||||
<svg class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="truncate">Token revoked {tokenRevokedRelative} — data stale</span>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
|
|
@ -833,6 +854,14 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
<span class="text-xs text-gray-500 dark:text-gray-400">({group.host.hostname})</span>
|
||||
</Show>
|
||||
{renderDockerStatusBadge(group.host.status)}
|
||||
<Show when={typeof group.host.tokenRevokedAt === 'number'}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Token revoked
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{group.containers.length} {group.containers.length === 1 ? 'container' : 'containers'}
|
||||
</span>
|
||||
|
|
@ -844,6 +873,14 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
<Show when={group.host.agentVersion}>
|
||||
<span>Agent {group.host.agentVersion}</span>
|
||||
</Show>
|
||||
<Show when={typeof group.host.tokenRevokedAt === 'number'}>
|
||||
<span class="flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Revoked {formatRelativeTime(group.host.tokenRevokedAt!)}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -888,6 +925,14 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
<span class="text-sm text-gray-500 dark:text-gray-400">({host().hostname})</span>
|
||||
</Show>
|
||||
{renderDockerStatusBadge(host().status)}
|
||||
<Show when={typeof host().tokenRevokedAt === 'number'}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Token revoked
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedHostContainers().length} {selectedHostContainers().length === 1 ? 'container' : 'containers'}
|
||||
</span>
|
||||
|
|
@ -907,9 +952,29 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
<Show when={host().lastSeen}>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Updated {formatRelativeTime(host().lastSeen!)}</span>
|
||||
</Show>
|
||||
<Show when={typeof host().tokenRevokedAt === 'number'}>
|
||||
<span class="flex items-center gap-1 text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Revoked {formatRelativeTime(host().tokenRevokedAt!)}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={typeof host().tokenRevokedAt === 'number'}>
|
||||
<div class="mt-2 flex items-start gap-2 rounded-md bg-amber-100/70 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold">Agent token was revoked {formatRelativeTime(host().tokenRevokedAt!)}</div>
|
||||
<div class="text-[11px] font-medium">No new telemetry will arrive until the agent is reconfigured.</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Host Metrics */}
|
||||
<Show when={host().status?.toLowerCase() === 'online' || host().status?.toLowerCase() === 'running'}>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
|
|
|
|||
|
|
@ -179,19 +179,19 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[30%]">
|
||||
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]">
|
||||
Host
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
||||
Platform
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[20%]">
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||
CPU
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[20%]">
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||
Memory
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
|
||||
Uptime
|
||||
</th>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -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<APITokenManagerProps> = (props) => {
|
||||
const { state } = useWebSocket();
|
||||
const { state, markDockerHostsTokenRevoked, markHostsTokenRevoked } = useWebSocket();
|
||||
const dockerHosts = createMemo<DockerHost[]>(() => state.dockerHosts ?? []);
|
||||
const hosts = createMemo<Host[]>(() => state.hosts ?? []);
|
||||
const dockerTokenUsage = createMemo(() => {
|
||||
const usage = new Map<string, { count: number; hosts: string[] }>();
|
||||
type UsageHost = { id: string; label: string };
|
||||
const usage = new Map<string, { count: number; hosts: UsageHost[] }>();
|
||||
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<string, { count: number; hosts: UsageHost[] }>();
|
||||
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<APITokenRecord[]>([]);
|
||||
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<APITokenManagerProps> = (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<APITokenManagerProps> = (props) => {
|
|||
void loadTokens();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!tokensLoaded()) return;
|
||||
const activeTokenIds = new Set(tokens().map((token) => token.id));
|
||||
const pendingDockerByToken = new Map<string, string[]>();
|
||||
|
||||
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<string, string[]>();
|
||||
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<APITokenManagerProps> = (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<APITokenManagerProps> = (props) => {
|
|||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<For each={sortedTokens()}>
|
||||
{(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<APITokenManagerProps> = (props) => {
|
|||
</For>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600 dark:text-gray-400" title={usage?.hosts.join(', ')}>
|
||||
<td
|
||||
class="px-5 py-3 text-gray-600 dark:text-gray-400"
|
||||
title={usageTitleSegments.length > 0 ? usageTitleSegments.join('\n') : undefined}
|
||||
>
|
||||
{hostSummary}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -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 = '<api-token>';
|
||||
const pulseUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:7655';
|
||||
|
|
@ -30,7 +24,16 @@ const buildDefaultTokenName = () => {
|
|||
return `Host agent ${stamp}`;
|
||||
};
|
||||
|
||||
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
|
||||
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<HostAgentVariant, { title: string; description:
|
|||
},
|
||||
};
|
||||
|
||||
const platformFilters: Record<HostAgentVariant, string[]> = {
|
||||
linux: ['linux'],
|
||||
macos: ['macos'],
|
||||
windows: ['windows'],
|
||||
};
|
||||
|
||||
export const HostAgents: Component<HostAgentsProps> = (props) => {
|
||||
const variant = () => props.variant ?? 'linux';
|
||||
export const HostAgents: Component = () => {
|
||||
const { state } = useWebSocket();
|
||||
|
||||
let hasLoggedSecurityStatusError = false;
|
||||
|
|
@ -119,19 +115,6 @@ export const HostAgents: Component<HostAgentsProps> = (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<HostAgentsProps> = (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<HostAgentsProps> = (props) => {
|
|||
<Card padding="lg" class="space-y-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a host agent</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-xs text-gray-500 dark:text-gray-400">{installMeta().description}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generate a token once, then run the matching command on Linux, macOS, or Windows to register new hosts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
|
|
@ -324,42 +313,56 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
|
|||
|
||||
<Show when={commandsUnlocked()}>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Install command</h4>
|
||||
<div class="space-y-3">
|
||||
<For each={installMeta().snippets}>
|
||||
{(snippet) => {
|
||||
const copyCommand = () =>
|
||||
snippet.command.replace(
|
||||
TOKEN_PLACEHOLDER,
|
||||
resolvedToken(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-200">{snippet.label}</h5>
|
||||
<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>
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Copy the command for the platform you are deploying.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<For each={commandSections()}>
|
||||
{(section) => (
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="space-y-1">
|
||||
<h5 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{section.title}</h5>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{section.description}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
<div class="space-y-3">
|
||||
<For each={section.snippets}>
|
||||
{(snippet) => {
|
||||
const copyCommand = () =>
|
||||
snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
|
||||
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h6 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{snippet.label}
|
||||
</h6>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -376,121 +379,217 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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">{allHosts().length} connected</span>
|
||||
</div>
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<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">{allHosts().length} connected</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={allHosts().length > 0}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
No host agents are reporting yet. Deploy the agent using the commands above to see hosts listed here.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Hostname</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Platform</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Uptime</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Memory</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Last seen</th>
|
||||
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Tags</th>
|
||||
<th class="px-3 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<For each={allHosts()}>
|
||||
{(host) => {
|
||||
const [isDeleting, setIsDeleting] = createSignal(false);
|
||||
<Show
|
||||
when={allHosts().length > 0}
|
||||
fallback={
|
||||
<div class="text-center py-8">
|
||||
<div class="text-gray-400 dark:text-gray-500 mb-2">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
No host agents are reporting yet.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Deploy the agent using the commands above to see hosts listed here.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Host</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Platform</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Uptime</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Memory</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Last Seen</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Tags</th>
|
||||
<th class="py-3 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={allHosts()}>
|
||||
{(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 (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<td class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{host.displayName || host.hostname || host.id}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300 capitalize">
|
||||
{host.platform || '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.memory?.total
|
||||
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
|
||||
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{renderTags(host)}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting()}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Remove host from monitoring"
|
||||
>
|
||||
{isDeleting() ? (
|
||||
<>
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Removing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Remove</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
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 (
|
||||
<tr class={rowClass}>
|
||||
<td class="py-3 px-4">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{host.displayName || host.hostname || host.id}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{host.hostname}
|
||||
</div>
|
||||
<Show when={host.agentVersion}>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Agent {host.agentVersion}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isOnline
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{host.status || 'unknown'}
|
||||
</span>
|
||||
<Show when={isStale}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
No recent data
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tokenRevoked}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Token revoked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-700 dark:text-gray-300 capitalize">
|
||||
{host.platform || '—'}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-700 dark:text-gray-300">
|
||||
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-700 dark:text-gray-300">
|
||||
{host.memory?.total
|
||||
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-gray-900 dark:text-gray-100">
|
||||
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
|
||||
</div>
|
||||
<Show when={host.lastSeen}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatAbsoluteTime(host.lastSeen!)}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-700 dark:text-gray-300">
|
||||
{renderTags(host)}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting() || !isStale}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
isStale
|
||||
? 'Remove this stale host entry from the inventory'
|
||||
: 'Host is still reporting — stop the agent before removing'
|
||||
}
|
||||
>
|
||||
{isDeleting() ? (
|
||||
<>
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Removing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Remove</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<SettingsProps> = (props) => {
|
|||
const activeTab = () => currentTab();
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve');
|
||||
const [selectedHostPlatform, setSelectedHostPlatform] = createSignal<'linux' | 'macos' | 'windows'>('linux');
|
||||
|
||||
const agentPaths: Record<AgentKey, string> = {
|
||||
pve: '/settings/pve',
|
||||
|
|
@ -419,10 +417,6 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (props) => {
|
|||
|
||||
{/* Servers Platform Tab */}
|
||||
<Show when={activeTab() === 'hosts'}>
|
||||
<HostsSectionNav
|
||||
current={selectedHostPlatform()}
|
||||
onSelect={handleSelectHostPlatform}
|
||||
class="mb-6"
|
||||
/>
|
||||
<HostAgents variant={selectedHostPlatform()} />
|
||||
<HostAgents />
|
||||
</Show>
|
||||
|
||||
{/* Podman Tab */}
|
||||
|
|
|
|||
|
|
@ -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<string, { ack: boolean; previousAckTime?: string }>();
|
||||
|
||||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue