Add host agent multi-platform binary distribution and improve host details UI

- Build host agent binaries for all platforms (linux/darwin/windows, amd64/arm64/armv7) in Docker
- Add Makefile target for building agent binaries locally
- Add startup validation to check for missing agent binaries
- Improve download endpoint error messages with troubleshooting guidance
- Enhance host details drawer layout with better organization and visual hierarchy
- Update base images to rolling versions (node:20-alpine, golang:1.24-alpine, alpine:3.20)
This commit is contained in:
rcourtman
2025-11-05 17:38:17 +00:00
parent 7dd7a0b0f9
commit fdf0977be2
5 changed files with 160 additions and 48 deletions

View File

@@ -2,7 +2,7 @@
ARG BUILD_AGENT=1
# Build stage for frontend (must be built first for embedding)
FROM node:20.16.0-alpine3.19 AS frontend-builder
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend-modern
@@ -19,7 +19,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
npm run build
# Build stage for Go backend
FROM golang:1.24.9-alpine3.19 AS backend-builder
FROM golang:1.24-alpine AS backend-builder
ARG BUILD_AGENT
WORKDIR /app
@@ -77,6 +77,34 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
fi && \
cp pulse-docker-agent-linux-amd64 pulse-docker-agent
# Build host-agent binaries for all platforms (for download endpoint)
RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-linux-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-linux-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent
# Build pulse-sensor-proxy
RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \
@@ -86,7 +114,7 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
-o pulse-sensor-proxy ./cmd/pulse-sensor-proxy
# Runtime image for the Docker agent (offered via --target agent_runtime)
FROM alpine:3.19.1 AS agent_runtime
FROM alpine:3.20 AS agent_runtime
# Use TARGETARCH to select the correct binary for the build platform
ARG TARGETARCH
@@ -118,7 +146,7 @@ ENV PULSE_NO_AUTO_UPDATE=true
ENTRYPOINT ["/usr/local/bin/pulse-docker-agent"]
# Final stage (Pulse server runtime)
FROM alpine:3.19.1
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata su-exec openssh-client
@@ -138,9 +166,10 @@ RUN chmod +x /docker-entrypoint.sh
# Provide installer scripts for HTTP download endpoints
RUN mkdir -p /opt/pulse/scripts
COPY scripts/install-docker-agent.sh /opt/pulse/scripts/install-docker-agent.sh
COPY scripts/install-host-agent.sh /opt/pulse/scripts/install-host-agent.sh
COPY scripts/install-sensor-proxy.sh /opt/pulse/scripts/install-sensor-proxy.sh
COPY scripts/install-docker.sh /opt/pulse/scripts/install-docker.sh
RUN chmod 755 /opt/pulse/scripts/install-docker-agent.sh /opt/pulse/scripts/install-sensor-proxy.sh /opt/pulse/scripts/install-docker.sh
RUN chmod 755 /opt/pulse/scripts/install-docker-agent.sh /opt/pulse/scripts/install-host-agent.sh /opt/pulse/scripts/install-sensor-proxy.sh /opt/pulse/scripts/install-docker.sh
# Copy multi-arch docker-agent binaries for download endpoint
RUN mkdir -p /opt/pulse/bin
@@ -149,6 +178,16 @@ COPY --from=backend-builder /app/pulse-docker-agent-linux-arm64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-docker-agent-linux-armv7 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-docker-agent /opt/pulse/bin/pulse-docker-agent
# Copy multi-arch host-agent binaries for download endpoint
COPY --from=backend-builder /app/pulse-host-agent-linux-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-arm64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-armv7 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-darwin-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-darwin-arm64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-windows-amd64.exe /opt/pulse/bin/
# Create symlink for Windows without .exe extension
RUN ln -s pulse-host-agent-windows-amd64.exe /opt/pulse/bin/pulse-host-agent-windows-amd64
# Copy pulse-sensor-proxy binary for download endpoint
COPY --from=backend-builder /app/pulse-sensor-proxy /opt/pulse/bin/pulse-sensor-proxy

View File

@@ -1,13 +1,13 @@
# Pulse Makefile for development
.PHONY: build run dev frontend backend all clean distclean dev-hot lint lint-backend lint-frontend format format-backend format-frontend
.PHONY: build run dev frontend backend all clean distclean dev-hot lint lint-backend lint-frontend format format-backend format-frontend build-agents
FRONTEND_DIR := frontend-modern
FRONTEND_DIST := $(FRONTEND_DIR)/dist
FRONTEND_EMBED_DIR := internal/api/frontend-modern
# Build everything
all: frontend backend
# Build everything (including all agent binaries)
all: frontend backend build-agents
# Build frontend only
frontend:
@@ -68,3 +68,16 @@ format-backend:
format-frontend:
npm --prefix $(FRONTEND_DIR) run format
# Build all host agent binaries for all platforms
build-agents:
@echo "Building host agent binaries for all platforms..."
@mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-amd64 ./cmd/pulse-host-agent
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-arm64 ./cmd/pulse-host-agent
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent
@ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64
@echo "✓ All host agent binaries built in bin/"

View File

@@ -99,6 +99,9 @@ func runServer() {
log.Info().Msg("Starting Pulse monitoring server")
// Validate agent binaries are available for download
validateAgentBinaries()
// Create context that cancels on interrupt
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -345,3 +348,46 @@ func performAutoImport() error {
log.Info().Msg("Configuration auto-imported successfully")
return nil
}
// validateAgentBinaries checks if agent binaries are available for download
// and logs warnings if any are missing
func validateAgentBinaries() {
binDirs := []string{"/opt/pulse/bin", "./bin", "."}
platforms := []struct {
name string
file string
}{
{"linux-amd64", "pulse-host-agent-linux-amd64"},
{"linux-arm64", "pulse-host-agent-linux-arm64"},
{"linux-armv7", "pulse-host-agent-linux-armv7"},
{"darwin-amd64", "pulse-host-agent-darwin-amd64"},
{"darwin-arm64", "pulse-host-agent-darwin-arm64"},
{"windows-amd64", "pulse-host-agent-windows-amd64"},
}
missing := []string{}
searchedPaths := []string{}
for _, platform := range platforms {
found := false
for _, dir := range binDirs {
path := filepath.Join(dir, platform.file)
searchedPaths = append(searchedPaths, path)
if _, err := os.Stat(path); err == nil {
found = true
break
}
}
if !found {
missing = append(missing, platform.name)
}
}
if len(missing) > 0 {
log.Warn().
Strs("missing_platforms", missing).
Msg("Host agent binaries missing - install script downloads will fail. Rebuild Docker image or run build-release.sh to generate all platform binaries.")
} else {
log.Info().Msg("All host agent binaries available for download")
}
}

View File

@@ -333,41 +333,41 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}>
<tr class="text-[11px] bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300">
<td class="px-4 py-2" colSpan={5}>
<div class="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3">
<tr class="bg-gray-50 dark:bg-gray-900/50">
<td class="px-4 py-3" colSpan={5}>
<div class="flex flex-wrap justify-start gap-3">
{/* System Info */}
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">System</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<Show when={host.cpuCount}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">CPUs:</span>
<span>{host.cpuCount}</span>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
</div>
</Show>
<Show when={host.loadAverage && host.loadAverage.length > 0}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Load Avg:</span>
<span>{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
</div>
</Show>
<Show when={host.architecture}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Arch:</span>
<span>{host.architecture}</span>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
</div>
</Show>
<Show when={host.kernelVersion}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Kernel:</span>
<span class="break-all">{host.kernelVersion}</span>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
</div>
</Show>
<Show when={host.agentVersion}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Agent:</span>
<span>{host.agentVersion}</span>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
</div>
</Show>
</div>
@@ -375,15 +375,15 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{/* Network Interfaces */}
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network</div>
<div class="mt-1 space-y-1">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.networkInterfaces?.slice(0, 4)}>
{(iface) => (
<div class="text-gray-600 dark:text-gray-300">
<div class="font-medium">{iface.name}</div>
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
<Show when={iface.addresses && iface.addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-0.5">
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
<For each={iface.addresses}>
{(addr) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
@@ -402,22 +402,22 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{/* Disk Info */}
<Show when={host.disks && host.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Disks</div>
<div class="mt-1 space-y-1">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.disks?.slice(0, 3)}>
{(disk) => {
const diskPercent = () => disk.usage ?? 0;
return (
<div class="text-gray-600 dark:text-gray-300">
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="flex items-center justify-between">
<span class="font-medium truncate">{disk.mountpoint || disk.device}</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
</span>
</div>
<Show when={diskPercent() > 0}>
<div class="mt-0.5">
<div class="mt-1">
<MetricBar
value={diskPercent()}
label={formatPercent(diskPercent())}
@@ -435,14 +435,14 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Temperatures</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
{([name, temp]) => (
<div class="flex items-center justify-between">
<span class="truncate text-[10px]">{name}</span>
<span class={temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : ''}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
{temp.toFixed(1)}°C
</span>
</div>

View File

@@ -3302,7 +3302,21 @@ func (r *Router) handleDownloadHostAgent(w http.ResponseWriter, req *http.Reques
}
}
http.Error(w, "Host agent binary not found. Please build from source: go build ./cmd/pulse-host-agent", http.StatusNotFound)
// Build detailed error message with troubleshooting guidance
var errorMsg strings.Builder
errorMsg.WriteString(fmt.Sprintf("Host agent binary not found for %s/%s\n\n", platformParam, archParam))
errorMsg.WriteString("Troubleshooting:\n")
errorMsg.WriteString("1. If running in Docker: Rebuild the Docker image to include all platform binaries\n")
errorMsg.WriteString("2. If running bare metal: Run 'scripts/build-release.sh' to build all platform binaries\n")
errorMsg.WriteString("3. Build from source:\n")
errorMsg.WriteString(fmt.Sprintf(" GOOS=%s GOARCH=%s go build -o pulse-host-agent-%s-%s ./cmd/pulse-host-agent\n", platformParam, archParam, platformParam, archParam))
errorMsg.WriteString(fmt.Sprintf(" sudo mv pulse-host-agent-%s-%s /opt/pulse/bin/\n\n", platformParam, archParam))
errorMsg.WriteString("Searched locations:\n")
for _, path := range searchPaths {
errorMsg.WriteString(fmt.Sprintf(" - %s\n", path))
}
http.Error(w, errorMsg.String(), http.StatusNotFound)
}
// serveChecksum computes and serves the SHA256 checksum of a file