Prepare v6.0.0 release candidate

Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
This commit is contained in:
rcourtman 2026-06-04 14:07:14 +01:00
parent a73a24259c
commit bd6f77e093
119 changed files with 4753 additions and 2046 deletions

View file

@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Install Syft

View file

@ -253,7 +253,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Run backend tests
@ -484,7 +484,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Build Pulse Docker image for integration tests
@ -583,7 +583,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Set up Node.js
@ -1008,7 +1008,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Install Syft

View file

@ -82,7 +82,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache: true
- name: Set up Node.js

View file

@ -213,7 +213,8 @@ FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6
ARG TARGETARCH
ARG TARGETVARIANT
RUN apk --no-cache add ca-certificates tzdata
RUN apk --no-cache add ca-certificates tzdata && \
mkdir -p /var/lib/pulse-agent
WORKDIR /app
@ -234,9 +235,16 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \
COPY --from=release-assets-builder /app/VERSION /VERSION
ENV PULSE_NO_AUTO_UPDATE=true
ENV PULSE_NO_AUTO_UPDATE=true \
PULSE_DISABLE_AUTO_UPDATE=true \
PULSE_ENABLE_HOST=false \
PULSE_ENABLE_DOCKER=true \
PULSE_AGENT_ID_FILE=/var/lib/pulse-agent/agent-id \
PULSE_STATE_DIR=/var/lib/pulse-agent
ENTRYPOINT ["/usr/local/bin/pulse-agent", "--enable-docker", "--enable-host=false"]
VOLUME ["/var/lib/pulse-agent"]
ENTRYPOINT ["/usr/local/bin/pulse-agent"]
# Base Pulse server runtime shared by self-hosted and hosted tenant images.
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS pulse-runtime-base

View file

@ -94,8 +94,8 @@ func runProviderMSPPreflightWithDependencies(ctx context.Context, cfg *cloudcp.C
report.Failures = append(report.Failures, fmt.Sprintf(format, args...))
}
if !cfg.IsProviderHostedMSP() {
addFailure("CP_CONTROL_PLANE_MODE must be %s", cloudcp.ControlPlaneModeProviderHostedMSP)
if !cfg.IsMSPControlPlane() {
addFailure("CP_CONTROL_PLANE_MODE must be %s or %s", cloudcp.ControlPlaneModeProviderHostedMSP, cloudcp.ControlPlaneModePulseHostedMSP)
}
if cfg.UsesStripeBilling() {
addFailure("provider-hosted MSP preflight must be Stripe-free")

View file

@ -112,6 +112,40 @@ func TestProviderMSPPreflightPassesWithLicenseDockerAndStorage(t *testing.T) {
}
}
func TestProviderMSPPreflightAcceptsPulseHostedMSPMode(t *testing.T) {
docker := &fakeProviderMSPPreflightDocker{
report: &cpDocker.RuntimePrerequisiteReport{
OK: true,
DockerReachable: true,
NetworkName: "pulse-provider-msp",
NetworkOK: true,
NetworkID: "network-test",
ImageRef: "pulse:test",
ImageID: "sha256:test",
ImageAvailable: true,
},
}
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
cfg.ControlPlaneMode = cloudcp.ControlPlaneModePulseHostedMSP
report, err := runProviderMSPPreflightWithDependencies(
context.Background(),
cfg,
providerMSPPreflightOptions{},
fakeProviderMSPPreflightDependencies(docker, &cloudcp.StorageGuardrailReport{Enabled: false, OK: true}),
)
if err != nil {
t.Fatalf("runProviderMSPPreflightWithDependencies: %v", err)
}
if !report.OK {
t.Fatalf("report.OK = false, failures = %v", report.Failures)
}
if report.ControlMode != string(cloudcp.ControlPlaneModePulseHostedMSP) {
t.Fatalf("ControlMode = %q, want %q", report.ControlMode, cloudcp.ControlPlaneModePulseHostedMSP)
}
}
func TestProviderMSPPreflightSkipImagePullUsesInspectOnly(t *testing.T) {
docker := &fakeProviderMSPPreflightDocker{
report: &cpDocker.RuntimePrerequisiteReport{

View file

@ -153,8 +153,8 @@ func newProviderMSPProofRuntimeFromConfig(cfg *cloudcp.CPConfig) (*providerMSPPr
if cfg == nil {
return nil, fmt.Errorf("control plane config is required")
}
if !cfg.IsProviderHostedMSP() {
return nil, fmt.Errorf("provider MSP proof requires CP_CONTROL_PLANE_MODE=%s", cloudcp.ControlPlaneModeProviderHostedMSP)
if !cfg.IsMSPControlPlane() {
return nil, fmt.Errorf("provider MSP proof requires CP_CONTROL_PLANE_MODE=%s or %s", cloudcp.ControlPlaneModeProviderHostedMSP, cloudcp.ControlPlaneModePulseHostedMSP)
}
if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil {
return nil, fmt.Errorf("create tenants dir: %w", err)

View file

@ -65,6 +65,22 @@ func TestProviderMSPProofRequiresLicenseBackedPlanSourceByDefault(t *testing.T)
}
}
func TestProviderMSPProofRuntimeAcceptsPulseHostedMSPMode(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock")
t.Setenv("DOCKER_TLS_VERIFY", "")
t.Setenv("DOCKER_CERT_PATH", "")
cfg := testProviderMSPProofConfig(t)
cfg.ControlPlaneMode = cloudcp.ControlPlaneModePulseHostedMSP
cfg.BaseURL = "https://acme.msp.pulserelay.pro"
rt, err := newProviderMSPProofRuntimeFromConfig(cfg)
if err != nil {
t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err)
}
defer rt.close()
}
func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock")
t.Setenv("DOCKER_TLS_VERIFY", "")

View file

@ -1,4 +1,7 @@
# Domain
# Client runtimes are routed as https://<client-id>.${DOMAIN}/.
# Provider-hosted MSP: use the MSP's own domain, for example pulse.example-msp.com.
# Pulse-hosted MSP: use a Pulse-operated provider namespace, for example example-msp.msp.pulserelay.pro, and set CP_CONTROL_PLANE_MODE=pulse_hosted_msp.
DOMAIN=msp.example.com
ACME_EMAIL=admin@example.com
@ -13,6 +16,7 @@ CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256:<pin>
# Control plane
CP_ENV=production
CP_CONTROL_PLANE_MODE=provider_hosted_msp
CP_ADMIN_KEY=
PULSE_PROVIDER_MSP_DATA_DIR=/data
PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp

View file

@ -53,7 +53,7 @@ services:
environment:
- CP_DATA_DIR=${PULSE_PROVIDER_MSP_DATA_DIR:-/data}
- CP_ENV=${CP_ENV}
- CP_CONTROL_PLANE_MODE=provider_hosted_msp
- CP_CONTROL_PLANE_MODE=${CP_CONTROL_PLANE_MODE:-provider_hosted_msp}
- CP_BIND_ADDRESS=0.0.0.0
- CP_PORT=8443
- CP_ADMIN_KEY=${CP_ADMIN_KEY}

View file

@ -77,29 +77,18 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
## Legacy Cleanup (If Upgrading)
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container):
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
```bash
# Stop and disable all sensor-proxy systemd units
sudo systemctl disable --now pulse-sensor-proxy pulse-sensor-proxy-selfheal.timer pulse-sensor-proxy-selfheal.service pulse-sensor-cleanup.path pulse-sensor-cleanup.service 2>/dev/null
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge
```
# Remove systemd unit files
sudo rm -f /etc/systemd/system/pulse-sensor-proxy.service
sudo rm -f /etc/systemd/system/pulse-sensor-proxy-selfheal.timer
sudo rm -f /etc/systemd/system/pulse-sensor-proxy-selfheal.service
sudo rm -f /etc/systemd/system/pulse-sensor-cleanup.service
sudo rm -f /etc/systemd/system/pulse-sensor-cleanup.path
sudo systemctl daemon-reload
If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`:
# Remove sensor-proxy files
sudo rm -rf /opt/pulse/sensor-proxy
sudo rm -rf /etc/pulse-sensor-proxy
sudo rm -rf /var/lib/pulse-sensor-proxy
sudo rm -rf /var/log/pulse/sensor-proxy
sudo rm -rf /run/pulse-sensor-proxy
# Optional: remove sensor-proxy SSH keys from authorized_keys
sudo sed -i '/# pulse-managed-key$/d;/# pulse-proxy-key$/d' /root/.ssh/authorized_keys
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --remove-proxmox-access
```
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.

View file

@ -57,7 +57,16 @@ If you reset auth (for example by deleting `.env`), Pulse may require a bootstra
### Sensor proxy removal
The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring is now handled by the unified agent. If you had the sensor proxy installed on your Proxmox hosts, remove it **on each host** after upgrading. See the [Legacy Cleanup](TEMPERATURE_MONITORING.md#legacy-cleanup-if-upgrading) section in the temperature monitoring docs for the full cleanup commands.
The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring is now handled by the unified agent. If you had the sensor proxy installed on your Proxmox hosts, remove it **on each host** after upgrading:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge
```
If you deleted the old node from Pulse and want the cleanup to also remove the old `pulse-monitor@pam` API user and tokens before reinstalling, add `--remove-proxmox-access`.
See the [Legacy Cleanup](TEMPERATURE_MONITORING.md#legacy-cleanup-if-upgrading) section in the temperature monitoring docs for the full cleanup details.
Skipping this step will leave a selfheal timer running on the host that generates recurring `TASK ERROR` entries in the Proxmox task log.

View file

@ -96,8 +96,8 @@ servers.
### Can I keep Pulse v5 stable while I test Pulse v6?
Pulse v5 entered maintenance-only support on `2026-06-03` and remains eligible
only for critical maintenance fixes until `2026-09-01`.
Pulse v5 entered maintenance-only support on `2026-06-04` and remains eligible
only for critical maintenance fixes until `2026-09-02`.
If you want extra caution, use a staging or otherwise controlled upgrade first
and keep a rollback path available, but v6 is now the supported stable line.

View file

@ -529,7 +529,7 @@ explain monitored-system identity:
| Date | Change | Author |
|---|---|---|
| 2026-06-02 | Reconciled MSP pricing evidence with the provider-hosted model: signed MSP license, Stripe-free provider control plane, isolated Pulse runtime per client, 5/15/40 client workspace caps, and request-assisted access until launch approval. | Richard |
| 2026-06-02 | Reconciled MSP pricing evidence with the provider-operated architecture: signed MSP license, Stripe-free provider control plane, isolated Pulse runtime per client, 5/15/40 client workspace caps, and request-assisted access until launch approval. | Richard |
| 2026-04-29 | Replaced stale capacity-style monitoring phrasing with core-monitoring-included language across active v6 docs and upgrade-return copy so Community does not read like a former capacity upsell. | Codex |
| 2026-04-23 | Removed stale self-hosted monitored-system capacity and Pro+ public-checkout language. Reaffirmed Community / Relay / Pro as current public self-hosted tiers, with Pro+ as continuity only and Pro value centered on operations, history, and admin controls. | Codex |
| 2026-03-17 | Re-locked the self-hosted commercial model around monitored systems rather than installed agents. New self-hosted public pricing: Relay $4.99/$39, Pro $8.99/$79, Pro+ $14.99/$129. Added free-tier grace policy and marked the monitored-system counting migration as still required in code. | Codex + Richard |

View file

@ -64,8 +64,8 @@ These do not qualify as v5 maintenance work:
The first stable `v6.0.0` release must publish this exact notice:
> Pulse v5 entered maintenance-only support on 2026-06-03. I will ship only
> Pulse v5 entered maintenance-only support on 2026-06-04. I will ship only
> critical security, data-loss, licensing or billing blocker, installer or
> updater failure, and safe migration blocker fixes for existing v5 users until
> 2026-09-01. After 2026-09-01, Pulse v5 is end-of-support and new fixes land
> 2026-09-02. After 2026-09-02, Pulse v5 is end-of-support and new fixes land
> on v6 unless I publish an explicit exception.

View file

@ -20,8 +20,8 @@
7. `docs/releases/RELEASE_NOTES_v6.md` and
`docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the
currently proposed exact dates for the eventual GA notice:
- `v6` GA date: `2026-06-03`
- `v5` end-of-support date: `2026-09-01`
- `v6` GA date: `2026-06-04`
- `v5` end-of-support date: `2026-09-02`
8. There is still no governed `Release Dry Run` artifact or rehearsal record
exercising stable inputs for:
- `version=6.0.0`
@ -30,10 +30,10 @@
- the artifact-owned promotion channel for that rehearsal
- the artifact-owned promoted prerelease tag for that rehearsal
- the artifact-owned rollback target for that stable candidate
- `ga_date=2026-06-03`
- `ga_date=2026-06-04`
- an explicit `rollback_version`
- the exact derived rollback command that artifact will publish
- `v5_eos_date=2026-09-01`
- `v5_eos_date=2026-09-02`
## Why The Gate Cannot Be Cleared Yet
@ -57,10 +57,10 @@ users would still be the first real cohort for the final promotion path.
- an artifact-owned promoted prerelease tag matching that rehearsal
- an artifact-owned rollback target for the stable candidate
- the exact planned GA and v5 end-of-support dates for the publish notice
- `ga_date=2026-06-03`
- `ga_date=2026-06-04`
- an explicit stable `rollback_version`
- the exact derived rollback command that artifact will publish
- `v5_eos_date=2026-09-01`
- `v5_eos_date=2026-09-02`
4. Capture the `rc-to-ga-rehearsal-summary` artifact and run URL.
5. Materialize the final rehearsal record from that artifact without
hand-repairing any missing candidate tag, promoted prerelease tag, rollback

View file

@ -6763,7 +6763,21 @@
],
"coverage_gaps": [],
"candidate_lanes": [],
"work_claims": [],
"work_claims": [
{
"id": "codex-v6-release-release-gate-known-rc-issue-closure-for-ga",
"agent_id": "codex-v6-release",
"summary": "Resolve current v5-to-v6 parity audit gaps that would regress known user-visible fixes before GA publication.",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-06-04T11:23:28Z",
"heartbeat_at": "2026-06-04T13:02:12Z",
"expires_at": "2026-06-04T17:02:12Z",
"work_item": {
"kind": "release-gate",
"id": "known-rc-issue-closure-for-ga"
}
}
],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [

View file

@ -243,6 +243,13 @@ Stripe-free and avoids a cloud-control-plane report data path across clients.
into each tenant container, but it must not collect report data or render
PDFs in the control plane. Tenant-local reporting and tenant-local licensing
decide whether that configured brand appears.
`pulse_hosted_msp` is the Pulse-operated form of the same Stripe-free MSP
control-plane family, not the public Pulse-hosted SaaS checkout path. It
must share the license-backed MSP plan source, workspace limit policy,
disabled public signup and Stripe billing routes, isolated tenant runtime
networks, tenant-local report branding, and provider MSP operator commands
with `provider_hosted_msp` while preserving its own control-plane mode value
for status, backup manifests, and operational audit.
10. `internal/cloudcp/provider_msp_backup.go` shared with `deployment-installability`: provider-hosted MSP backup is both a cloud-paid license/account/runtime continuity boundary and a deployment-installability recovery artifact boundary.
License-backed provider MSP backups must include the signed MSP license
file as a recovery artifact while exposing only license metadata in command

View file

@ -175,6 +175,12 @@ TLS floor in the dynamic config.
dry-run restore into a separate target data dir, dry-run failed-workspace
recovery, remove proof workspaces when requested, and report final
operational status.
The packaged provider MSP compose bundle defaults to
`CP_CONTROL_PLANE_MODE=provider_hosted_msp`, but must allow
`pulse_hosted_msp` as an operator override for Pulse-operated MSP stacks
without forking the deployment artifact. Both modes use the same
`provider-msp` command group, license-backed proof path, isolated client
runtime containers, and runtime URL shape `https://<client-id>.${DOMAIN}/`.
`deploy/provider-msp/run-install-proof.sh` is the compose-level operator
wrapper for that rehearsal. It must validate the provider `.env` and compose
config, require a reachable Docker daemon, optionally pull the pinned
@ -514,9 +520,9 @@ TLS floor in the dynamic config.
rollback target, exact GA date, and exact v5 end-of-support date aligned
across release notes, upgrade guidance, support policy, promotion records,
and release-promotion resolver proof before workflow dispatch. For the
2026-06-03 cutover candidate, that packet is
2026-06-04 cutover candidate, that packet is
`promoted_from_tag=v6.0.0-rc.6`, `rollback_version=v5.1.34`,
`ga_date=2026-06-03`, and `v5_eos_date=2026-09-01`.
`ga_date=2026-06-04`, and `v5_eos_date=2026-09-02`.
That stable cut must also move the repo-root Docker compose default and
`scripts/install-docker.sh` fallback from the final RC image tag to the
stable `6.0.0` image tag in the same commit as `VERSION=6.0.0`.

View file

@ -21,10 +21,10 @@ backend, the navigation shape you already know.
## Pulse v5 Support Transition
Pulse v5 entered maintenance-only support on `2026-06-03`.
Pulse v5 entered maintenance-only support on `2026-06-04`.
I will ship only critical security, data-loss, licensing or billing blocker,
installer or updater failure, and safe migration blocker fixes for existing v5 users until `2026-09-01`.
After `2026-09-01`, Pulse v5 is end-of-support and new fixes land on v6 unless
installer or updater failure, and safe migration blocker fixes for existing v5 users until `2026-09-02`.
After `2026-09-02`, Pulse v5 is end-of-support and new fixes land on v6 unless
I publish an explicit exception.
## What Is In v6.0.0

View file

@ -2,7 +2,7 @@
> **This migration has been reverted as of `v6.0.0-rc.6`.** Pulse v6 ships
> with the platform-shaped top-level navigation existing v5 operators
> already know (Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Standalone,
> already know (Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines,
> plus Alerts, Patrol, and Settings). The unified
> `/infrastructure` / `/workloads` / `/storage` / `/recovery` layout that
> briefly shipped across `rc.1` through `rc.5` is retired. The unified
@ -21,7 +21,7 @@
> The keyboard shortcuts follow the platform-shaped shape:
>
> - `g p` Proxmox, `g d` Docker, `g k` Kubernetes, `g n` TrueNAS,
> `g v` vSphere, `g s` Standalone
> `g v` vSphere, `g s` Machines
> - `g a` Alerts, `g r` Patrol, `g t` Settings, `?` shortcuts help
>
> The "Classic shortcuts" bar referenced in the historical content below

View file

@ -0,0 +1,26 @@
import { buildMetadataAPI, type ResourceMetadataRecord } from './metadataClient';
export interface DockerMetadata extends ResourceMetadataRecord {}
const dockerMetadataAPI = buildMetadataAPI<DockerMetadata>('/api/docker/metadata');
export class DockerMetadataAPI {
static async getMetadata(resourceId: string): Promise<DockerMetadata> {
return dockerMetadataAPI.getMetadata(resourceId);
}
static async getAllMetadata(): Promise<Record<string, DockerMetadata>> {
return dockerMetadataAPI.getAllMetadata();
}
static async updateMetadata(
resourceId: string,
metadata: Partial<DockerMetadata>,
): Promise<DockerMetadata> {
return dockerMetadataAPI.updateMetadata(resourceId, metadata);
}
static async deleteMetadata(resourceId: string): Promise<void> {
await dockerMetadataAPI.deleteMetadata(resourceId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -20,15 +20,26 @@ vi.mock('@/utils/clipboard', () => ({
copyToClipboard: vi.fn(async () => true),
}));
vi.mock('@/api/ai', () => ({
AIAPI: {
getSettings: vi.fn(async () => ({ discovery_enabled: true })),
},
}));
import { AIAPI } from '@/api/ai';
import * as discoveryApi from '@/api/discovery';
import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab';
import { copyToClipboard } from '@/utils/clipboard';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
const aiSettingsWithDiscovery = (discovery_enabled: boolean) =>
({ discovery_enabled }) as Awaited<ReturnType<typeof AIAPI.getSettings>>;
describe('DiscoveryTab', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.mocked(AIAPI.getSettings).mockResolvedValue(aiSettingsWithDiscovery(true));
});
it('keeps run action visible while discovery lookup is still loading', async () => {
@ -39,6 +50,19 @@ describe('DiscoveryTab', () => {
expect(await screen.findByRole('button', { name: 'Run Discovery Now' })).toBeInTheDocument();
});
it('does not fetch discovery data when AI discovery is disabled', async () => {
vi.mocked(AIAPI.getSettings).mockResolvedValue(aiSettingsWithDiscovery(false));
render(() => (
<DiscoveryTab resourceType="agent" agentId="agent-1" resourceId="agent-1" hostname="pve1" />
));
expect(await screen.findByText('AI Discovery Disabled')).toBeInTheDocument();
expect(discoveryApi.getDiscovery).not.toHaveBeenCalled();
expect(discoveryApi.getDiscoveryInfo).not.toHaveBeenCalled();
expect(discoveryApi.getConnectedAgents).not.toHaveBeenCalled();
});
it('treats placeholder-only discovery records as unidentified instead of valid results', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue({
id: 'system-container:pve4:152',
@ -135,7 +159,9 @@ describe('DiscoveryTab', () => {
/>
));
fireEvent.click(await screen.findByRole('button', { name: 'Run Discovery' }));
const runButton = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(runButton).not.toBeDisabled());
fireEvent.click(runButton);
await waitFor(() => {
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledWith('agent', 'agent-1', 'agent-1', {

View file

@ -7,6 +7,7 @@ import {
triggerDiscovery,
updateDiscoveryNotes,
} from '@/api/discovery';
import { AIAPI } from '@/api/ai';
import { eventBus } from '@/stores/events';
import type { DiscoveryProgress, ResourceType } from '@/types/discovery';
import {
@ -52,9 +53,25 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
makeResourceId(props.resourceType, targetAgentId(), props.resourceId),
);
const [aiSettings] = createResource(async () => {
try {
return await AIAPI.getSettings();
} catch {
return null;
}
});
const discoveryFeatureResolved = createMemo(() => !aiSettings.loading);
const discoveryFeatureEnabled = createMemo(
() => discoveryFeatureResolved() && aiSettings()?.discovery_enabled !== false,
);
const discoveryFeatureKnownDisabled = createMemo(
() => discoveryFeatureResolved() && aiSettings()?.discovery_enabled === false,
);
const [discoveryInfo] = createResource(
() => props.resourceType,
() => (discoveryFeatureEnabled() ? props.resourceType : null),
async (type) => {
if (!type) return null;
try {
return await getDiscoveryInfo(type);
} catch {
@ -63,13 +80,19 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
},
);
const [connectedAgents] = createResource(async () => {
try {
return await getConnectedAgents();
} catch {
return { count: 0, agents: [] };
}
});
const [connectedAgents] = createResource(
() => discoveryFeatureEnabled(),
async (enabled) => {
if (!enabled) {
return { count: 0, agents: [] };
}
try {
return await getConnectedAgents();
} catch {
return { count: 0, agents: [] };
}
},
);
const hasConnectedAgent = createMemo(() => {
const agentId = targetAgentId();
@ -83,20 +106,28 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
return agents.length === 1;
});
const canTriggerDiscovery = createMemo(() => Boolean(targetAgentId()));
const canTriggerDiscovery = createMemo(
() => discoveryFeatureEnabled() && Boolean(targetAgentId()),
);
const [discovery, { refetch, mutate }] = createResource(discoverySourceKey, async () => {
const agentId = targetAgentId();
if (!agentId) return null;
const [discovery, { refetch, mutate }] = createResource(
() => (discoveryFeatureEnabled() ? discoverySourceKey() : null),
async (sourceKey) => {
if (!sourceKey) return null;
try {
return await getDiscovery(props.resourceType, agentId, props.resourceId);
} catch {
return null;
}
});
const agentId = targetAgentId();
if (!agentId) return null;
try {
return await getDiscovery(props.resourceType, agentId, props.resourceId);
} catch {
return null;
}
},
);
createEffect(() => {
void discoveryFeatureEnabled();
void discoverySourceKey();
setIsScanning(false);
setHttpScanInProgress(false);
@ -111,9 +142,13 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
});
createEffect(() => {
if (discoveryFeatureKnownDisabled()) {
setShowLoadingSpinner(false);
return;
}
if (discovery.loading) {
const timer = setTimeout(() => {
if (discovery.loading) {
if (discovery.loading && !discoveryFeatureKnownDisabled()) {
setShowLoadingSpinner(true);
}
}, 150);
@ -136,6 +171,11 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
});
const handleTriggerDiscovery = async (force = false) => {
if (!discoveryFeatureEnabled()) {
setScanError('AI discovery is disabled in Settings -> AI.');
return;
}
setIsScanning(true);
setHttpScanInProgress(true);
setScanProgress(null);
@ -231,6 +271,8 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
};
createEffect(() => {
if (!discoveryFeatureEnabled()) return;
const unsubscribe = eventBus.on('ai_discovery_progress', (progress) => {
if (!progress || progress.resource_id !== resourceId()) return;
@ -277,6 +319,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
canTriggerDiscovery,
copiedDiscoveryValue,
discovery,
discoveryFeatureKnownDisabled,
discoveryInfo,
editingNotes,
handleSaveNotes,

View file

@ -245,8 +245,8 @@ describe('toDiscoveryConfig', () => {
agentId: 'docker-host-1',
resourceId: 'container-abc123',
hostname: 'stale-hostname',
metadataKind: 'guest',
metadataId: 'resource:app-container:hash-1',
metadataKind: 'docker',
metadataId: 'docker-host-1:container:container-abc123',
targetLabel: 'container',
});
});
@ -280,8 +280,8 @@ describe('toDiscoveryConfig', () => {
agentId: 'agent-edge-01',
resourceId: 'customer-portal',
hostname: 'edge-apps-01',
metadataKind: 'guest',
metadataId: 'customer-portal',
metadataKind: 'docker',
metadataId: 'agent-edge-01:container:abc123def456',
targetLabel: 'container',
});
});

View file

@ -20,7 +20,7 @@ export type DiscoveryConfig = {
agentId: string;
resourceId: string;
hostname: string;
metadataKind: 'guest' | 'agent';
metadataKind: 'guest' | 'agent' | 'docker';
metadataId: string;
targetLabel: string;
};
@ -31,6 +31,7 @@ type ProxmoxPlatformData = {
};
type DockerPlatformData = {
hostSourceId?: string;
containerId?: string;
hostname?: string;
};
@ -84,7 +85,44 @@ const getPreferredHostLabel = (resource: Resource): string =>
getPreferredInfrastructureDisplayName(resource) ||
resource.id;
const getDockerContainerMetadataId = (
resource: Resource,
platformData: PlatformData | undefined,
): string | undefined => {
const dockerPlatformData = platformData?.docker;
const hostSourceId =
asString(resource.docker?.hostSourceId) || asString(dockerPlatformData?.hostSourceId);
const containerId =
asString(resource.docker?.containerId) || asString(dockerPlatformData?.containerId);
if (!hostSourceId || !containerId) return undefined;
return `${hostSourceId}:container:${containerId}`;
};
const getMetadataTarget = (
resource: Resource,
resourceType: DiscoveryResourceType,
fallbackMetadataId: string,
platformData: PlatformData | undefined,
): Pick<DiscoveryConfig, 'metadataKind' | 'metadataId'> => {
if (resourceType === 'app-container') {
const dockerMetadataId = getDockerContainerMetadataId(resource, platformData);
if (dockerMetadataId) {
return {
metadataKind: 'docker',
metadataId: dockerMetadataId,
};
}
}
return {
metadataKind: 'guest',
metadataId: fallbackMetadataId,
};
};
export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => {
const platformData = resource.platformData as PlatformData | undefined;
const explicitDiscoveryTarget = resource.discoveryTarget;
const explicitDiscoveryAgentId = asString(
(explicitDiscoveryTarget as { agentId?: unknown } | undefined)?.agentId,
@ -119,6 +157,9 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null =>
if (resourceType) {
const hostname = explicitDiscoveryTarget.hostname || getPreferredHostLabel(resource);
const isHostDiscovery = isAgentDiscoveryResourceType(resourceType);
const metadataTarget = isHostDiscovery
? { metadataKind: 'agent' as const, metadataId: explicitDiscoveryAgentId }
: getMetadataTarget(resource, resourceType, explicitDiscoveryResourceId, platformData);
const targetLabel = isHostDiscovery
? 'agent'
: resourceType === 'app-container'
@ -131,14 +172,13 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null =>
agentId: explicitDiscoveryAgentId,
resourceId: explicitDiscoveryResourceId,
hostname,
metadataKind: isHostDiscovery ? 'agent' : 'guest',
metadataId: explicitDiscoveryResourceId,
metadataKind: metadataTarget.metadataKind,
metadataId: metadataTarget.metadataId,
targetLabel,
};
}
}
const platformData = resource.platformData as PlatformData | undefined;
const dockerPlatformData = platformData?.docker;
const kubernetesPlatformData = platformData?.kubernetes;
const proxmoxVmid =
@ -253,8 +293,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null =>
agentId: workloadAgentId,
resourceId: asString(dockerPlatformData?.containerId) || resource.id,
hostname,
metadataKind: 'guest',
metadataId: resource.id,
...getMetadataTarget(resource, 'app-container', resource.id, platformData),
targetLabel: 'container',
};
case 'pod':

View file

@ -1 +1 @@
export { toReportingResourceType, type ReportingResourceType } from '@/utils/reportingResourceTypes';

View file

@ -26,6 +26,7 @@ const mockWebSocketState: State = {
},
activeAlerts: [],
recentlyResolved: [],
pveTagColors: {},
lastUpdate: 0,
resources: [],
temperatureMonitoringEnabled: false,

View file

@ -2,6 +2,7 @@ import { Component, For, Show } from 'solid-js';
import { getTagColorWithSpecial } from '@/utils/tagColors';
import { useDarkMode } from '@/contexts/appRuntime';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
interface TagBadgesProps {
tags?: string[];
@ -16,12 +17,14 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
const maxVisible = () => (props.maxVisible === 0 ? Infinity : (props.maxVisible ?? 3));
const darkModeSignal = useDarkMode();
const isDark = () => props.isDarkMode ?? darkModeSignal();
const ws = getGlobalWebSocketStore();
const pveTagColors = () => ws.state.pveTagColors;
const visibleTags = () => props.tags?.slice(0, maxVisible()) || [];
const hiddenTags = () => props.tags?.slice(maxVisible()) || [];
const TagDot: Component<{ tag: string }> = (dotProps) => {
const colors = () => getTagColorWithSpecial(dotProps.tag, isDark());
const colors = () => getTagColorWithSpecial(dotProps.tag, isDark(), pveTagColors());
const isActive = () => props.activeSearch?.includes(`tags:${dotProps.tag}`) || false;
const ringClass = () =>
isActive() ? (isDark() ? 'text-white/90' : 'text-black/80') : 'text-transparent';

View file

@ -3,11 +3,12 @@ import { render, screen, cleanup, fireEvent } from '@solidjs/testing-library';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { darkModeMock, showTooltipMock, hideTooltipMock } = vi.hoisted(() => {
const { darkModeMock, showTooltipMock, hideTooltipMock, pveTagColors } = vi.hoisted(() => {
const darkModeMock = vi.fn(() => false);
const showTooltipMock = vi.fn();
const hideTooltipMock = vi.fn();
return { darkModeMock, showTooltipMock, hideTooltipMock };
const pveTagColors = { web: '#112233' };
return { darkModeMock, showTooltipMock, hideTooltipMock, pveTagColors };
});
// ── Module mocks ───────────────────────────────────────────────────────
@ -21,15 +22,25 @@ vi.mock('@/components/shared/Tooltip', () => ({
hideTooltip: hideTooltipMock,
}));
const getTagColorWithSpecialMock = vi.fn((_tag: string, isDark: boolean) => ({
bg: isDark ? 'rgb(30, 30, 30)' : 'rgb(200, 200, 200)',
text: isDark ? 'rgb(240, 240, 240)' : 'rgb(20, 20, 20)',
border: isDark ? 'rgb(80, 80, 80)' : 'rgb(150, 150, 150)',
vi.mock('@/stores/websocket-global', () => ({
getGlobalWebSocketStore: () => ({
state: {
pveTagColors,
},
}),
}));
const getTagColorWithSpecialMock = vi.fn(
(_tag: string, isDark: boolean, _colorMap?: Record<string, string>) => ({
bg: isDark ? 'rgb(30, 30, 30)' : 'rgb(200, 200, 200)',
text: isDark ? 'rgb(240, 240, 240)' : 'rgb(20, 20, 20)',
border: isDark ? 'rgb(80, 80, 80)' : 'rgb(150, 150, 150)',
}),
);
vi.mock('@/utils/tagColors', () => ({
getTagColorWithSpecial: (...args: unknown[]) =>
getTagColorWithSpecialMock(...(args as [string, boolean])),
getTagColorWithSpecialMock(...(args as [string, boolean, Record<string, string> | undefined])),
}));
import { TagBadges } from '../TagBadges';
@ -141,14 +152,14 @@ describe('TagBadges', () => {
it('calls getTagColorWithSpecial with isDark=false in light mode', () => {
darkModeMock.mockReturnValue(false);
render(() => <TagBadges tags={['web']} />);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', false);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', false, pveTagColors);
const dot = getTagDots()[0];
expect(getTagDotFill(dot)).toBe('rgb(200, 200, 200)');
});
it('calls getTagColorWithSpecial with isDark=true when isDarkMode prop is true', () => {
render(() => <TagBadges tags={['web']} isDarkMode={true} />);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', true);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', true, pveTagColors);
const dot = getTagDots()[0];
expect(getTagDotFill(dot)).toBe('rgb(30, 30, 30)');
});
@ -156,7 +167,7 @@ describe('TagBadges', () => {
it('uses dark mode signal when isDarkMode prop is not set', () => {
darkModeMock.mockReturnValue(true);
render(() => <TagBadges tags={['db']} />);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('db', true);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('db', true, pveTagColors);
const dot = getTagDots()[0];
expect(getTagDotFill(dot)).toBe('rgb(30, 30, 30)');
});
@ -164,7 +175,7 @@ describe('TagBadges', () => {
it('isDarkMode=false overrides dark mode signal', () => {
darkModeMock.mockReturnValue(true);
render(() => <TagBadges tags={['web']} isDarkMode={false} />);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', false);
expect(getTagColorWithSpecialMock).toHaveBeenCalledWith('web', false, pveTagColors);
const dot = getTagDots()[0];
expect(getTagDotFill(dot)).toBe('rgb(200, 200, 200)');
});

View file

@ -18,11 +18,22 @@ vi.mock('@/api/agentMetadata', () => ({
},
}));
vi.mock('@/api/dockerMetadata', () => ({
DockerMetadataAPI: {
getMetadata: vi.fn(async () => ({ id: 'docker-host-1:container:container-1', customUrl: '' })),
updateMetadata: vi.fn(async () => ({
id: 'docker-host-1:container:container-1',
customUrl: '',
})),
},
}));
vi.mock('@/utils/clipboard', () => ({
copyToClipboard: vi.fn(async () => true),
}));
import { AgentMetadataAPI } from '@/api/agentMetadata';
import { DockerMetadataAPI } from '@/api/dockerMetadata';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
import { copyToClipboard } from '@/utils/clipboard';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
@ -43,6 +54,7 @@ describe('WebInterfaceUrlField', () => {
expect(webInterfaceUrlFieldStateSource).toContain('GuestMetadataAPI.getMetadata');
expect(webInterfaceUrlFieldStateSource).toContain('AgentMetadataAPI.updateMetadata');
expect(webInterfaceUrlFieldStateSource).toContain('DockerMetadataAPI.updateMetadata');
expect(webInterfaceUrlFieldStateSource).toContain('createSignal');
expect(webInterfaceUrlFieldStateSource).toContain('copyToClipboard');
expect(webInterfaceUrlFieldStateSource).toContain(
@ -99,6 +111,30 @@ describe('WebInterfaceUrlField', () => {
});
});
it('saves a Docker container URL through Docker metadata API', async () => {
render(() => (
<WebInterfaceUrlField
metadataKind="docker"
metadataId="docker-host-1:container:container-1"
targetLabel="container"
customUrl=""
/>
));
const input = await screen.findByPlaceholderText('https://198.51.100.100:8080');
fireEvent.input(input, { target: { value: 'https://app.internal:9443' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(DockerMetadataAPI.updateMetadata).toHaveBeenCalledWith(
'docker-host-1:container:container-1',
{
customUrl: 'https://app.internal:9443',
},
);
});
});
it('broadcasts saved URL changes for same-tab metadata consumers', async () => {
const handler = vi.fn();
window.addEventListener(RESOURCE_METADATA_CHANGED_EVENT, handler);

View file

@ -1,3 +1,4 @@
export const SUMMARY_CHART_SLOT_CLASS = 'h-[136px] sm:h-[150px]';
export const SUMMARY_CHART_SLOT_COMPACT_CLASS = 'h-[108px] sm:h-[120px]';
export const SUMMARY_CHART_PLOT_AREA_CLASS = 'h-[120px] sm:h-[134px]';

View file

@ -1,6 +1,7 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { AgentMetadataAPI } from '@/api/agentMetadata';
import { DockerMetadataAPI } from '@/api/dockerMetadata';
import { copyToClipboard } from '@/utils/clipboard';
import { dispatchResourceMetadataChanged } from '@/utils/resourceMetadataEvents';
import {
@ -79,6 +80,10 @@ export function useWebInterfaceUrlFieldState(props: WebInterfaceUrlFieldProps) {
const metadata = await AgentMetadataAPI.getMetadata(id);
return metadata?.customUrl ?? '';
}
if (props.metadataKind === 'docker') {
const metadata = await DockerMetadataAPI.getMetadata(id);
return metadata?.customUrl ?? '';
}
const metadata = await GuestMetadataAPI.getMetadata(id);
return metadata?.customUrl ?? '';
};
@ -88,6 +93,10 @@ export function useWebInterfaceUrlFieldState(props: WebInterfaceUrlFieldProps) {
await AgentMetadataAPI.updateMetadata(id, { customUrl: value });
return;
}
if (props.metadataKind === 'docker') {
await DockerMetadataAPI.updateMetadata(id, { customUrl: value });
return;
}
await GuestMetadataAPI.updateMetadata(id, { customUrl: value });
};

View file

@ -1,7 +1,7 @@
import { getDiscoverySuggestedURLFallback } from '@/utils/discoveryPresentation';
export interface WebInterfaceUrlFieldProps {
metadataKind: 'guest' | 'agent';
metadataKind: 'guest' | 'agent' | 'docker';
metadataId?: string;
targetLabel?: string;
title?: string;

View file

@ -1,6 +1,4 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { SearchInput } from '@/components/shared/SearchInput';
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
@ -17,6 +15,7 @@ import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
type PlatformTableFilterOption,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
@ -48,9 +47,9 @@ type DockerNetworksTableProps = DockerNativeTableProps & {
relatedResources?: Resource[];
};
type AttachmentStatusFilter = 'all' | 'attention' | 'running' | 'other';
type AttachmentGroupKey = 'attention' | 'running' | 'other';
type AttachmentGroupKey = Exclude<AttachmentStatusFilter, 'all'>;
type AttachmentStatusFilter = 'all' | AttachmentGroupKey;
type AttachmentGroup = {
key: AttachmentGroupKey;
@ -66,6 +65,28 @@ const ATTACHMENT_GROUPS: readonly AttachmentGroup[] = [
{ key: 'running', label: 'Running', description: 'No active issue reported' },
] as const;
const ATTACHMENT_STATUS_FILTER_OPTIONS: PlatformTableFilterOption<AttachmentStatusFilter>[] = [
{ value: 'all', label: 'All', ariaLabel: 'All', title: 'All attached containers' },
{
value: 'attention',
label: 'Attention',
ariaLabel: 'Attention',
title: 'Containers that need review',
},
{
value: 'running',
label: 'Running',
ariaLabel: 'Running',
title: 'Running containers',
},
{
value: 'other',
label: 'Other',
ariaLabel: 'Other',
title: 'Stopped, paused, or unknown containers',
},
];
const networkFlags = (resource: Resource): string =>
dockerJoinValues(
[
@ -141,33 +162,19 @@ const attachmentGroupKey = (row: DockerNetworkAttachmentRow): AttachmentGroupKey
return 'other';
};
const attachmentFilterMatches = (
row: DockerNetworkAttachmentRow,
filter: AttachmentStatusFilter,
): boolean => filter === 'all' || attachmentGroupKey(row) === filter;
const attachmentStatusCounts = (rows: readonly DockerNetworkAttachmentRow[]) => {
const counts: Record<AttachmentStatusFilter, number> = {
all: rows.length,
attention: 0,
running: 0,
other: 0,
};
for (const row of rows) {
counts[attachmentGroupKey(row)] += 1;
}
return counts;
const filterAttachmentRows = (
rows: DockerNetworkAttachmentRow[],
search: string,
status: AttachmentStatusFilter,
): DockerNetworkAttachmentRow[] => {
const needle = search.trim().toLowerCase();
return rows.filter((row) => {
if (status !== 'all' && attachmentGroupKey(row) !== status) return false;
if (!needle) return true;
return row.searchText.includes(needle);
});
};
const filterOptionLabel = (label: string, count: number): JSX.Element => (
<>
<span>{label}</span>
<span class="rounded bg-surface-alt px-1.5 py-px text-[10px] font-semibold text-muted">
{count}
</span>
</>
);
const AttachmentRowCard: Component<{ row: DockerNetworkAttachmentRow }> = (props) => (
<div class="rounded border border-border bg-surface px-3 py-2">
<div class="grid gap-2 text-[11px] md:grid-cols-[minmax(0,1.2fr)_7rem_9rem_minmax(0,1fr)] md:items-center">
@ -192,46 +199,14 @@ const AttachmentRowCard: Component<{ row: DockerNetworkAttachmentRow }> = (props
);
const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[] }> = (props) => {
const [attachmentSearch, setAttachmentSearch] = createSignal('');
const [attachmentFilter, setAttachmentFilter] = createSignal<AttachmentStatusFilter>('all');
const [showAll, setShowAll] = createSignal(false);
const counts = createMemo(() => attachmentStatusCounts(props.rows));
const filterOptions = createMemo(() => [
{
value: 'all',
label: filterOptionLabel('All', counts().all),
ariaLabel: 'All',
title: 'All attached containers',
},
{
value: 'attention',
label: filterOptionLabel('Attention', counts().attention),
ariaLabel: 'Attention',
title: 'Containers that need review',
},
{
value: 'running',
label: filterOptionLabel('Running', counts().running),
ariaLabel: 'Running',
title: 'Running containers',
},
{
value: 'other',
label: filterOptionLabel('Other', counts().other),
ariaLabel: 'Other',
title: 'Stopped, paused, or unknown containers',
},
]);
const filteredRows = createMemo(() => {
const needle = attachmentSearch().trim().toLowerCase();
return props.rows.filter((row) => {
if (!attachmentFilterMatches(row, attachmentFilter())) return false;
if (!needle) return true;
return row.searchText.includes(needle);
});
const tableState = createPlatformTableFilterState<DockerNetworkAttachmentRow, AttachmentStatusFilter>({
resources: () => [...props.rows],
initialStatus: 'all',
filter: filterAttachmentRows,
});
const visibleRows = createMemo(() =>
showAll() ? filteredRows() : filteredRows().slice(0, ATTACHMENT_DETAIL_ROW_LIMIT),
showAll() ? tableState.filtered() : tableState.filtered().slice(0, ATTACHMENT_DETAIL_ROW_LIMIT),
);
const groupedRows = createMemo(() =>
ATTACHMENT_GROUPS.map((group) => ({
@ -240,26 +215,20 @@ const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[]
})).filter((group) => group.rows.length > 0),
);
const hiddenRowCount = createMemo(() =>
Math.max(filteredRows().length - visibleRows().length, 0),
Math.max(tableState.filtered().length - visibleRows().length, 0),
);
const activeFilterCount = createMemo(() => {
let count = 0;
if (attachmentSearch().trim()) count += 1;
if (attachmentFilter() !== 'all') count += 1;
return count;
});
const filteredSummary = createMemo(() => {
if (activeFilterCount() === 0) return attachmentCountLabel(props.rows.length);
return `${attachmentPlainCountLabel(filteredRows().length)} of ${attachmentPlainCountLabel(
props.rows.length,
if (!tableState.hasActiveFilters()) return attachmentCountLabel(tableState.total());
return `${attachmentPlainCountLabel(tableState.visible())} of ${attachmentPlainCountLabel(
tableState.total(),
)}`;
});
const setSearch = (value: string) => {
setAttachmentSearch(value);
tableState.setSearch(value);
setShowAll(false);
};
const setFilter = (value: string) => {
setAttachmentFilter(value as AttachmentStatusFilter);
const setStatus = (value: AttachmentStatusFilter) => {
tableState.setStatus(value);
setShowAll(false);
};
@ -277,26 +246,27 @@ const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[]
</div>
}
>
<div class="mb-3 grid gap-2 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-center">
<SearchInput
value={attachmentSearch}
onChange={setSearch}
placeholder="Search attached containers"
title="Search attached containers"
inputClass="py-1.5 text-xs"
clearOnFocusedEscape
/>
<FilterSegmentedControl
value={attachmentFilter()}
onChange={setFilter}
options={filterOptions()}
aria-label="Attached container status"
class="justify-start xl:justify-end"
<div class="mb-3">
<PlatformTableToolbar
search={tableState.search}
onSearchChange={setSearch}
searchPlaceholder="Search attached containers"
status={tableState.status()}
onStatusChange={setStatus}
statusOptions={ATTACHMENT_STATUS_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="containers"
hasActiveFilters={tableState.hasActiveFilters()}
onResetFilters={() => {
tableState.resetFilters();
setShowAll(false);
}}
/>
</div>
<Show
when={filteredRows().length > 0}
when={tableState.filtered().length > 0}
fallback={
<div class="rounded border border-border bg-surface px-3 py-2 text-[11px] text-muted">
No attached containers match current filters.
@ -326,10 +296,10 @@ const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[]
class="w-full rounded border border-border bg-surface px-3 py-2 text-xs font-medium text-base-content hover:bg-surface-hover"
onClick={() => setShowAll(true)}
>
Show all {attachmentPlainCountLabel(filteredRows().length)}
Show all {attachmentPlainCountLabel(tableState.filtered().length)}
</button>
</Show>
<Show when={showAll() && filteredRows().length > ATTACHMENT_DETAIL_ROW_LIMIT}>
<Show when={showAll() && tableState.filtered().length > ATTACHMENT_DETAIL_ROW_LIMIT}>
<button
type="button"
class="w-full rounded border border-border bg-surface px-3 py-2 text-xs font-medium text-base-content hover:bg-surface-hover"

View file

@ -23,6 +23,7 @@ const baseState = (overrides: Partial<State> = {}): State => ({
lastUpdate: 0,
resources: [],
...overrides,
pveTagColors: overrides.pveTagColors ?? {},
});
const makeResourceStorage = (overrides: Partial<Resource> = {}): Resource =>

View file

@ -180,6 +180,7 @@ describe('useUnifiedResources', () => {
},
activeAlerts: [],
recentlyResolved: [],
pveTagColors: {},
resources: [wsResource],
lastUpdate: 0,
});

View file

@ -1,5 +1,8 @@
import { createWebSocketStore } from './websocket';
import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { getPulseWebSocketUrl } from '@/utils/url';
import type { Alert, ResolvedAlert, State } from '@/types/api';
// Store the instance on window to survive hot reloads
declare global {
@ -9,6 +12,61 @@ declare global {
}
}
const isJSDOMTestRuntime = () =>
import.meta.env.MODE === 'test' &&
typeof window !== 'undefined' &&
/jsdom/i.test(window.navigator.userAgent);
const createNoopWebSocketStore = (): ReturnType<typeof createWebSocketStore> => {
const [connected] = createSignal(false);
const [reconnecting] = createSignal(false);
const [initialDataReceived] = createSignal(true);
const [updateProgress] = createSignal<unknown>(null);
const [state] = createStore<State>({
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: {
startTime: new Date().toISOString(),
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '2.0.0',
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
pveTagColors: {},
resources: [],
});
return {
state,
activeAlerts: {} as Record<string, Alert>,
recentlyResolved: {} as Record<string, ResolvedAlert>,
connected,
reconnecting,
initialDataReceived,
updateProgress,
shutdown: () => {},
reconnect: () => {},
switchUrl: () => {},
markDockerRuntimesTokenRevoked: () => {},
markAgentsTokenRevoked: () => {},
removeAlerts: () => {},
updateAlert: () => {},
};
};
const bindGlobalShutdownHandler = () => {
if (window.__pulseWsShutdownBound) return;
@ -25,6 +83,11 @@ const bindGlobalShutdownHandler = () => {
export function getGlobalWebSocketStore() {
if (!window.__pulseWsStore) {
if (isJSDOMTestRuntime()) {
window.__pulseWsStore = createNoopWebSocketStore();
return window.__pulseWsStore;
}
const wsUrl = getPulseWebSocketUrl();
window.__pulseWsStore = createWebSocketStore(wsUrl);

View file

@ -148,6 +148,7 @@ export function createWebSocketStore(url: string) {
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
pveTagColors: {},
// Unified resources for cross-platform monitoring
resources: [],
});
@ -463,6 +464,9 @@ export function createWebSocketStore(url: string) {
if (message.data.connectionHealth !== undefined)
setState('connectionHealth', message.data.connectionHealth);
if (message.data.stats !== undefined) setState('stats', message.data.stats);
if (message.data.pveTagColors !== undefined) {
setState('pveTagColors', message.data.pveTagColors ?? {});
}
// Handle unified resources
if (message.data.resources !== undefined) {
const nextResources = Array.isArray(message.data.resources)

View file

@ -26,6 +26,7 @@ export interface State {
recentlyResolved: ResolvedAlert[];
lastUpdate: number;
temperatureMonitoringEnabled?: boolean;
pveTagColors: Record<string, string>;
// Unified resources (canonical resource model)
resources: Resource[];
}

View file

@ -1316,6 +1316,7 @@ export interface Resource {
// Metadata
tags?: string[];
labels?: Record<string, string>;
customUrl?: string;
lastSeen: number; // Unix milliseconds
alerts?: ResourceAlert[];
incidents?: ResourceIncident[];

View file

@ -124,6 +124,7 @@ export const useAppRuntimeState = () => {
},
activeAlerts: [],
recentlyResolved: [],
pveTagColors: {},
lastUpdate: 0,
resources: [],
};

View file

@ -1,44 +1,32 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { getTagColorWithSpecial } from '../tagColors';
describe('tagColors', () => {
describe('getTagColorWithSpecial', () => {
it('returns consistent colors for special tags in light mode', () => {
const production = getTagColorWithSpecial('production', false);
expect(production).toEqual({
bg: 'rgb(254, 226, 226)',
text: 'rgb(153, 27, 27)',
border: 'rgb(239, 68, 68)',
it('matches Proxmox fallback colors for tags', () => {
expect(getTagColorWithSpecial('production', false)).toEqual({
bg: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
text: '#000000',
border: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
});
const staging = getTagColorWithSpecial('STAGING', false); // case insensitive
expect(staging).toEqual({
bg: 'rgb(254, 243, 199)',
text: 'rgb(146, 64, 14)',
border: 'rgb(245, 158, 11)',
expect(getTagColorWithSpecial('STAGING', false)).toEqual({
bg: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
text: '#ffffff',
border: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
});
});
it('returns consistent colors for special tags in dark mode', () => {
const backup = getTagColorWithSpecial('backup', true);
expect(backup).toEqual({
bg: 'rgb(30, 58, 138)',
text: 'rgb(191, 219, 254)',
border: 'rgb(59, 130, 246)',
});
});
it('generates hash-based colors for non-special tags', () => {
it('is deterministic for the same tag', () => {
const color1 = getTagColorWithSpecial('mytag', false);
const color2 = getTagColorWithSpecial('mytag', false);
const color2 = getTagColorWithSpecial('mytag', true);
// Consistency
expect(color1).toEqual(color2);
// Values (H, S, L format)
expect(color1.bg).toMatch(/hsl\(\d+, 65%, 60%\)/);
expect(color1.text).toMatch(/hsl\(\d+, 65%, 25%\)/);
expect(color1.border).toMatch(/hsl\(\d+, 65%, 50%\)/);
expect(color1).toEqual({
bg: 'rgb(228.39999999999998, 164.7, 128.3)',
text: '#000000',
border: 'rgb(228.39999999999998, 164.7, 128.3)',
});
});
it('generates different colors for different tags', () => {
@ -48,12 +36,28 @@ describe('tagColors', () => {
expect(colorA.bg).not.toBe(colorB.bg);
});
it('generates dark mode hash-based colors', () => {
const color = getTagColorWithSpecial('custom', true);
it('prefers proxmox-supplied colors over generated fallback colors', () => {
expect(
getTagColorWithSpecial('Production', false, {
production: '#112233',
}),
).toEqual({
bg: 'rgb(17, 34, 51)',
text: '#ffffff',
border: 'rgb(17, 34, 51)',
});
});
expect(color.bg).toMatch(/hsl\(\d+, 55%, 35%\)/);
expect(color.text).toMatch(/hsl\(\d+, 55%, 85%\)/);
expect(color.border).toMatch(/hsl\(\d+, 55%, 45%\)/);
it('falls back to generated colors when proxmox color is invalid', () => {
expect(
getTagColorWithSpecial('backup', false, {
backup: 'not-a-color',
}),
).toEqual({
bg: 'rgb(108.00000000000001, 149.3, 143.7)',
text: '#ffffff',
border: 'rgb(108.00000000000001, 149.3, 143.7)',
});
});
});
});

View file

@ -1,7 +1,7 @@
export const RESOURCE_METADATA_CHANGED_EVENT = 'pulse:resource-metadata-changed';
export type ResourceMetadataChangedDetail = {
metadataKind: 'agent' | 'guest';
metadataKind: 'agent' | 'guest' | 'docker';
metadataId: string;
customUrl?: string;
};

View file

@ -1,111 +1,93 @@
// Generate consistent colors for tags based on their text
// This replicates Proxmox's tag color generation logic
/**
* Simple hash function to generate a number from a string
* This ensures the same tag always gets the same color
*/
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
/**
* Generate a color for a tag based on its text
* Uses HSL to ensure good visibility and consistent saturation/lightness
* (Internal helper - use getTagColorWithSpecial instead)
*/
function getTagColor(tag: string): { bg: string; text: string; border: string } {
// Get a hash of the tag
const hash = hashString(tag.toLowerCase());
// Generate hue from hash (0-360 degrees)
const hue = hash % 360;
// Use moderate saturation for subtle but visible colors
// These values are tuned to be noticeable without being distracting
const saturation = 65; // Moderate saturation
const lightnessBg = 60; // Slightly muted background
const lightnessText = 25; // Dark text for contrast
const lightnessBorder = 50; // Medium border
// For dark mode, we'll adjust these in the component
return {
bg: `hsl(${hue}, ${saturation}%, ${lightnessBg}%)`,
text: `hsl(${hue}, ${saturation}%, ${lightnessText}%)`,
border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`,
};
}
/**
* Get tag colors adjusted for dark mode
* (Internal helper - use getTagColorWithSpecial instead)
*/
function getTagColorDark(tag: string): { bg: string; text: string; border: string } {
const hash = hashString(tag.toLowerCase());
const hue = hash % 360;
const saturation = 55; // Moderate saturation in dark mode
return {
bg: `hsl(${hue}, ${saturation}%, 35%)`, // Subtler background
text: `hsl(${hue}, ${saturation}%, 85%)`, // Light text
border: `hsl(${hue}, ${saturation}%, 45%)`, // Subtle border
};
}
interface TagColorStyle {
bg: string;
text: string;
border: string;
}
interface TagColorTheme {
light: TagColorStyle;
dark: TagColorStyle;
function stringToRGB(tag: string): [number, number, number] {
let hash = 0;
if (!tag) {
return [255, 255, 255];
}
const value = `${tag.toLowerCase()}prox`;
for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash);
hash &= hash;
}
const alpha = 0.7;
const bg = 255;
return [
(hash & 255) * alpha + bg * (1 - alpha),
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
];
}
function rgbToCss(rgb: [number, number, number]): string {
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
function getTextContrastClass(rgb: [number, number, number]): 'light' | 'dark' {
const blkThrs = 0.022;
const blkClmp = 1.414;
const r = (rgb[0] / 255) ** 2.4;
const g = (rgb[1] / 255) ** 2.4;
const b = (rgb[2] / 255) ** 2.4;
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175;
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp;
const contrastLight = bg ** 0.65 - 1;
const contrastDark = bg ** 0.56 - 0.046134502;
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? 'light' : 'dark';
}
function parseHexColor(hex: string): [number, number, number] | null {
const normalized = hex.trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return [
parseInt(normalized.slice(0, 2), 16),
parseInt(normalized.slice(2, 4), 16),
parseInt(normalized.slice(4, 6), 16),
];
}
function buildStyleFromRGB(rgb: [number, number, number]): TagColorStyle {
const bg = rgbToCss(rgb);
const text = getTextContrastClass(rgb) === 'light' ? '#ffffff' : '#000000';
return {
bg,
text,
border: bg,
};
}
/**
* Proxmox's default tag colors for special tags
* These override the hash-based colors for specific tags
*/
const specialTagColors: Record<string, TagColorTheme> = {
production: {
light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' },
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' },
},
staging: {
light: { bg: 'rgb(254, 243, 199)', text: 'rgb(146, 64, 14)', border: 'rgb(245, 158, 11)' },
dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' },
},
development: {
light: { bg: 'rgb(220, 252, 231)', text: 'rgb(22, 101, 52)', border: 'rgb(34, 197, 94)' },
dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' },
},
backup: {
light: { bg: 'rgb(219, 234, 254)', text: 'rgb(30, 58, 138)', border: 'rgb(59, 130, 246)' },
dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' },
},
};
/**
* Get color for a tag, checking special colors first
* Get color for a tag.
* Priority: Proxmox-supplied hex color -> Proxmox fallback hash algorithm.
*/
export function getTagColorWithSpecial(
tag: string,
isDarkMode: boolean,
): { bg: string; text: string; border: string } {
_isDarkMode: boolean,
colorMap?: Record<string, string>,
): TagColorStyle {
const lowerTag = tag.toLowerCase();
// Check if it's a special tag
if (specialTagColors[lowerTag]) {
return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light;
const proxmoxHex = colorMap?.[lowerTag];
if (proxmoxHex) {
const rgb = parseHexColor(proxmoxHex);
if (rgb) {
return buildStyleFromRGB(rgb);
}
}
// Otherwise use hash-based color
return isDarkMode ? getTagColorDark(tag) : getTagColor(tag);
return buildStyleFromRGB(stringToRGB(tag));
}

View file

@ -1980,7 +1980,10 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca
log.Warn().Err(saveErr).Msg("failed to save patrol message after error")
}
}
return nil, err
return &PatrolResponse{
InputTokens: tempLoop.GetTotalInputTokens(),
OutputTokens: tempLoop.GetTotalOutputTokens(),
}, err
}
// Save result messages

View file

@ -3,6 +3,7 @@ package chat
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@ -198,6 +199,70 @@ func TestService_ExecutePatrolStream_Success(t *testing.T) {
}
}
func TestServiceExecutePatrolStreamReturnsPartialTokenCountsOnError(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
service := &Service{
started: true,
sessions: store,
executor: executor,
cfg: &config.AIConfig{PatrolModel: "mock:model"},
}
executor.RegisterTool(tools.RegisteredTool{
Definition: tools.Tool{
Name: "test_tool",
Description: "test tool",
InputSchema: tools.InputSchema{
Type: "object",
Properties: map[string]tools.PropertySchema{},
},
},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("tool ok"), nil
},
})
callCount := 0
service.providerFactory = func(modelStr string) (providers.StreamingProvider, error) {
return &mockStreamingProvider{
chatStreamFunc: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
callCount++
if callCount == 1 {
callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{
InputTokens: 13,
OutputTokens: 8,
ToolCalls: []providers.ToolCall{{
ID: "call-1",
Name: "test_tool",
Input: map[string]interface{}{},
}},
}})
return nil
}
return errors.New("provider interrupted")
},
}, nil
}
resp, err := service.ExecutePatrolStream(context.Background(), PatrolRequest{
Prompt: "check status",
MaxTurns: 2,
SessionID: "patrol-main",
}, func(event StreamEvent) {})
if err == nil {
t.Fatal("expected patrol stream error")
}
if resp == nil {
t.Fatal("expected partial patrol response")
}
if resp.InputTokens != 13 || resp.OutputTokens != 8 {
t.Fatalf("unexpected partial token counts: %+v", resp)
}
}
func TestService_ExecutePatrolStream_PulseStorageSnapshotsToleratesMalformedRecoveryMetadata(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {

View file

@ -49,17 +49,17 @@ func TestNormalizeModelForProvider(t *testing.T) {
}
func TestLookupPriceAndPatterns(t *testing.T) {
if _, ok := lookupPrice("", "gpt-4o"); ok {
if _, ok := lookupPrice("", "gpt-4o", 0); ok {
t.Fatal("expected empty provider to be unknown")
}
if _, ok := lookupPrice("openai", ""); ok {
if _, ok := lookupPrice("openai", "", 0); ok {
t.Fatal("expected empty model to be unknown")
}
if _, ok := lookupPrice("unknown", "model"); ok {
if _, ok := lookupPrice("unknown", "model", 0); ok {
t.Fatal("expected unknown provider to be unknown")
}
price, ok := lookupPrice("openai", "gpt-4o-mini")
price, ok := lookupPrice("openai", "gpt-4o-mini", 0)
if !ok || price.InputUSDPerMTok == 0 {
t.Fatalf("expected openai pricing match, got ok=%v price=%+v", ok, price)
}
@ -78,6 +78,56 @@ func TestLookupPriceAndPatterns(t *testing.T) {
}
}
func TestLookupPriceUsesTieredGeminiPricing(t *testing.T) {
under200k, ok := lookupPrice("gemini", "gemini-2.5-pro", 150_000)
if !ok {
t.Fatal("expected Gemini 2.5 Pro pricing to resolve")
}
if under200k.InputUSDPerMTok != 1.25 || under200k.OutputUSDPerMTok != 10.00 {
t.Fatalf("unexpected <=200k tier: %+v", under200k)
}
over200k, ok := lookupPrice("gemini", "gemini-2.5-pro", 250_000)
if !ok {
t.Fatal("expected Gemini 2.5 Pro high-tier pricing to resolve")
}
if over200k.InputUSDPerMTok != 2.50 || over200k.OutputUSDPerMTok != 15.00 {
t.Fatalf("unexpected >200k tier: %+v", over200k)
}
pro31, ok := lookupPrice("gemini", "gemini-3.1-pro-preview", 150_000)
if !ok {
t.Fatal("expected Gemini 3.1 Pro Preview pricing to resolve")
}
if pro31.InputUSDPerMTok != 2.00 || pro31.OutputUSDPerMTok != 12.00 {
t.Fatalf("unexpected Gemini 3.1 Pro Preview pricing: %+v", pro31)
}
flash35, ok := lookupPrice("gemini", "gemini-3.5-flash", 50_000)
if !ok {
t.Fatal("expected Gemini 3.5 Flash pricing to resolve")
}
if flash35.InputUSDPerMTok != 1.50 || flash35.OutputUSDPerMTok != 9.00 {
t.Fatalf("unexpected Gemini 3.5 Flash pricing: %+v", flash35)
}
flash25, ok := lookupPrice("gemini", "gemini-2.5-flash", 50_000)
if !ok {
t.Fatal("expected Gemini 2.5 Flash pricing to resolve")
}
if flash25.InputUSDPerMTok != 0.30 || flash25.OutputUSDPerMTok != 2.50 {
t.Fatalf("unexpected Gemini 2.5 Flash pricing: %+v", flash25)
}
flashLite25, ok := lookupPrice("gemini", "gemini-2.5-flash-lite", 50_000)
if !ok {
t.Fatal("expected Gemini 2.5 Flash-Lite pricing to resolve")
}
if flashLite25.InputUSDPerMTok != 0.10 || flashLite25.OutputUSDPerMTok != 0.40 {
t.Fatalf("unexpected Gemini 2.5 Flash-Lite pricing: %+v", flashLite25)
}
}
func TestSetPersistence_LoadError(t *testing.T) {
store := NewStore(30)
mock := &savePersistence{loadErr: errors.New("load failed")}

View file

@ -13,7 +13,7 @@ type TokenPrice struct {
// EstimateUSD returns an estimated USD cost for the given provider/model and token counts.
// If the model pricing is unknown, ok is false and usd is 0.
func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd float64, ok bool, price TokenPrice) {
price, ok = lookupPrice(provider, model)
price, ok = lookupPrice(provider, model, inputTokens)
if !ok {
return 0, false, TokenPrice{}
}
@ -24,12 +24,18 @@ func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd f
}
type modelPrice struct {
Pattern string
Pattern string
Tiers []priceTier
}
type priceTier struct {
// MaxInputTokens is inclusive. Zero means no upper bound.
MaxInputTokens int64
InputUSDPerMTok float64
OutputUSDPerMTok float64
}
const pricingAsOf = "2026-04"
const pricingAsOf = "2026-06-04"
// PricingAsOf indicates the effective date of the pricing table used for estimation.
func PricingAsOf() string {
@ -40,37 +46,67 @@ func PricingAsOf() string {
// The goal is quick estimation and relative comparisons, not exact billing.
var providerPrices = map[string][]modelPrice{
"openai": {
{Pattern: "gpt-4o*", InputUSDPerMTok: 5.00, OutputUSDPerMTok: 15.00},
{Pattern: "gpt-4o-mini*", InputUSDPerMTok: 0.15, OutputUSDPerMTok: 0.60},
flatPrice("gpt-4o-mini*", 0.15, 0.60),
flatPrice("gpt-4o*", 5.00, 15.00),
},
"anthropic": {
{Pattern: "claude-opus*", InputUSDPerMTok: 15.00, OutputUSDPerMTok: 75.00},
{Pattern: "claude-sonnet*", InputUSDPerMTok: 3.00, OutputUSDPerMTok: 15.00},
{Pattern: "claude-haiku*", InputUSDPerMTok: 0.25, OutputUSDPerMTok: 1.25},
flatPrice("claude-opus*", 15.00, 75.00),
flatPrice("claude-sonnet*", 3.00, 15.00),
flatPrice("claude-haiku*", 0.25, 1.25),
},
"deepseek": {
// DeepSeek docs include an input cache-hit discount; this uses cache-miss rates for conservative estimates.
{Pattern: "deepseek-v4-flash*", InputUSDPerMTok: 0.14, OutputUSDPerMTok: 0.28},
{Pattern: "deepseek-v4-pro*", InputUSDPerMTok: 0.435, OutputUSDPerMTok: 0.87},
{Pattern: "deepseek-*", InputUSDPerMTok: 0.14, OutputUSDPerMTok: 0.28},
flatPrice("deepseek-v4-flash*", 0.14, 0.28),
flatPrice("deepseek-v4-pro*", 0.435, 0.87),
flatPrice("deepseek-*", 0.14, 0.28),
},
"gemini": {
// Gemini pricing (as of December 2025)
// Gemini 3 models are in preview, pricing may change
{Pattern: "gemini-3-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-3-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-2.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-2.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-1.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-1.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, // Default to flash pricing
// Gemini Developer API standard paid-tier pricing, checked from
// https://ai.google.dev/gemini-api/docs/pricing on 2026-06-04.
flatPrice("gemini-3.5-flash*", 1.50, 9.00),
tieredPrice("gemini-3.1-pro-preview*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}),
tieredPrice("gemini-3.1-pro*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}),
flatPrice("gemini-3.1-flash-live-preview*", 0.75, 4.50),
flatPrice("gemini-3.1-flash-image*", 0.50, 3.00),
flatPrice("gemini-3.1-flash-lite*", 0.25, 1.50),
flatPrice("gemini-3-pro-image*", 2.00, 12.00),
tieredPrice("gemini-3-pro-preview*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}),
tieredPrice("gemini-3-pro*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}),
flatPrice("gemini-3-flash-preview*", 0.50, 3.00),
flatPrice("gemini-3-flash*", 0.50, 3.00),
tieredPrice("gemini-2.5-pro*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 1.25, OutputUSDPerMTok: 10.00}, priceTier{InputUSDPerMTok: 2.50, OutputUSDPerMTok: 15.00}),
flatPrice("gemini-2.5-flash-lite-preview*", 0.10, 0.40),
flatPrice("gemini-2.5-flash-lite*", 0.10, 0.40),
flatPrice("gemini-2.5-flash*", 0.30, 2.50),
flatPrice("gemini-2.0-flash-lite*", 0.075, 0.30),
flatPrice("gemini-2.0-flash*", 0.10, 0.40),
flatPrice("gemini-1.5-pro*", 1.25, 5.00),
flatPrice("gemini-1.5-flash*", 0.075, 0.30),
flatPrice("gemini-*", 0.30, 2.50), // Default to current Flash pricing.
},
"ollama": {
{Pattern: "*", InputUSDPerMTok: 0, OutputUSDPerMTok: 0},
flatPrice("*", 0, 0),
},
}
func lookupPrice(provider, model string) (TokenPrice, bool) {
func flatPrice(pattern string, inputUSDPerMTok, outputUSDPerMTok float64) modelPrice {
return modelPrice{
Pattern: pattern,
Tiers: []priceTier{{
InputUSDPerMTok: inputUSDPerMTok,
OutputUSDPerMTok: outputUSDPerMTok,
}},
}
}
func tieredPrice(pattern string, tiers ...priceTier) modelPrice {
return modelPrice{
Pattern: pattern,
Tiers: tiers,
}
}
func lookupPrice(provider, model string, inputTokens int64) (TokenPrice, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
model = strings.ToLower(strings.TrimSpace(model))
if provider == "" || model == "" {
@ -84,9 +120,13 @@ func lookupPrice(provider, model string) (TokenPrice, bool) {
for _, p := range prices {
if matchPattern(model, strings.ToLower(p.Pattern)) {
tier, ok := selectPriceTier(p.Tiers, inputTokens)
if !ok {
return TokenPrice{}, false
}
return TokenPrice{
InputUSDPerMTok: p.InputUSDPerMTok,
OutputUSDPerMTok: p.OutputUSDPerMTok,
InputUSDPerMTok: tier.InputUSDPerMTok,
OutputUSDPerMTok: tier.OutputUSDPerMTok,
AsOf: pricingAsOf,
}, true
}
@ -94,6 +134,21 @@ func lookupPrice(provider, model string) (TokenPrice, bool) {
return TokenPrice{}, false
}
func selectPriceTier(tiers []priceTier, inputTokens int64) (priceTier, bool) {
if len(tiers) == 0 {
return priceTier{}, false
}
if inputTokens < 0 {
inputTokens = 0
}
for _, tier := range tiers {
if tier.MaxInputTokens == 0 || inputTokens <= tier.MaxInputTokens {
return tier, true
}
}
return tiers[len(tiers)-1], true
}
func matchPattern(model, pattern string) bool {
if pattern == "*" {
return true

View file

@ -531,6 +531,9 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti
}
if chatErr != nil {
if attempt != nil && attempt.response != nil {
p.recordPatrolUsage(attempt.response.InputTokens, attempt.response.OutputTokens)
}
if !noStream {
p.setStreamPhase("idle")
p.broadcast(PatrolStreamEvent{Type: "error", Content: chatErr.Error()})
@ -734,6 +737,9 @@ func (p *PatrolService) runEvaluationPass(ctx context.Context, adapter *patrolFi
})
if err != nil {
if resp != nil {
p.recordPatrolUsage(resp.InputTokens, resp.OutputTokens)
}
log.Warn().Err(err).Msg("AI Patrol: Evaluation pass failed")
return nil, err
}

View file

@ -2,6 +2,7 @@ package ai
import (
"context"
"errors"
"strings"
"testing"
@ -75,3 +76,31 @@ func TestRunEvaluationPass(t *testing.T) {
t.Fatalf("unexpected evaluation response: %+v", resp)
}
}
func TestRunEvaluationPassRecordsPartialUsageOnStreamError(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
svc := NewService(persistence, nil)
svc.cfg = &config.AIConfig{Enabled: true, PatrolModel: "mock:patrol"}
svc.provider = &mockProvider{nameFunc: func() string { return "mock" }}
svc.SetChatService(&patrolMockChatService{
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
return &PatrolStreamResponse{InputTokens: 11, OutputTokens: 7}, errors.New("stream interrupted")
},
})
ps := NewPatrolService(svc, nil)
resp, err := ps.runEvaluationPass(context.Background(), nil, []DetectedSignal{{SignalType: SignalHighCPU}}, "patrol-run-eval")
if err == nil {
t.Fatal("expected evaluation error")
}
if resp != nil {
t.Fatalf("expected no response on evaluation error, got %+v", resp)
}
events := svc.ListCostEvents(1)
if len(events) != 1 {
t.Fatalf("expected one partial usage event, got %d", len(events))
}
if events[0].Provider != "mock" || events[0].RequestModel != "mock:patrol" || events[0].UseCase != "patrol" || events[0].InputTokens != 11 || events[0].OutputTokens != 7 {
t.Fatalf("unexpected partial usage event: %+v", events[0])
}
}

View file

@ -33,9 +33,13 @@ func TestService_QuickAnalysis(t *testing.T) {
t.Fatalf("execution_id=%q want patrol-run-123", req.ExecutionID)
}
return &providers.ChatResponse{
Content: "Analysis Result",
Content: "Analysis Result",
Model: "fast-model-response",
InputTokens: 12,
OutputTokens: 5,
}, nil
},
nameFunc: func() string { return "mock-provider" },
}
svc.provider = mockProv
svc.cfg = &config.AIConfig{
@ -54,6 +58,13 @@ func TestService_QuickAnalysis(t *testing.T) {
if res != "Analysis Result" {
t.Errorf("Unexpected result: %s", res)
}
events := svc.ListCostEvents(1)
if len(events) != 1 {
t.Fatalf("expected QuickAnalysis usage event, got %d", len(events))
}
if events[0].Provider == "" || events[0].UseCase != "patrol" || events[0].InputTokens != 12 || events[0].OutputTokens != 5 {
t.Fatalf("unexpected QuickAnalysis usage event: %+v", events[0])
}
// Case 3: Empty response
mockProv.chatFunc = func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {

View file

@ -43,6 +43,13 @@ func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.Pat
MaxTurns: req.MaxTurns,
}, adaptCallback(callback))
if err != nil {
if resp != nil {
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
}, err
}
return nil, err
}
return &ai.PatrolStreamResponse{

View file

@ -669,6 +669,8 @@ type NodeConfigRequest struct {
Password string `json:"password,omitempty"`
TokenName string `json:"tokenName,omitempty"`
TokenValue string `json:"tokenValue,omitempty"`
TokenID string `json:"tokenId,omitempty"`
TokenSecret string `json:"tokenSecret,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
VerifySSL *bool `json:"verifySSL,omitempty"`
MonitorVMs *bool `json:"monitorVMs,omitempty"` // PVE only
@ -691,6 +693,18 @@ type NodeConfigRequest struct {
Enabled *bool `json:"enabled,omitempty"` // Lifecycle toggle; nil on update preserves current
}
func (r *NodeConfigRequest) normalizeTokenAliases() {
r.TokenName = strings.TrimSpace(r.TokenName)
r.TokenValue = strings.TrimSpace(r.TokenValue)
if r.TokenName == "" {
r.TokenName = strings.TrimSpace(r.TokenID)
}
if r.TokenValue == "" {
r.TokenValue = strings.TrimSpace(r.TokenSecret)
}
}
// NodeResponse represents a node in API responses
type NodeResponse struct {
ID string `json:"id"`

View file

@ -156,6 +156,31 @@ func TestHandleAddNode(t *testing.T) {
}
},
},
{
name: "success_add_pve_token_aliases",
requestBody: map[string]interface{}{
"name": "test-token-alias-pve",
"type": "pve",
"host": "10.0.0.4",
"tokenId": "root@pam!alias",
"tokenSecret": "alias-secret",
},
expectedStatus: http.StatusCreated,
verifyConfig: func(t *testing.T, c *config.Config) {
for _, node := range c.PVEInstances {
if node.Name == "test-token-alias-pve" {
if node.TokenName != "root@pam!alias" {
t.Errorf("expected token name alias to persist, got %q", node.TokenName)
}
if node.TokenValue != "alias-secret" {
t.Errorf("expected token secret alias to persist, got %q", node.TokenValue)
}
return
}
}
t.Error("new PVE node (token aliases) not found in config")
},
},
}
for _, tt := range tests {
@ -177,6 +202,33 @@ func TestHandleAddNode(t *testing.T) {
}
}
func TestNodeConfigRequestNormalizeTokenAliases(t *testing.T) {
req := NodeConfigRequest{
TokenID: " root@pam!token ",
TokenSecret: " secret ",
}
req.normalizeTokenAliases()
if req.TokenName != "root@pam!token" {
t.Fatalf("TokenName = %q, want tokenId alias", req.TokenName)
}
if req.TokenValue != "secret" {
t.Fatalf("TokenValue = %q, want tokenSecret alias", req.TokenValue)
}
req = NodeConfigRequest{
TokenName: " root@pam!canonical ",
TokenValue: " canonical-secret ",
TokenID: "root@pam!alias",
TokenSecret: "alias-secret",
}
req.normalizeTokenAliases()
if req.TokenName != "root@pam!canonical" || req.TokenValue != "canonical-secret" {
t.Fatalf("canonical token fields must win over aliases, got name=%q value=%q", req.TokenName, req.TokenValue)
}
}
func TestHandleAddNode_PBSTurnkeyTokenCreationUsesCanonicalPulseURL(t *testing.T) {
tempDir := t.TempDir()

View file

@ -300,7 +300,11 @@ func TestPVESetupScript_ConfiguresPulseMonitorRoleSafely(t *testing.T) {
// Privileges must be comma-separated for pveum, and generated tokens must stay privilege-separated.
wantSnippets := []string{
`TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 2>&1)`,
`extract_pve_token_value()`,
`create_pve_token()`,
`pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 --output-format json`,
`pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 2>&1`,
`TOKEN_VALUE=$(extract_pve_token_value "$TOKEN_OUTPUT")`,
`pveum aclmod / -user pulse-monitor@pve -role PVEAuditor`,
`pveum aclmod / -token "$PULSE_TOKEN_ID" -role PVEAuditor`,
`PRIV_STRING="$(IFS=,; echo "${EXTRA_PRIVS[*]}")"`,
@ -318,6 +322,91 @@ func TestPVESetupScript_ConfiguresPulseMonitorRoleSafely(t *testing.T) {
}
}
func TestPVESetupScript_HardensTokenValueExtraction(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
handlers := newTestConfigHandlers(t, cfg)
req := httptest.NewRequest(http.MethodGet,
"/api/setup-script?type=pve&host=http://sentinel-host:8006&pulse_url=http://sentinel-url:7656&setup_token=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", nil)
rr := httptest.NewRecorder()
handlers.HandleSetupScript(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String())
}
script := rr.Body.String()
wantSnippets := []string{
`extract_json_string_field()`,
`sed -n 's/.*"'"$field_name"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'`,
`awk -F'[|│]'`,
`unknown option|unknown command|no such option|unable to parse option|output-format`,
}
for _, snippet := range wantSnippets {
if !containsString(script, snippet) {
t.Fatalf("expected generated script to contain:\n%s\n\nGot (first 1200 chars):\n%s", snippet, truncate(script, 1200))
}
}
jsonCreate := strings.Index(script, `pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 --output-format json`)
legacyCreate := strings.Index(script, `pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 2>&1`)
if jsonCreate < 0 || legacyCreate < 0 || jsonCreate > legacyCreate {
t.Fatalf("generated script must try JSON token output before legacy output fallback")
}
if containsString(script, `grep "│ value" | awk -F'│'`) {
t.Fatalf("expected hardened token parser to replace the old grep/awk single-shape extraction")
}
}
func TestPVESetupScript_PrefersGuestAgentPrivileges(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
handlers := newTestConfigHandlers(t, cfg)
req := httptest.NewRequest(http.MethodGet,
"/api/setup-script?type=pve&host=http://sentinel-host:8006&pulse_url=http://sentinel-url:7656&setup_token=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", nil)
rr := httptest.NewRecorder()
handlers.HandleSetupScript(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String())
}
script := rr.Body.String()
wantSnippets := []string{
`local HAS_VM_GUEST_AGENT_FILE_READ=false`,
`pveum role add TestGuestAgentFileRead -privs VM.GuestAgent.FileRead 2>/dev/null`,
`if [ "$HAS_VM_GUEST_AGENT_AUDIT" = true ]; then`,
`EXTRA_PRIVS+=("VM.GuestAgent.Audit")`,
`if [ "$HAS_VM_GUEST_AGENT_FILE_READ" = true ]; then`,
`EXTRA_PRIVS+=("VM.GuestAgent.FileRead")`,
`elif [ "$HAS_VM_MONITOR" = true ]; then`,
`EXTRA_PRIVS+=("VM.Monitor")`,
}
for _, snippet := range wantSnippets {
if !containsString(script, snippet) {
t.Fatalf("expected generated script to contain:\n%s\n\nGot (first 900 chars):\n%s", snippet, truncate(script, 900))
}
}
guestBranch := strings.Index(script, `if [ "$HAS_VM_GUEST_AGENT_AUDIT" = true ]; then`)
monitorBranch := strings.Index(script, `elif [ "$HAS_VM_MONITOR" = true ]; then`)
if guestBranch < 0 || monitorBranch < 0 || guestBranch > monitorBranch {
t.Fatalf("generated script must prefer VM.GuestAgent.* privileges before VM.Monitor")
}
}
func TestPVESetupScript_UsesFailFastRetryGuidance(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{

View file

@ -266,6 +266,7 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
log.Info().
Str("type", req.Type).
@ -764,6 +765,7 @@ func (h *ConfigHandlers) handleTestConnection(w http.ResponseWriter, r *http.Req
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
log.Info().
Str("type", req.Type).
@ -1075,6 +1077,7 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
// Debug: Log the received temperatureMonitoringEnabled value
log.Info().
@ -1699,6 +1702,7 @@ func (h *ConfigHandlers) handleTestNodeConfig(w http.ResponseWriter, r *http.Req
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
var testResult map[string]interface{}

View file

@ -337,6 +337,7 @@ var bareRouteAllowlist = []string{
"/api/server/info",
"/api/setup-script",
"/api/state",
"/api/state/summary",
"/api/system/mock-mode",
"/api/system/ssh-config",
"/api/system/verify-temperature-ssh",
@ -365,6 +366,7 @@ var allRouteAllowlist = []string{
"/api/health",
"/api/monitoring/scheduler/health",
"/api/state",
"/api/state/summary",
"/api/logs/stream",
"/api/logs/download",
"/api/logs/level",

View file

@ -25,6 +25,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
// API routes
r.mux.HandleFunc("/api/health", r.handleHealth)
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/state/summary", r.handleStateSummary)
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/security/validate-bootstrap-token", r.handleValidateBootstrapToken)
// Security routes

View file

@ -5,10 +5,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/stretchr/testify/assert"
)
@ -104,3 +106,96 @@ func TestRouter_HandleState_MockIsolation(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
func TestRouter_HandleStateSummary(t *testing.T) {
dataPath := t.TempDir()
hp, _ := auth.HashPassword("password")
cfg := &config.Config{
DataPath: dataPath,
AuthUser: "admin",
AuthPass: hp,
}
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
monitor, state, _ := newTestMonitor(t)
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
state.Nodes = []models.Node{{ID: "node-1"}, {ID: "node-2"}}
state.VMs = []models.VM{{ID: "vm-1"}}
state.Containers = []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}}
state.ActiveAlerts = []models.Alert{{ID: "alert-1"}}
state.DockerHosts = []models.DockerHost{
{
ID: "docker-1",
Hostname: "docker.local",
DisplayName: "Docker Host",
UptimeSeconds: 3600,
CPUUsage: 12.5,
Containers: []models.DockerContainer{
{ID: "container-1"},
{ID: "container-2"},
},
},
}
state.LastUpdate = lastUpdate
syncTestResourceStore(t, monitor, state)
router := &Router{config: cfg, monitor: monitor}
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
req.SetBasicAuth("admin", "password")
rec := httptest.NewRecorder()
router.handleStateSummary(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var summary stateSummaryResponse
err := json.Unmarshal(rec.Body.Bytes(), &summary)
assert.NoError(t, err)
assert.Equal(t, 1, summary.ActiveAlerts)
assert.Equal(t, 2, summary.Nodes)
assert.Equal(t, 1, summary.VMs)
assert.Equal(t, 3, summary.Containers)
assert.Equal(t, lastUpdate, summary.LastUpdate)
if assert.Len(t, summary.DockerHosts, 1) {
assert.Equal(t, "Docker Host", summary.DockerHosts[0].Name)
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
}
}
func TestBuildStateSummaryCountsSnapshotResources(t *testing.T) {
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
snapshot := models.StateSnapshot{
ActiveAlerts: []models.Alert{{ID: "alert-1"}},
Nodes: []models.Node{{ID: "node-1"}, {ID: "node-2"}},
VMs: []models.VM{{ID: "vm-1"}},
Containers: []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}},
DockerHosts: []models.DockerHost{{
ID: "docker-1",
Hostname: "docker.local",
DisplayName: "Docker Host",
CustomDisplayName: "Custom Docker Host",
UptimeSeconds: 3600,
CPUUsage: 12.5,
Containers: []models.DockerContainer{{ID: "container-1"}, {ID: "container-2"}},
}},
LastUpdate: lastUpdate,
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
summary := buildStateSummary(registry, len(snapshot.ActiveAlerts), snapshot.LastUpdate)
assert.Equal(t, 1, summary.ActiveAlerts)
assert.Equal(t, 2, summary.Nodes)
assert.Equal(t, 1, summary.VMs)
assert.Equal(t, 3, summary.Containers)
assert.Equal(t, lastUpdate, summary.LastUpdate)
if assert.Len(t, summary.DockerHosts, 1) {
assert.Equal(t, "Custom Docker Host", summary.DockerHosts[0].Name)
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
}
}

View file

@ -261,6 +261,7 @@ configure_pve_pulse_monitor_role() {
local apply_token_acl="$1"
local HAS_VM_MONITOR=false
local HAS_VM_GUEST_AGENT_AUDIT=false
local HAS_VM_GUEST_AGENT_FILE_READ=false
local HAS_SYS_AUDIT=false
local PVE_VERSION=""
local EXTRA_PRIVS=()
@ -278,6 +279,13 @@ configure_pve_pulse_monitor_role() {
pveum role delete TestGuestAgentAudit 2>/dev/null || true
fi
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.FileRead"; then
HAS_VM_GUEST_AGENT_FILE_READ=true
elif pveum role add TestGuestAgentFileRead -privs VM.GuestAgent.FileRead 2>/dev/null; then
HAS_VM_GUEST_AGENT_FILE_READ=true
pveum role delete TestGuestAgentFileRead 2>/dev/null || true
fi
if pveum role list 2>/dev/null | grep -q "Sys.Audit"; then
HAS_SYS_AUDIT=true
elif pveum role add TestSysAudit -privs Sys.Audit 2>/dev/null; then
@ -293,10 +301,13 @@ configure_pve_pulse_monitor_role() {
EXTRA_PRIVS+=("Sys.Audit")
fi
if [ "$HAS_VM_MONITOR" = true ]; then
EXTRA_PRIVS+=("VM.Monitor")
elif [ "$HAS_VM_GUEST_AGENT_AUDIT" = true ]; then
if [ "$HAS_VM_GUEST_AGENT_AUDIT" = true ]; then
EXTRA_PRIVS+=("VM.GuestAgent.Audit")
if [ "$HAS_VM_GUEST_AGENT_FILE_READ" = true ]; then
EXTRA_PRIVS+=("VM.GuestAgent.FileRead")
fi
elif [ "$HAS_VM_MONITOR" = true ]; then
EXTRA_PRIVS+=("VM.Monitor")
fi
if [ ${#EXTRA_PRIVS[@]} -gt 0 ]; then
@ -612,6 +623,66 @@ pveum user add pulse-monitor@pve --comment "Pulse monitoring service" 2>/dev/nul
AUTO_REG_SUCCESS=false
extract_json_string_field() {
local json_input="$1"
local field_name="$2"
printf '%%s\n' "$json_input" | sed -n 's/.*"'"$field_name"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
}
extract_pve_token_value() {
local token_output="$1"
local token_value=""
token_value=$(extract_json_string_field "$token_output" "value")
if [ -n "$token_value" ]; then
printf '%%s\n' "$token_value"
return 0
fi
printf '%%s\n' "$token_output" | awk -F'[|]' '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
{
key = trim($2)
value = trim($3)
if (key == "value" && value != "") {
print value
exit
}
}
'
}
create_pve_token() {
if TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 --output-format json 2>&1); then
TOKEN_CREATE_RC=0
else
TOKEN_CREATE_RC=$?
fi
if [ "$TOKEN_CREATE_RC" -eq 0 ]; then
TOKEN_VALUE=$(extract_pve_token_value "$TOKEN_OUTPUT")
return 0
fi
if echo "$TOKEN_OUTPUT" | grep -Eqi 'unknown option|unknown command|no such option|unable to parse option|output-format'; then
if TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 2>&1); then
TOKEN_CREATE_RC=0
else
TOKEN_CREATE_RC=$?
fi
if [ "$TOKEN_CREATE_RC" -eq 0 ]; then
TOKEN_VALUE=$(extract_pve_token_value "$TOKEN_OUTPUT")
fi
fi
return "$TOKEN_CREATE_RC"
}
attempt_auto_registration() {
if [ -z "$PULSE_SETUP_TOKEN" ]; then
if [ -t 0 ]; then
@ -697,8 +768,7 @@ if pulse_pve_token_exists; then
fi
# Create a privilege-separated token and capture value (shown once by Proxmox)
TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pve "$TOKEN_NAME" --privsep 1 2>&1)
TOKEN_CREATE_RC=$?
create_pve_token
if [ "$TOKEN_CREATE_RC" -ne 0 ]; then
echo "❌ Failed to create token '$TOKEN_NAME'"
echo "$TOKEN_OUTPUT"
@ -707,7 +777,6 @@ if [ "$TOKEN_CREATE_RC" -ne 0 ]; then
echo ""
else
TOKEN_CREATED=true
TOKEN_VALUE=$(echo "$TOKEN_OUTPUT" | grep "│ value" | awk -F'│' '{print $3}' | tr -d ' ' | tail -1)
if [ -z "$TOKEN_VALUE" ]; then
echo ""

View file

@ -0,0 +1,108 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
type stateSummaryResponse struct {
ActiveAlerts int `json:"activeAlerts"`
Nodes int `json:"nodes"`
VMs int `json:"vms"`
Containers int `json:"containers"`
DockerHosts []stateSummaryDockerHost `json:"dockerHosts"`
LastUpdate time.Time `json:"lastUpdate"`
}
type stateSummaryDockerHost struct {
Name string `json:"name"`
Containers int `json:"containers"`
UptimeSeconds int64 `json:"uptimeSeconds"`
CPUUsagePercent float64 `json:"cpuUsagePercent"`
}
func buildStateSummary(readState unifiedresources.ReadState, activeAlerts int, lastUpdate time.Time) stateSummaryResponse {
if readState == nil {
return stateSummaryResponse{
ActiveAlerts: activeAlerts,
LastUpdate: lastUpdate,
}
}
dockerHosts := make([]stateSummaryDockerHost, 0, len(readState.DockerHosts()))
for _, host := range readState.DockerHosts() {
if host == nil {
continue
}
name := strings.TrimSpace(host.CustomDisplayName())
if name == "" {
name = strings.TrimSpace(host.DisplayName())
}
if name == "" {
name = strings.TrimSpace(host.Hostname())
}
if name == "" {
name = host.ID()
}
dockerHosts = append(dockerHosts, stateSummaryDockerHost{
Name: name,
Containers: len(host.Containers()),
UptimeSeconds: host.UptimeSeconds(),
CPUUsagePercent: host.CPUPercent(),
})
}
return stateSummaryResponse{
ActiveAlerts: activeAlerts,
Nodes: len(readState.Nodes()),
VMs: len(readState.VMs()),
Containers: len(readState.Containers()),
DockerHosts: dockerHosts,
LastUpdate: lastUpdate,
}
}
func (r *Router) handleStateSummary(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only GET method is allowed", nil)
return
}
authWriter := &responseCapture{ResponseWriter: w}
if !checkAuth(r.config, authWriter, req, false) {
if !authWriter.wrote {
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
"Authentication required", nil)
}
return
}
if record := getAPITokenRecordFromRequest(req); record != nil && !record.HasScope(config.ScopeMonitoringRead) {
respondMissingScope(w, config.ScopeMonitoringRead)
return
}
monitor := r.getTenantMonitor(req.Context())
if monitor == nil {
writeErrorResponse(w, http.StatusInternalServerError, "no_monitor",
"Monitor not available", nil)
return
}
readState := monitor.GetUnifiedReadStateOrSnapshot()
snapshot := monitor.ReadSnapshot()
if err := utils.WriteJSONResponse(w, buildStateSummary(readState, len(snapshot.ActiveAlerts), snapshot.LastUpdate)); err != nil {
log.Error().Err(err).Msg("Failed to encode state summary response")
writeErrorResponse(w, http.StatusInternalServerError, "encoding_error",
"Failed to encode state summary", nil)
}
}

View file

@ -63,3 +63,32 @@ func TestHandleDownloadUnifiedAgentSetsChecksumAndInvalidatesOnChange(t *testing
t.Fatalf("unexpected response body after update")
}
}
func TestHandleDownloadUnifiedAgentAllowsVersionCacheKeyQuery(t *testing.T) {
binDir := setupTempPulseBin(t)
filePath := filepath.Join(binDir, "pulse-agent-linux-amd64")
payload := validTestUnifiedAgentBinary("agent-binary-v1")
if err := os.WriteFile(filePath, payload, 0o755); err != nil {
t.Fatalf("failed to write test binary: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/download/pulse-agent?arch=linux-amd64&serverVersion=v6.0.0-rc.6", nil)
rr := httptest.NewRecorder()
router := &Router{checksumCache: make(map[string]checksumCacheEntry)}
router.handleDownloadUnifiedAgent(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", rr.Code)
}
expected := fmt.Sprintf("%x", sha256.Sum256(payload))
if got := rr.Header().Get("X-Checksum-Sha256"); got != expected {
t.Fatalf("unexpected checksum header: got %q want %q", got, expected)
}
if strings.TrimSpace(rr.Body.String()) != string(payload) {
t.Fatalf("unexpected response body")
}
}

View file

@ -22,6 +22,7 @@ type ControlPlaneMode string
const (
ControlPlaneModePulseHosted ControlPlaneMode = "pulse_hosted"
ControlPlaneModePulseHostedMSP ControlPlaneMode = "pulse_hosted_msp"
ControlPlaneModeProviderHostedMSP ControlPlaneMode = "provider_hosted_msp"
defaultProviderHostedMSPPlanVersion = "msp_starter"
ProviderMSPPlanSourceLicenseFile = "license_file"
@ -106,8 +107,12 @@ func (c *CPConfig) IsProviderHostedMSP() bool {
return c != nil && c.ControlPlaneMode == ControlPlaneModeProviderHostedMSP
}
func (c *CPConfig) IsMSPControlPlane() bool {
return c != nil && isMSPControlPlaneMode(c.ControlPlaneMode)
}
func (c *CPConfig) UsesStripeBilling() bool {
return c != nil && !c.IsProviderHostedMSP()
return c != nil && !c.IsMSPControlPlane()
}
// LoadConfig loads control plane configuration from environment variables.
@ -184,7 +189,7 @@ func LoadConfig() (*CPConfig, error) {
providerMSPPlanSource := ProviderMSPPlanSourceEnvFallback
providerMSPLicenseID := ""
providerMSPLicenseEmail := ""
if controlPlaneMode == ControlPlaneModeProviderHostedMSP && providerMSPLicenseFile != "" {
if isMSPControlPlaneMode(controlPlaneMode) && providerMSPLicenseFile != "" {
resolvedPlan, licenseID, licenseEmail, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile)
if err != nil {
return nil, fmt.Errorf("resolve provider MSP license: %w", err)
@ -344,30 +349,30 @@ func (c *CPConfig) validate() error {
if c.Environment != "development" && c.Environment != "staging" && c.Environment != "production" {
return fmt.Errorf("CP_ENV must be one of development, staging, production (got %q)", c.Environment)
}
if c.ControlPlaneMode != ControlPlaneModePulseHosted && c.ControlPlaneMode != ControlPlaneModeProviderHostedMSP {
return fmt.Errorf("CP_CONTROL_PLANE_MODE must be one of %s, %s (got %q)", ControlPlaneModePulseHosted, ControlPlaneModeProviderHostedMSP, c.ControlPlaneMode)
if !isKnownControlPlaneMode(c.ControlPlaneMode) {
return fmt.Errorf("CP_CONTROL_PLANE_MODE must be one of %s, %s, %s (got %q)", ControlPlaneModePulseHosted, ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP, c.ControlPlaneMode)
}
if c.Environment == "production" && c.AllowDockerlessProvisioning {
return fmt.Errorf("CP_ALLOW_DOCKERLESS_PROVISIONING must be false in production")
}
if c.IsProviderHostedMSP() {
if c.IsMSPControlPlane() {
if strings.TrimSpace(c.StripeWebhookSecret) != "" {
return fmt.Errorf("STRIPE_WEBHOOK_SECRET must not be configured when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("STRIPE_WEBHOOK_SECRET must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if strings.TrimSpace(c.StripeAPIKey) != "" {
return fmt.Errorf("STRIPE_API_KEY must not be configured when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("STRIPE_API_KEY must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if c.PublicCloudSignupEnabled {
return fmt.Errorf("CP_PUBLIC_CLOUD_SIGNUP_ENABLED must be false when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("CP_PUBLIC_CLOUD_SIGNUP_ENABLED must be false when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if strings.TrimSpace(c.CloudMSPStarterPriceID) != "" || strings.TrimSpace(c.CloudMSPGrowthPriceID) != "" || strings.TrimSpace(c.CloudMSPScalePriceID) != "" {
return fmt.Errorf("CP_MSP_*_PRICE_ID must not be configured when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("CP_MSP_*_PRICE_ID must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if c.Environment == "production" && strings.TrimSpace(c.ProviderMSPLicenseFile) == "" {
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required in production when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required in production when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
}
if !strings.HasPrefix(strings.ToLower(c.ProviderMSPPlanVersion), "msp_") {
return fmt.Errorf("CP_PROVIDER_MSP_PLAN_VERSION must be a canonical MSP plan version, got %q", c.ProviderMSPPlanVersion)
@ -505,6 +510,8 @@ func normalizeControlPlaneMode(raw string) ControlPlaneMode {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "pulse_hosted", "pulse-hosted", "pulse_cloud", "pulse-cloud", "cloud":
return ControlPlaneModePulseHosted
case "pulse_hosted_msp", "pulse-hosted-msp", "pulse_msp", "pulse-msp", "hosted_msp", "hosted-msp":
return ControlPlaneModePulseHostedMSP
case "provider_hosted_msp", "provider-hosted-msp", "provider_msp", "provider-msp", "msp_provider", "msp-provider":
return ControlPlaneModeProviderHostedMSP
default:
@ -512,6 +519,24 @@ func normalizeControlPlaneMode(raw string) ControlPlaneMode {
}
}
func isKnownControlPlaneMode(mode ControlPlaneMode) bool {
switch mode {
case ControlPlaneModePulseHosted, ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP:
return true
default:
return false
}
}
func isMSPControlPlaneMode(mode ControlPlaneMode) bool {
switch mode {
case ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP:
return true
default:
return false
}
}
func resolveProviderMSPPlanFromLicenseFile(path string) (planVersion, licenseID, licenseEmail string, err error) {
path = strings.TrimSpace(path)
if path == "" {

View file

@ -467,6 +467,12 @@ func setProviderHostedMSPEnv(t *testing.T) {
setTrialSigningEnv(t)
}
func setPulseHostedMSPEnv(t *testing.T) {
t.Helper()
setProviderHostedMSPEnv(t)
t.Setenv("CP_CONTROL_PLANE_MODE", "pulse_hosted_msp")
}
func writeProviderMSPLicenseForTest(t *testing.T, tier pkglicensing.Tier, planVersion string) string {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
@ -523,6 +529,58 @@ func TestLoadConfig_ProviderHostedMSPDoesNotRequireStripe(t *testing.T) {
}
}
func TestLoadConfig_PulseHostedMSPUsesStripeFreeMSPStack(t *testing.T) {
setPulseHostedMSPEnv(t)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.ControlPlaneMode != ControlPlaneModePulseHostedMSP {
t.Fatalf("ControlPlaneMode = %q, want %q", cfg.ControlPlaneMode, ControlPlaneModePulseHostedMSP)
}
if !cfg.IsMSPControlPlane() {
t.Fatal("IsMSPControlPlane = false, want true")
}
if cfg.IsProviderHostedMSP() {
t.Fatal("IsProviderHostedMSP = true, want false for Pulse-hosted MSP")
}
if cfg.UsesStripeBilling() {
t.Fatal("UsesStripeBilling = true, want false")
}
if cfg.ProviderMSPPlanVersion != "msp_starter" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_starter", cfg.ProviderMSPPlanVersion)
}
}
func TestLoadConfig_PulseHostedMSPUsesSignedLicenseFilePlan(t *testing.T) {
setPulseHostedMSPEnv(t)
t.Setenv("CP_ENV", "production")
t.Setenv("CP_PROVIDER_MSP_PLAN_VERSION", "msp_starter")
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierMSP, "msp_scale"))
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.ProviderMSPPlanVersion != "msp_scale" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_scale", cfg.ProviderMSPPlanVersion)
}
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, ProviderMSPPlanSourceLicenseFile)
}
}
func TestNormalizeControlPlaneMode_PulseHostedMSPAliases(t *testing.T) {
for _, raw := range []string{"pulse_hosted_msp", "pulse-hosted-msp", "pulse_msp", "pulse-msp", "hosted_msp", "hosted-msp"} {
t.Run(raw, func(t *testing.T) {
if got := normalizeControlPlaneMode(raw); got != ControlPlaneModePulseHostedMSP {
t.Fatalf("normalizeControlPlaneMode(%q) = %q, want %q", raw, got, ControlPlaneModePulseHostedMSP)
}
})
}
}
func TestLoadConfig_ProviderHostedMSPReportBrandDefaults(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_REPORT_BRAND_DISPLAY_NAME", "Provider Default")
@ -586,6 +644,19 @@ func TestLoadConfig_ProviderHostedMSPRejectsStripeConfig(t *testing.T) {
}
}
func TestLoadConfig_PulseHostedMSPRejectsStripeConfig(t *testing.T) {
setPulseHostedMSPEnv(t)
t.Setenv("STRIPE_API_KEY", "sk_test_123")
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error when STRIPE_API_KEY is configured")
}
if !strings.Contains(err.Error(), "must not be configured") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadConfig_ProviderHostedMSPRejectsPublicSignup(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", "true")

View file

@ -29,7 +29,7 @@ func CanonicalTenantRuntimeRouting(tenantID, baseDomain string) TenantRuntimeRou
}
// TraefikLabels generates Docker labels for Traefik reverse-proxy routing.
// Each tenant gets a subdomain: <tenantID>.cloud.pulserelay.pro
// Each tenant gets a subdomain under the configured control-plane base domain.
func TraefikLabels(tenantID, baseDomain string, containerPort int, dockerNetwork ...string) map[string]string {
svc := "pulse-" + tenantID
routing := CanonicalTenantRuntimeRouting(tenantID, baseDomain)

View file

@ -132,8 +132,8 @@ func CreateProviderMSPBackup(ctx context.Context, cfg *CPConfig, outputPath stri
if cfg == nil {
return nil, fmt.Errorf("control plane config is required")
}
if !cfg.IsProviderHostedMSP() {
return nil, fmt.Errorf("provider MSP backup requires CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
if !cfg.IsMSPControlPlane() {
return nil, fmt.Errorf("provider MSP backup requires CP_CONTROL_PLANE_MODE=%s or %s", ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP)
}
if cfg.UsesStripeBilling() {
return nil, fmt.Errorf("provider MSP backup is unavailable for Stripe-backed control planes")
@ -967,8 +967,8 @@ func validateProviderMSPBackupManifest(manifest ProviderMSPBackupManifest) error
if manifest.Version != ProviderMSPBackupManifestVersion {
return fmt.Errorf("unsupported provider MSP backup manifest version %q", manifest.Version)
}
if manifest.ControlPlaneMode != string(ControlPlaneModeProviderHostedMSP) {
return fmt.Errorf("backup control_plane_mode = %q, want %q", manifest.ControlPlaneMode, ControlPlaneModeProviderHostedMSP)
if !isMSPControlPlaneMode(ControlPlaneMode(manifest.ControlPlaneMode)) {
return fmt.Errorf("backup control_plane_mode = %q, want %q or %q", manifest.ControlPlaneMode, ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP)
}
if strings.TrimSpace(manifest.PlanVersion) == "" {
return fmt.Errorf("backup manifest is missing plan_version")

View file

@ -225,6 +225,26 @@ func TestProviderMSPBackupRestoreRequiresReplaceForExistingState(t *testing.T) {
assertProviderMSPBackupRestoredTenantCount(t, filepath.Join(targetDataDir, "control-plane", "tenants.db"), 2)
}
func TestProviderMSPBackupManifestAcceptsMSPControlPlaneModes(t *testing.T) {
base := ProviderMSPBackupManifest{
Version: ProviderMSPBackupManifestVersion,
PlanVersion: "msp_growth",
PlanSource: ProviderMSPPlanSourceLicenseFile,
ControlPlaneDir: providerMSPBackupControlPlaneDir,
TenantsDir: providerMSPBackupTenantsDir,
}
for _, mode := range []ControlPlaneMode{ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP} {
t.Run(string(mode), func(t *testing.T) {
manifest := base
manifest.ControlPlaneMode = string(mode)
if err := validateProviderMSPBackupManifest(manifest); err != nil {
t.Fatalf("validateProviderMSPBackupManifest: %v", err)
}
})
}
}
func testProviderMSPBackupConfig(t *testing.T) *CPConfig {
t.Helper()
root := t.TempDir()

View file

@ -37,8 +37,8 @@ type ProviderMSPBootstrapResult struct {
}
// BootstrapProviderMSP creates or reuses the MSP account and owner identity for
// a provider-hosted MSP control plane. It is deliberately unavailable in normal
// Pulse-hosted mode so the MSP bootstrap path cannot leak into ordinary hosting.
// an MSP control plane. It is deliberately unavailable in normal Pulse-hosted
// mode so the MSP bootstrap path cannot leak into ordinary hosting.
func BootstrapProviderMSP(ctx context.Context, cfg *CPConfig, opts ProviderMSPBootstrapOptions) (*ProviderMSPBootstrapResult, error) {
if err := ctx.Err(); err != nil {
return nil, err
@ -46,8 +46,8 @@ func BootstrapProviderMSP(ctx context.Context, cfg *CPConfig, opts ProviderMSPBo
if cfg == nil {
return nil, fmt.Errorf("control plane config is required")
}
if !cfg.IsProviderHostedMSP() {
return nil, fmt.Errorf("provider MSP bootstrap requires CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
if !cfg.IsMSPControlPlane() {
return nil, fmt.Errorf("provider MSP bootstrap requires CP_CONTROL_PLANE_MODE=%s or %s", ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP)
}
accountName := strings.TrimSpace(opts.AccountName)

View file

@ -101,6 +101,23 @@ func TestBootstrapProviderMSPIsIdempotentForExistingMSPAccount(t *testing.T) {
}
}
func TestBootstrapProviderMSPAcceptsPulseHostedMSPMode(t *testing.T) {
cfg := testProviderMSPBootstrapConfig(t)
cfg.ControlPlaneMode = ControlPlaneModePulseHostedMSP
cfg.BaseURL = "https://acme.msp.pulserelay.pro"
result, err := BootstrapProviderMSP(context.Background(), cfg, ProviderMSPBootstrapOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
})
if err != nil {
t.Fatalf("BootstrapProviderMSP: %v", err)
}
if result.WorkspaceLimit != 15 {
t.Fatalf("WorkspaceLimit = %d, want 15", result.WorkspaceLimit)
}
}
func TestBootstrapProviderMSPRejectsPulseHostedMode(t *testing.T) {
cfg := testProviderMSPBootstrapConfig(t)
cfg.ControlPlaneMode = ControlPlaneModePulseHosted

View file

@ -204,8 +204,8 @@ func validateProviderMSPRecoveryOptions(opts ProviderMSPRecoveryOptions) error {
}
func validateProviderMSPRecoveryConfig(cfg *CPConfig, opts ProviderMSPRecoveryOptions) error {
if !cfg.IsProviderHostedMSP() {
return fmt.Errorf("provider MSP recovery requires CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
if !cfg.IsMSPControlPlane() {
return fmt.Errorf("provider MSP recovery requires CP_CONTROL_PLANE_MODE=%s or %s", ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP)
}
if cfg.UsesStripeBilling() {
return fmt.Errorf("provider-hosted MSP recovery must be Stripe-free")

View file

@ -33,7 +33,7 @@ type Deps struct {
}
func publicCloudSignupPath(cfg *CPConfig) string {
if cfg != nil && cfg.IsProviderHostedMSP() {
if cfg != nil && cfg.IsMSPControlPlane() {
return ""
}
if cfg != nil && cfg.PublicCloudSignupEnabled {
@ -56,7 +56,7 @@ func runtimeStatus(cfg *CPConfig) admin.RuntimeStatus {
status := admin.RuntimeStatus{
ControlPlaneMode: string(cfg.ControlPlaneMode),
}
if cfg.IsProviderHostedMSP() {
if cfg.IsMSPControlPlane() {
status.ProviderMSPPlanVersion = providerMSPPlanVersion(cfg)
status.ProviderMSPPlanSource = cfg.ProviderMSPPlanSource
if limit, known := pkglicensing.WorkspaceLimitForPlan(status.ProviderMSPPlanVersion); known {
@ -246,7 +246,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
// Workspace management (session + account-membership authenticated)
listTenants := account.HandleListTenants(deps.Registry)
workspaceLimitPolicy := account.WorkspaceLimitPolicy{}
if deps.Config.IsProviderHostedMSP() {
if deps.Config.IsMSPControlPlane() {
workspaceLimitPolicy.ProviderHostedMSP = true
workspaceLimitPolicy.ProviderMSPPlanVersion = providerMSPPlanVersion(deps.Config)
}

View file

@ -58,6 +58,51 @@ func TestRegisterRoutes_StatusIncludesProviderMSPRuntime(t *testing.T) {
}
}
func TestRegisterRoutes_StatusIncludesPulseHostedMSPRuntime(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
mux := http.NewServeMux()
RegisterRoutes(mux, &Deps{
Config: &CPConfig{
DataDir: dir,
AdminKey: "test-admin-key",
BaseURL: "https://example-msp.msp.pulserelay.pro",
ControlPlaneMode: ControlPlaneModePulseHostedMSP,
ProviderMSPPlanVersion: "msp_scale",
ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile,
},
Registry: reg,
Version: "test",
})
req := httptest.NewRequest(http.MethodGet, "/status", nil)
req.Header.Set("X-Admin-Key", "test-admin-key")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /status status=%d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode /status payload: %v", err)
}
if payload["control_plane_mode"] != string(ControlPlaneModePulseHostedMSP) {
t.Fatalf("control_plane_mode = %#v", payload["control_plane_mode"])
}
if payload["provider_msp_plan_version"] != "msp_scale" {
t.Fatalf("provider_msp_plan_version = %#v", payload["provider_msp_plan_version"])
}
if payload["provider_msp_workspace_limit"] != float64(40) {
t.Fatalf("provider_msp_workspace_limit = %#v", payload["provider_msp_workspace_limit"])
}
}
func TestRegisterRoutes_AccountAndTenantMethodDispatch(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
@ -383,45 +428,49 @@ func TestRegisterRoutes_PublicCloudSignupRoutesDisabledByDefault(t *testing.T) {
}
}
func TestRegisterRoutes_ProviderHostedMSPDisablesPulseHostedBillingRoutes(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
func TestRegisterRoutes_MSPControlPlaneDisablesPulseHostedBillingRoutes(t *testing.T) {
for _, mode := range []ControlPlaneMode{ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP} {
t.Run(string(mode), func(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
mux := http.NewServeMux()
RegisterRoutes(mux, &Deps{
Config: &CPConfig{
DataDir: dir,
AdminKey: "test-admin-key",
BaseURL: "https://msp.example.com",
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
PublicCloudSignupEnabled: true,
ProviderMSPPlanVersion: "msp_growth",
},
Registry: reg,
Version: "test",
})
mux := http.NewServeMux()
RegisterRoutes(mux, &Deps{
Config: &CPConfig{
DataDir: dir,
AdminKey: "test-admin-key",
BaseURL: "https://msp.example.com",
ControlPlaneMode: mode,
PublicCloudSignupEnabled: true,
ProviderMSPPlanVersion: "msp_growth",
},
Registry: reg,
Version: "test",
})
for _, tc := range []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/stripe/webhook"},
{method: http.MethodGet, path: "/api/portal/billing"},
{method: http.MethodGet, path: "/signup"},
{method: http.MethodGet, path: "/cloud/signup"},
{method: http.MethodPost, path: "/api/public/signup"},
{method: http.MethodGet, path: "/cloud/msp/signup"},
{method: http.MethodPost, path: "/api/public/msp/signup"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("%s %s status=%d, want %d", tc.method, tc.path, rec.Code, http.StatusNotFound)
}
for _, tc := range []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/stripe/webhook"},
{method: http.MethodGet, path: "/api/portal/billing"},
{method: http.MethodGet, path: "/signup"},
{method: http.MethodGet, path: "/cloud/signup"},
{method: http.MethodPost, path: "/api/public/signup"},
{method: http.MethodGet, path: "/cloud/msp/signup"},
{method: http.MethodPost, path: "/api/public/msp/signup"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("%s %s status=%d, want %d", tc.method, tc.path, rec.Code, http.StatusNotFound)
}
}
})
}
}

View file

@ -55,7 +55,7 @@ func Run(ctx context.Context, version string) error {
dockerMgr, err = cpDocker.NewManager(cpDocker.ManagerConfig{
Image: cfg.PulseImage,
Network: cfg.DockerNetwork,
IsolateTenantNetworks: cfg.IsProviderHostedMSP(),
IsolateTenantNetworks: cfg.IsMSPControlPlane(),
BaseDomain: baseDomainFromURL(cfg.BaseURL),
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,

View file

@ -218,7 +218,7 @@ func newTenantRuntimeRolloutServiceFromConfig(
dockerMgr, err := cpDocker.NewManager(cpDocker.ManagerConfig{
Image: image,
Network: cfg.DockerNetwork,
IsolateTenantNetworks: cfg.IsProviderHostedMSP(),
IsolateTenantNetworks: cfg.IsMSPControlPlane(),
BaseDomain: baseDomainFromURL(cfg.BaseURL),
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,

View file

@ -244,7 +244,7 @@ func (p *ProxmoxSetup) configurePVEPermissions(ctx context.Context, tokenID stri
// Extra privileges are optional, but enable additional features:
// - Sys.Audit: required for pending apt updates + some cluster/ceph visibility
// - VM.Monitor (PVE 8) or VM.GuestAgent.Audit (PVE 9+): guest agent data
// - VM.GuestAgent.Audit/FileRead (PVE 9+) or VM.Monitor (PVE 8): guest agent data
// - Datastore.Audit: improved storage visibility
var extraPrivs []string
@ -252,10 +252,13 @@ func (p *ProxmoxSetup) configurePVEPermissions(ctx context.Context, tokenID stri
extraPrivs = append(extraPrivs, "Sys.Audit")
}
if p.probePVEPrivilege(ctx, "VM.Monitor") {
extraPrivs = append(extraPrivs, "VM.Monitor")
} else if p.probePVEPrivilege(ctx, "VM.GuestAgent.Audit") {
if p.probePVEPrivilege(ctx, "VM.GuestAgent.Audit") {
extraPrivs = append(extraPrivs, "VM.GuestAgent.Audit")
if p.probePVEPrivilege(ctx, "VM.GuestAgent.FileRead") {
extraPrivs = append(extraPrivs, "VM.GuestAgent.FileRead")
}
} else if p.probePVEPrivilege(ctx, "VM.Monitor") {
extraPrivs = append(extraPrivs, "VM.Monitor")
}
if p.probePVEPrivilege(ctx, "Datastore.Audit") {

View file

@ -745,6 +745,51 @@ func TestProxmoxSetup_ProbePVEPrivilege_ReturnsFalseOnAddError(t *testing.T) {
}
}
func TestProxmoxSetup_ConfigurePVEPermissions_PrefersGuestAgentPrivileges(t *testing.T) {
mc := &mockCollector{}
var pulseMonitorPrivs string
var calls []commandCall
tokenID := fmt.Sprintf("%s!%s", proxmoxUserPVE, "pulse-node-1")
mc.commandCombinedOutputFn = func(ctx context.Context, name string, arg ...string) (string, error) {
calls = append(calls, newCommandCall(name, arg...))
if name != "pveum" {
return "", nil
}
if len(arg) >= 5 && arg[0] == "role" && (arg[1] == "modify" || arg[1] == "add") && arg[2] == proxmoxMonitorRole {
for i := 0; i < len(arg)-1; i++ {
if arg[i] == "-privs" {
pulseMonitorPrivs = arg[i+1]
}
}
return "", nil
}
return "", nil
}
p := &ProxmoxSetup{
logger: zerolog.Nop(),
collector: mc,
}
p.configurePVEPermissions(context.Background(), tokenID)
if !strings.Contains(pulseMonitorPrivs, "VM.GuestAgent.Audit") {
t.Fatalf("expected PulseMonitor privileges to include VM.GuestAgent.Audit, got %q", pulseMonitorPrivs)
}
if !strings.Contains(pulseMonitorPrivs, "VM.GuestAgent.FileRead") {
t.Fatalf("expected PulseMonitor privileges to include VM.GuestAgent.FileRead, got %q", pulseMonitorPrivs)
}
if strings.Contains(pulseMonitorPrivs, "VM.Monitor") {
t.Fatalf("did not expect PulseMonitor privileges to include VM.Monitor when guest-agent privileges are available, got %q", pulseMonitorPrivs)
}
if hasCommandCall(calls, "pveum", "role", "add", privProbeRoleName("VM.Monitor"), "-privs", "VM.Monitor") {
t.Fatalf("expected guest-agent privilege support to avoid probing legacy VM.Monitor; calls=%#v", calls)
}
}
func TestProxmoxSetup_ConfigurePVEPermissions_FallsBackToGuestAgentAudit(t *testing.T) {
mc := &mockCollector{}
var pulseMonitorPrivs string

View file

@ -1037,6 +1037,7 @@ type ResourceConvertInput struct {
Uptime *int64
Tags []string
Labels map[string]string
CustomURL string
LastSeenUnix int64
Alerts []ResourceAlertInput
IncidentCount int
@ -1118,6 +1119,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend {
Uptime: input.Uptime,
Tags: append([]string(nil), input.Tags...),
Labels: nil,
CustomURL: input.CustomURL,
LastSeen: input.LastSeenUnix,
IncidentCount: input.IncidentCount,
IncidentCode: input.IncidentCode,

View file

@ -160,6 +160,7 @@ func TestConvertResourceToFrontend(t *testing.T) {
HasNetwork: true,
Tags: []string{"prod"},
Labels: map[string]string{"env": "prod"},
CustomURL: "https://resource.internal",
LastSeenUnix: 12345,
Alerts: []ResourceAlertInput{
{ID: "a1", Type: "cpu", Level: "warn", Message: "high", Value: 90, Threshold: 80, StartTimeUnix: 111},
@ -181,6 +182,9 @@ func TestConvertResourceToFrontend(t *testing.T) {
if len(frontend.Alerts) != 1 || frontend.Alerts[0].ID != "a1" {
t.Fatalf("Alerts = %#v, want 1 alert", frontend.Alerts)
}
if frontend.CustomURL != input.CustomURL {
t.Fatalf("CustomURL = %q, want %q", frontend.CustomURL, input.CustomURL)
}
if frontend.Identity == nil || frontend.Identity.Hostname != identity.Hostname {
t.Fatalf("Identity = %#v, want hostname %q", frontend.Identity, identity.Hostname)
}

View file

@ -44,6 +44,7 @@ type State struct {
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
PVETagColors map[string]string `json:"pveTagColors,omitempty"`
}
var (
@ -2939,6 +2940,7 @@ func NewState() *State {
ConnectionHealth: make(map[string]bool),
ActiveAlerts: make([]Alert, 0),
RecentlyResolved: make([]ResolvedAlert, 0),
PVETagColors: make(map[string]string),
Performance: Performance{}.NormalizeCollections(),
LastUpdate: time.Now(),
}
@ -2947,6 +2949,22 @@ func NewState() *State {
return state
}
// MergeTagColors merges Proxmox tag color entries into the shared state.
func (s *State) MergeTagColors(colors map[string]string) {
if len(colors) == 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.PVETagColors == nil {
s.PVETagColors = make(map[string]string, len(colors))
}
for tag, color := range colors {
s.PVETagColors[strings.ToLower(strings.TrimSpace(tag))] = strings.TrimSpace(color)
}
s.LastUpdate = time.Now()
}
// syncBackupsLocked updates the aggregated backups structure.
func (s *State) syncBackupsLocked() {
s.Backups = Backups{

View file

@ -893,14 +893,15 @@ type ReplicationJobFrontend struct {
// StateFrontend represents the state with frontend-friendly field names
type StateFrontend struct {
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` // Recently resolved alerts
Metrics []Metric `json:"metrics"` // Time-series metrics
Performance Performance `json:"performance"` // Polling/runtime performance
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
Stats Stats `json:"stats"` // Runtime statistics
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` // Recently resolved alerts
Metrics []Metric `json:"metrics"` // Time-series metrics
Performance Performance `json:"performance"` // Polling/runtime performance
ConnectionHealth map[string]bool `json:"connectionHealth"` // Keep as is
Stats Stats `json:"stats"` // Runtime statistics
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting
PVETagColors map[string]string `json:"pveTagColors"` // Tag name -> "#rrggbb" from Proxmox datacenter config
// Unified resources - the new way to access all monitored entities
Resources []ResourceFrontend `json:"resources"`
ConnectedInfrastructure []ConnectedInfrastructureItemFrontend `json:"connectedInfrastructure"`
@ -927,6 +928,9 @@ func (s StateFrontend) NormalizeCollections() StateFrontend {
if s.ConnectionHealth == nil {
s.ConnectionHealth = map[string]bool{}
}
if s.PVETagColors == nil {
s.PVETagColors = map[string]string{}
}
if s.Performance.APICallDuration == nil {
s.Performance.APICallDuration = map[string]float64{}
}
@ -976,10 +980,11 @@ type ResourceFrontend struct {
Uptime *int64 `json:"uptime,omitempty"`
// Metadata
Tags []string `json:"tags"`
Labels map[string]string `json:"labels"`
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
Alerts []ResourceAlertFrontend `json:"alerts"`
Tags []string `json:"tags"`
Labels map[string]string `json:"labels"`
CustomURL string `json:"customUrl,omitempty"`
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
Alerts []ResourceAlertFrontend `json:"alerts"`
IncidentCount int `json:"incidentCount,omitempty"`
IncidentCode string `json:"incidentCode,omitempty"`

View file

@ -34,6 +34,7 @@ type StateSnapshot struct {
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
LastUpdate time.Time `json:"lastUpdate"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
PVETagColors map[string]string `json:"pveTagColors,omitempty"`
}
// EmptyStateSnapshot returns a normalized zero-value snapshot for runtime
@ -149,6 +150,9 @@ func (s *StateSnapshot) NormalizeCollections() {
if s.ConnectionHealth == nil {
s.ConnectionHealth = map[string]bool{}
}
if s.PVETagColors == nil {
s.PVETagColors = map[string]string{}
}
s.Performance = s.Performance.NormalizeCollections()
}
@ -187,6 +191,7 @@ func (s StateSnapshot) Clone() StateSnapshot {
RecentlyResolved: cloneResolvedAlerts(s.RecentlyResolved),
LastUpdate: s.LastUpdate,
TemperatureMonitoringEnabled: s.TemperatureMonitoringEnabled,
PVETagColors: cloneStringMap(s.PVETagColors),
}
for key, healthy := range s.ConnectionHealth {
snapshot.ConnectionHealth[key] = healthy
@ -237,6 +242,7 @@ func (s *State) GetSnapshot() StateSnapshot {
RecentlyResolved: cloneResolvedAlerts(s.RecentlyResolved),
LastUpdate: s.LastUpdate,
TemperatureMonitoringEnabled: s.TemperatureMonitoringEnabled,
PVETagColors: cloneStringMap(s.PVETagColors),
}
snapshot.NormalizeCollections()
@ -584,5 +590,6 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
frontend.Stats = s.Stats
frontend.LastUpdate = s.LastUpdate.Unix() * 1000 // JavaScript timestamp
frontend.TemperatureMonitoringEnabled = s.TemperatureMonitoringEnabled
frontend.PVETagColors = cloneStringMap(s.PVETagColors)
return frontend.NormalizeCollections()
}

View file

@ -32,6 +32,7 @@ func TestStateSnapshotToFrontend_UsesConcreteStateContract(t *testing.T) {
RecentlyResolved: []ResolvedAlert{},
LastUpdate: now,
TemperatureMonitoringEnabled: true,
PVETagColors: map[string]string{"production": "#ff0000"},
}.ToFrontend()
if len(frontend.Metrics) != 1 || frontend.Metrics[0].ID != "node-1" {
@ -43,6 +44,9 @@ func TestStateSnapshotToFrontend_UsesConcreteStateContract(t *testing.T) {
if frontend.Stats.Version != "v6.0.0" {
t.Fatalf("expected concrete stats payload, got %#v", frontend.Stats)
}
if frontend.PVETagColors["production"] != "#ff0000" {
t.Fatalf("expected PVE tag colors to survive frontend conversion, got %#v", frontend.PVETagColors)
}
encoded, err := json.Marshal(frontend)
if err != nil {
@ -69,6 +73,9 @@ func TestStateSnapshotToFrontend_UsesConcreteStateContract(t *testing.T) {
if connectedInfrastructure, ok := payload["connectedInfrastructure"].([]any); !ok || len(connectedInfrastructure) != 0 {
t.Fatalf("expected connectedInfrastructure to serialize as an empty array, got %T (%v)", payload["connectedInfrastructure"], payload["connectedInfrastructure"])
}
if tagColors, ok := payload["pveTagColors"].(map[string]any); !ok || tagColors["production"] != "#ff0000" {
t.Fatalf("expected pveTagColors to serialize as an object, got %T (%v)", payload["pveTagColors"], payload["pveTagColors"])
}
}
func TestEmptyStateFrontend_UsesCanonicalCollectionShapes(t *testing.T) {

View file

@ -670,7 +670,8 @@ func TestProxmoxGuestMemoryFallbackUsesInstanceScopedCachesAndAgentMeminfo(t *te
"func (m *Monitor) getVMAgentMemAvailable(ctx context.Context, client PVEClientInterface, instanceName, node string, vmid int) (uint64, error) {",
"requestCtx, cancel := context.WithTimeout(ctx, vmAgentMemRequestTTL)",
"ttl := vmAgentMemCacheTTL",
"ttl = vmAgentMemNegativeTTL",
"ttl = m.vmAgentMemNegativeCacheTTL(instanceName, node, vmid)",
"vmAgentMemNegativeKnownGuestTTL = 30 * time.Second",
},
"guest_memory_sources.go": {
"func shouldPreferGuestAgentMemAvailable(status *proxmox.VMStatus, memTotal uint64) bool {",
@ -795,9 +796,11 @@ func TestProxmoxGuestMemoryCarryForwardUsesCanonicalSnapshotStability(t *testing
"Notes: snapshotNotes,",
},
"monitor_polling_vm.go": {
`prevSnapshot := m.previousGuestSnapshot(instanceName, "qemu", n.Node, vm.VMID)`,
"memUsed, memorySource, snapshotNotes := stabilizeGuestLowTrustMemory(",
"Notes: snapshotNotes,",
"prevVMByID := prevGuests.vmsByID",
"m.pollNodeVMsWithClusterResourceBuilder(ctx, instanceName, n.Node, vms, client, prevVMByID, vmIDToHostAgent)",
},
"monitor_pve_node_vm_builder.go": {
"return m.collectClusterVMResources(ctx, instanceName, resources, client, prevVMByID, vmIDToHostAgent), templateSubjects",
},
}
@ -828,8 +831,7 @@ func TestProxmoxGuestDiskCarryForwardUsesCanonicalStabilityHelper(t *testing.T)
"return nil, classifyGuestAgentDiskStatusError(err), false",
},
"monitor_polling_vm.go": {
"diskStatusReason = classifyGuestAgentDiskStatusError(err)",
"diskTotal, diskUsed, diskFree, diskUsage, individualDisks, diskStatusReason = stabilizeGuestLowTrustDisk(",
"m.pollNodeVMsWithClusterResourceBuilder(ctx, instanceName, n.Node, vms, client, prevVMByID, vmIDToHostAgent)",
},
}
@ -859,8 +861,8 @@ func TestProxmoxGuestDiskInventoryPrefersCanonicalLinkedHostAgentSource(t *testi
`Msg("QEMU disk: preferring linked Pulse host agent disk inventory")`,
},
"monitor_polling_vm.go": {
"preferLinkedHostAgentDiskInventory(",
`Msg("QEMU disk: preferring linked Pulse host agent disk inventory")`,
"vmIDToHostAgent := prevGuests.hostAgentsByVMID",
"m.pollNodeVMsWithClusterResourceBuilder(ctx, instanceName, n.Node, vms, client, prevVMByID, vmIDToHostAgent)",
},
}
@ -960,8 +962,10 @@ func TestBackupOrphanDetectionUsesCanonicalInventoryReadinessScope(t *testing.T)
"monitor_pve_guest_poll.go": {
"m.updatePVEBackupTemplateSubjectsFromClusterResources(instanceName, resources)",
},
"monitor_pve_node_vm_builder.go": {
`pveBackupTemplateSubjectKey(instanceName, "qemu", node, vm.VMID)`,
},
"monitor_polling_vm.go": {
`pveBackupTemplateSubjectKey(instanceName, "qemu", n.Node, vm.VMID)`,
`m.updatePVEBackupTemplateSubjectsForType(instanceName, "qemu", qemuTemplateSubjects)`,
},
"monitor_polling_containers.go": {
@ -1500,7 +1504,7 @@ func TestProxmoxNodeDiskFallbackPrefersCanonicalSystemStorage(t *testing.T) {
},
"monitor_pve_storage.go": {
"rank, ok := preferredNodeDiskFallbackRank(storage)",
`(modelNodes[i].Disk.Total == 0 || currentDiskSource == "" || currentDiskSource == "nodes-endpoint")`,
`(modelNodes[i].Disk.Total == 0 || currentDiskSource == "")`,
},
}
@ -1520,10 +1524,10 @@ func TestProxmoxNodeDiskFallbackPrefersCanonicalSystemStorage(t *testing.T) {
func TestProxmoxGuestPollersCarryPoolIntoCanonicalModels(t *testing.T) {
requiredSnippets := map[string][]string{
"monitor_pve_guest_builders.go": {"Pool: strings.TrimSpace(res.Pool)"},
"monitor_pve_guest_lxc.go": {"Pool: strings.TrimSpace(res.Pool)"},
"monitor_polling_vm.go": {"Pool: strings.TrimSpace(vm.Pool)"},
"monitor_polling_containers.go": {"Pool: strings.TrimSpace(container.Pool)"},
"monitor_pve_guest_builders.go": {"Pool: strings.TrimSpace(res.Pool)"},
"monitor_pve_guest_lxc.go": {"Pool: strings.TrimSpace(res.Pool)"},
"monitor_pve_node_vm_builder.go": {"Pool: vm.Pool"},
"monitor_polling_containers.go": {"Pool: strings.TrimSpace(container.Pool)"},
}
for file, snippets := range requiredSnippets {
@ -1569,9 +1573,8 @@ func TestProxmoxGuestAgentContinuityUsesCanonicalEvidenceAndRetryPaths(t *testin
},
"monitor_polling_vm.go": {
"prevVMByID := prevGuests.vmsByID",
"guestAgentAvailable := vm.Status == \"running\" &&",
"m.hasRecentGuestMetadataEvidence(instanceName, n.Node, vm.VMID, now)",
"if guestAgentAvailable && diskTotal > 0 {",
"vmIDToHostAgent := prevGuests.hostAgentsByVMID",
"m.pollNodeVMsWithClusterResourceBuilder(ctx, instanceName, n.Node, vms, client, prevVMByID, vmIDToHostAgent)",
},
}

View file

@ -193,6 +193,18 @@ func countCephMonitorDaemons(status *proxmox.CephStatus) int {
if status.MonMap.NumMons > 0 {
return status.MonMap.NumMons
}
if len(status.MonMap.Mons) > 0 {
return len(status.MonMap.Mons)
}
if len(status.MonMap.Monitors) > 0 {
return len(status.MonMap.Monitors)
}
if len(status.MonMap.QuorumNames) > 0 {
return len(status.MonMap.QuorumNames)
}
if len(status.MonMap.Quorum) > 0 {
return len(status.MonMap.Quorum)
}
return countServiceDaemons(status.ServiceMap.Services, "mon")
}

View file

@ -323,20 +323,80 @@ func TestCountServiceDaemons(t *testing.T) {
func TestCountCephMonitorDaemons(t *testing.T) {
t.Parallel()
status := &proxmox.CephStatus{
MonMap: proxmox.CephMonMap{NumMons: 3},
ServiceMap: proxmox.CephServiceMap{
Services: map[string]proxmox.CephServiceDefinition{
"mon": {
Daemons: map[string]proxmox.CephServiceDaemon{
"a": {Host: "node1", Status: "running"},
tests := []struct {
name string
status *proxmox.CephStatus
want int
}{
{
name: "uses explicit mon count",
status: &proxmox.CephStatus{
MonMap: proxmox.CephMonMap{NumMons: 3},
ServiceMap: proxmox.CephServiceMap{
Services: map[string]proxmox.CephServiceDefinition{
"mon": {
Daemons: map[string]proxmox.CephServiceDaemon{
"a": {Host: "node1", Status: "running"},
},
},
},
},
},
want: 3,
},
{
name: "uses mons fallback",
status: &proxmox.CephStatus{
MonMap: proxmox.CephMonMap{
Mons: []proxmox.CephMonitor{{Name: "a"}, {Name: "b"}},
},
},
want: 2,
},
{
name: "uses quorum names fallback",
status: &proxmox.CephStatus{
MonMap: proxmox.CephMonMap{
QuorumNames: []string{"a", "b", "c"},
},
ServiceMap: proxmox.CephServiceMap{
Services: map[string]proxmox.CephServiceDefinition{
"mon": {
Daemons: map[string]proxmox.CephServiceDaemon{
"a": {Host: "node1", Status: "running"},
},
},
},
},
},
want: 3,
},
{
name: "falls back to service map",
status: &proxmox.CephStatus{
ServiceMap: proxmox.CephServiceMap{
Services: map[string]proxmox.CephServiceDefinition{
"mon": {
Daemons: map[string]proxmox.CephServiceDaemon{
"a": {Host: "node1", Status: "running"},
"b": {Host: "node2", Status: "running"},
},
},
},
},
},
want: 2,
},
}
if got := countCephMonitorDaemons(status); got != 3 {
t.Fatalf("countCephMonitorDaemons() = %d, want 3", got)
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := countCephMonitorDaemons(tc.status); got != tc.want {
t.Fatalf("countCephMonitorDaemons() = %d, want %d", got, tc.want)
}
})
}
}

View file

@ -0,0 +1,69 @@
package monitoring
import (
"context"
"time"
)
func (m *Monitor) efficientQEMUWorkerCount(total int) int {
if total <= 0 {
return 0
}
limit := defaultGuestAgentVMMaxConcurrent
if m != nil && m.guestAgentWorkSlots != nil && cap(m.guestAgentWorkSlots) > 0 {
limit = cap(m.guestAgentWorkSlots)
}
if limit <= 0 || limit > total {
return total
}
return limit
}
func (m *Monitor) acquireGuestAgentWorkSlot(ctx context.Context) bool {
if m == nil || m.guestAgentWorkSlots == nil {
return true
}
select {
case m.guestAgentWorkSlots <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (m *Monitor) releaseGuestAgentWorkSlot() {
if m == nil || m.guestAgentWorkSlots == nil {
return
}
select {
case <-m.guestAgentWorkSlots:
default:
}
}
func (m *Monitor) guestAgentVMWorkContext(ctx context.Context) (context.Context, context.CancelFunc) {
if m == nil || m.guestAgentVMBudget <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, m.guestAgentVMBudget)
}
func (m *Monitor) runGuestAgentVMWork(ctx context.Context, work func(context.Context)) bool {
if !m.acquireGuestAgentWorkSlot(ctx) {
return false
}
defer m.releaseGuestAgentWorkSlot()
workCtx, cancel := m.guestAgentVMWorkContext(ctx)
defer cancel()
work(workCtx)
return true
}
func guestAgentVMBudgetOrZero(m *Monitor) time.Duration {
if m == nil {
return 0
}
return m.guestAgentVMBudget
}

View file

@ -3,14 +3,16 @@ package monitoring
import (
"context"
"fmt"
"strings"
"time"
)
const (
vmAgentMemCacheTTL = 60 * time.Second // Cache guest-agent /proc/meminfo reads.
vmAgentMemRequestTTL = 3 * time.Second // Bound guest-agent file-read latency.
vmAgentMemNegativeTTL = 5 * time.Minute // Back off on unsupported or failing guests.
vmAgentMemCleanupMaxAge = 2 * vmAgentMemNegativeTTL
vmAgentMemCacheTTL = 60 * time.Second // Cache guest-agent /proc/meminfo reads.
vmAgentMemRequestTTL = 3 * time.Second // Bound guest-agent file-read latency.
vmAgentMemNegativeTTL = 5 * time.Minute // Back off on unsupported or failing guests.
vmAgentMemNegativeKnownGuestTTL = 30 * time.Second // Retry sooner for guests we know are non-Windows.
vmAgentMemCleanupMaxAge = 2 * vmAgentMemNegativeTTL
)
type agentMemCacheEntry struct {
@ -43,7 +45,7 @@ func (m *Monitor) getVMAgentMemAvailable(ctx context.Context, client PVEClientIn
if entry, ok := m.vmAgentMemCache[cacheKey]; ok {
ttl := vmAgentMemCacheTTL
if entry.negative {
ttl = vmAgentMemNegativeTTL
ttl = m.vmAgentMemNegativeCacheTTL(instanceName, node, vmid)
}
if now.Sub(entry.fetchedAt) < ttl {
m.rrdCacheMu.RUnlock()
@ -75,3 +77,27 @@ func (m *Monitor) getVMAgentMemAvailable(ctx context.Context, client PVEClientIn
m.vmAgentMemCache[cacheKey] = agentMemCacheEntry{available: available, fetchedAt: now}
return available, nil
}
func (m *Monitor) vmAgentMemNegativeCacheTTL(instanceName, node string, vmid int) time.Duration {
if m == nil {
return vmAgentMemNegativeTTL
}
key := guestMetadataCacheKey(instanceName, node, vmid)
m.guestMetadataMu.RLock()
entry, ok := m.guestMetadataCache[key]
m.guestMetadataMu.RUnlock()
if !ok {
return vmAgentMemNegativeTTL
}
osName := strings.ToLower(strings.TrimSpace(entry.osName))
osVersion := strings.ToLower(strings.TrimSpace(entry.osVersion))
if osName == "" && osVersion == "" {
return vmAgentMemNegativeTTL
}
if strings.Contains(osName, "windows") || strings.Contains(osVersion, "windows") {
return vmAgentMemNegativeTTL
}
return vmAgentMemNegativeKnownGuestTTL
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
@ -110,6 +111,74 @@ func TestGetVMAgentMemAvailableCachesResults(t *testing.T) {
})
}
func TestGetVMAgentMemAvailableRetriesKnownNonWindowsGuestSoonerAfterNegativeCache(t *testing.T) {
t.Parallel()
client := &guestMemoryAgentTestClient{
stubPVEClient: &stubPVEClient{},
memAvailable: 2 * 1024 * 1024 * 1024,
}
mon := &Monitor{
guestMetadataCache: map[string]guestMetadataCacheEntry{
guestMetadataCacheKey("pve1", "node1", 100): {
osName: "Ubuntu",
fetchedAt: time.Now(),
},
},
vmAgentMemCache: map[string]agentMemCacheEntry{
guestMemoryCacheKey("pve1", "node1", 100): {
negative: true,
fetchedAt: time.Now().Add(-vmAgentMemNegativeKnownGuestTTL - time.Second),
},
},
}
available, err := mon.getVMAgentMemAvailable(context.Background(), client, "pve1", "node1", 100)
if err != nil {
t.Fatalf("getVMAgentMemAvailable() error = %v", err)
}
if available != 2*1024*1024*1024 {
t.Fatalf("getVMAgentMemAvailable() available = %d", available)
}
if client.memCalls != 1 {
t.Fatalf("expected guest-agent meminfo retry, got %d calls", client.memCalls)
}
}
func TestGetVMAgentMemAvailableKeepsLongNegativeCacheForWindowsGuest(t *testing.T) {
t.Parallel()
client := &guestMemoryAgentTestClient{
stubPVEClient: &stubPVEClient{},
memAvailable: 2 * 1024 * 1024 * 1024,
}
mon := &Monitor{
guestMetadataCache: map[string]guestMetadataCacheEntry{
guestMetadataCacheKey("pve1", "node1", 100): {
osName: "Microsoft Windows",
fetchedAt: time.Now(),
},
},
vmAgentMemCache: map[string]agentMemCacheEntry{
guestMemoryCacheKey("pve1", "node1", 100): {
negative: true,
fetchedAt: time.Now().Add(-vmAgentMemNegativeKnownGuestTTL - time.Second),
},
},
}
available, err := mon.getVMAgentMemAvailable(context.Background(), client, "pve1", "node1", 100)
if err == nil {
t.Fatal("expected cached negative result for Windows guest")
}
if available != 0 {
t.Fatalf("expected no memavailable result, got %d", available)
}
if client.memCalls != 0 {
t.Fatalf("expected Windows guest negative cache to suppress retry, got %d calls", client.memCalls)
}
}
func TestResolveGuestStatusMemoryUsesGuestAgentMeminfoFallback(t *testing.T) {
t.Parallel()

View file

@ -23,12 +23,13 @@ const (
// Guest agent timeout defaults (configurable via environment variables)
// Increased from 3-5s to 10-15s to handle high-load environments better (refs #592)
defaultGuestAgentFSInfoTimeout = 15 * time.Second // GUEST_AGENT_FSINFO_TIMEOUT
defaultGuestAgentNetworkTimeout = 10 * time.Second // GUEST_AGENT_NETWORK_TIMEOUT
defaultGuestAgentOSInfoTimeout = 10 * time.Second // GUEST_AGENT_OSINFO_TIMEOUT
defaultGuestAgentVersionTimeout = 10 * time.Second // GUEST_AGENT_VERSION_TIMEOUT
defaultGuestAgentRetries = 1 // GUEST_AGENT_RETRIES (0 = no retry, 1 = one retry)
defaultGuestAgentRetryDelay = 500 * time.Millisecond
defaultGuestAgentFSInfoTimeout = 15 * time.Second // GUEST_AGENT_FSINFO_TIMEOUT
defaultGuestAgentNetworkTimeout = 10 * time.Second // GUEST_AGENT_NETWORK_TIMEOUT
defaultGuestAgentOSInfoTimeout = 10 * time.Second // GUEST_AGENT_OSINFO_TIMEOUT
defaultGuestAgentVersionTimeout = 10 * time.Second // GUEST_AGENT_VERSION_TIMEOUT
defaultGuestAgentRetries = 1 // GUEST_AGENT_RETRIES (0 = no retry, 1 = one retry)
defaultGuestAgentRetryDelay = 500 * time.Millisecond
defaultGuestAgentVMMaxConcurrent = 8 // GUEST_AGENT_VM_MAX_CONCURRENT
// Skip OS info calls after this many consecutive failures to avoid triggering buggy guest agents (refs #692)
guestAgentOSInfoFailureThreshold = 3

View file

@ -27,9 +27,15 @@ func shouldSkipTemperatureSSHCollection(hostAgentTemp *models.Temperature) bool
return false
}
// Host-agent CPU or NVMe readings are preferred, but SSH should still augment
// the node with SMART disk temperatures until the agent has reported SMART.
return hostAgentTemp.HasSMART
if !hostAgentTemp.Available || !isHostAgentTemperatureRecent(hostAgentTemp.LastUpdate) {
return false
}
if hostAgentTemp.HasSMART {
return hostAgentTemp.HasSMART
}
return hostAgentTemp.HasCPU || hostAgentTemp.HasGPU || hostAgentTemp.HasNVMe
}
// getHostAgentTemperatureByID looks for a matching host agent by node ID first,

View file

@ -166,16 +166,53 @@ func TestShouldSkipTemperatureSSHCollection(t *testing.T) {
CPUPackage: 55,
}
if shouldSkipTemperatureSSHCollection(host) {
t.Fatal("expected CPU-only host agent temp not to skip SSH collection")
t.Fatal("expected CPU-only host agent temp without a recent timestamp not to skip SSH collection")
}
})
t.Run("recent cpu only host agent temp skips", func(t *testing.T) {
host := &models.Temperature{
Available: true,
HasCPU: true,
CPUPackage: 55,
LastUpdate: time.Now(),
}
if !shouldSkipTemperatureSSHCollection(host) {
t.Fatal("expected recent CPU-only host agent temp to skip SSH collection")
}
})
t.Run("stale cpu only host agent temp does not skip", func(t *testing.T) {
host := &models.Temperature{
Available: true,
HasCPU: true,
CPUPackage: 55,
LastUpdate: time.Now().Add(-3 * time.Minute),
}
if shouldSkipTemperatureSSHCollection(host) {
t.Fatal("expected stale CPU-only host agent temp not to skip SSH collection")
}
})
t.Run("unavailable host agent temp does not skip", func(t *testing.T) {
host := &models.Temperature{
Available: false,
HasCPU: true,
CPUPackage: 55,
LastUpdate: time.Now(),
}
if shouldSkipTemperatureSSHCollection(host) {
t.Fatal("expected unavailable host agent temp not to skip SSH collection")
}
})
t.Run("host agent smart data skips", func(t *testing.T) {
host := &models.Temperature{
Available: true,
HasCPU: true,
HasSMART: true,
SMART: []models.DiskTemp{{Device: "/dev/sda", Temperature: 35}},
Available: true,
HasCPU: true,
HasSMART: true,
SMART: []models.DiskTemp{{Device: "/dev/sda", Temperature: 35}},
LastUpdate: time.Now(),
}
if !shouldSkipTemperatureSSHCollection(host) {
t.Fatal("expected SMART-capable host agent temp to skip SSH collection")

View file

@ -151,19 +151,19 @@ func TestMockVMPollingDefersMemoryHistoryToCanonicalSampler(t *testing.T) {
func TestPollVMPrefersCanonicalLinkedHostAgentDiskInventory(t *testing.T) {
t.Parallel()
data, err := os.ReadFile("monitor_polling_vm.go")
data, err := os.ReadFile("monitor_pve_guest_builders.go")
if err != nil {
t.Fatalf("failed to read monitor_polling_vm.go: %v", err)
t.Fatalf("failed to read monitor_pve_guest_builders.go: %v", err)
}
source := string(data)
for _, snippet := range []string{
"preferLinkedHostAgentDiskInventory(",
`Bool("guestAgentDiskAvailable", diskFromAgent)`,
`Bool("guestAgentDiskAvailable", state.diskFromAgent)`,
`Msg("QEMU disk: preferring linked Pulse host agent disk inventory")`,
} {
if !strings.Contains(source, snippet) {
t.Fatalf("monitor_polling_vm.go must contain %q", snippet)
t.Fatalf("monitor_pve_guest_builders.go must contain %q", snippet)
}
}
}

View file

@ -993,12 +993,14 @@ type Monitor struct {
guestMetadataRefreshJitter time.Duration
guestMetadataRetryBackoff time.Duration
guestMetadataHoldDuration time.Duration
guestAgentWorkSlots chan struct{}
// Configurable guest agent timeouts (refs #592)
guestAgentFSInfoTimeout time.Duration
guestAgentNetworkTimeout time.Duration
guestAgentOSInfoTimeout time.Duration
guestAgentVersionTimeout time.Duration
guestAgentRetries int
guestAgentVMBudget time.Duration
executor PollExecutor
breakerBaseRetry time.Duration
breakerMaxDelay time.Duration
@ -1419,6 +1421,8 @@ func New(cfg *config.Config) (*Monitor, error) {
guestAgentOSInfoTimeout := parsePositiveDurationEnv("GUEST_AGENT_OSINFO_TIMEOUT", defaultGuestAgentOSInfoTimeout)
guestAgentVersionTimeout := parsePositiveDurationEnv("GUEST_AGENT_VERSION_TIMEOUT", defaultGuestAgentVersionTimeout)
guestAgentRetries := parseNonNegativeIntEnv("GUEST_AGENT_RETRIES", defaultGuestAgentRetries)
guestAgentVMBudget := parseDurationEnv("GUEST_AGENT_VM_BUDGET", 0)
guestAgentVMMaxConcurrent := parseNonNegativeIntEnv("GUEST_AGENT_VM_MAX_CONCURRENT", defaultGuestAgentVMMaxConcurrent)
// Initialize persistent metrics store (SQLite) with configurable retention
var metricsStore *metrics.Store
@ -1547,6 +1551,7 @@ func New(cfg *config.Config) (*Monitor, error) {
guestAgentOSInfoTimeout: guestAgentOSInfoTimeout,
guestAgentVersionTimeout: guestAgentVersionTimeout,
guestAgentRetries: guestAgentRetries,
guestAgentVMBudget: guestAgentVMBudget,
instanceInfoCache: make(map[string]*instanceInfo),
pollStatusMap: make(map[string]*pollStatus),
dlqInsightMap: make(map[string]*dlqInsight),
@ -1601,6 +1606,9 @@ func New(cfg *config.Config) (*Monitor, error) {
if concurrency > 0 {
m.guestMetadataSlots = make(chan struct{}, concurrency)
}
if guestAgentVMMaxConcurrent > 0 {
m.guestAgentWorkSlots = make(chan struct{}, guestAgentVMMaxConcurrent)
}
if appriseConfig, err := m.configPersist.LoadAppriseConfig(); err == nil {
m.notificationMgr.SetAppriseConfig(*appriseConfig)
@ -3751,6 +3759,7 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS
unifiedView := m.currentUnifiedStateView()
metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)
broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)
broadcastResources = m.applyDockerMetadataToUnifiedResources(broadcastResources)
frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)
frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)
if !unifiedView.freshness.IsZero() {
@ -4926,11 +4935,35 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
store := m.resourceStore
m.mu.RUnlock()
return convertResourcesForBroadcast(
m.getUnifiedResourcesForBroadcast(),
m.applyDockerMetadataToUnifiedResources(m.getUnifiedResourcesForBroadcast()),
broadcastMetricsTargetResolver(store),
)
}
func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
if len(resources) == 0 || m == nil || m.dockerMetadataStore == nil {
return resources
}
out := make([]unifiedresources.Resource, len(resources))
copy(out, resources)
for i := range out {
resource := &out[i]
if resource.Type != unifiedresources.ResourceTypeAppContainer || strings.TrimSpace(resource.CustomURL) != "" || resource.Docker == nil {
continue
}
hostID := strings.TrimSpace(resource.Docker.HostSourceID)
containerID := strings.TrimSpace(resource.Docker.ContainerID)
if hostID == "" || containerID == "" {
continue
}
if meta := m.dockerMetadataStore.Get(fmt.Sprintf("%s:container:%s", hostID, containerID)); meta != nil {
resource.CustomURL = strings.TrimSpace(meta.CustomURL)
}
}
return out
}
// convertResourcesForBroadcast converts unified resources into the frontend payload shape.
func convertResourcesForBroadcast(
allResources []unifiedresources.Resource,
@ -5066,6 +5099,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re
Uptime: monitorUptime(resource),
Tags: append([]string(nil), resource.Tags...),
Labels: monitorLabels(resource),
CustomURL: strings.TrimSpace(resource.CustomURL),
LastSeenUnix: monitorLastSeenUnix(resource.LastSeen),
IncidentCount: resource.IncidentCount,
IncidentCode: resource.IncidentCode,

View file

@ -9,6 +9,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
)
@ -436,6 +437,60 @@ func TestApplyDockerReportSkipsMetadataMigrationForAmbiguousContainerNames(t *te
}
}
func TestApplyDockerMetadataToUnifiedResourcesAddsContainerCustomURL(t *testing.T) {
monitor := newTestMonitor(t)
if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{
CustomURL: " https://app.internal ",
}); err != nil {
t.Fatalf("seed docker metadata: %v", err)
}
resources := []unifiedresources.Resource{
{
ID: "resource:app-container:app",
Type: unifiedresources.ResourceTypeAppContainer,
Docker: &unifiedresources.DockerData{
HostSourceID: " docker-host-1 ",
ContainerID: " container-new ",
},
},
}
got := monitor.applyDockerMetadataToUnifiedResources(resources)
if got[0].CustomURL != "https://app.internal" {
t.Fatalf("CustomURL = %q, want migrated Docker metadata URL", got[0].CustomURL)
}
if resources[0].CustomURL != "" {
t.Fatalf("applyDockerMetadataToUnifiedResources mutated input CustomURL to %q", resources[0].CustomURL)
}
}
func TestApplyDockerMetadataToUnifiedResourcesKeepsResourceCustomURL(t *testing.T) {
monitor := newTestMonitor(t)
if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{
CustomURL: "https://metadata.internal",
}); err != nil {
t.Fatalf("seed docker metadata: %v", err)
}
resources := []unifiedresources.Resource{
{
ID: "resource:app-container:app",
Type: unifiedresources.ResourceTypeAppContainer,
CustomURL: "https://resource.internal",
Docker: &unifiedresources.DockerData{
HostSourceID: "docker-host-1",
ContainerID: "container-new",
},
},
}
got := monitor.applyDockerMetadataToUnifiedResources(resources)
if got[0].CustomURL != "https://resource.internal" {
t.Fatalf("CustomURL = %q, want existing resource URL to win", got[0].CustomURL)
}
}
func TestApplyDockerReportComputesContainerNetworkAndDiskRates(t *testing.T) {
monitor := newTestMonitor(t)
baseTime := time.Now().UTC()

View file

@ -497,6 +497,22 @@ type mockPVEClientExtra struct {
agentVersion string
}
type delayedGuestAgentClient struct {
mockPVEClientExtra
fsDelay time.Duration
}
func (m *delayedGuestAgentClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) {
if m.fsDelay > 0 {
select {
case <-time.After(m.fsDelay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return m.mockPVEClientExtra.GetVMFSInfo(ctx, node, vmid)
}
func (m *mockPVEClientExtra) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) {
return m.resources, nil
}
@ -649,6 +665,65 @@ func TestMonitor_PollVMsAndContainersEfficient_Extra(t *testing.T) {
}
}
func TestPollVMsWithNodesCompletesDiskQueriesWithinPollBudget(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
m := &Monitor{
state: models.NewState(),
guestAgentFSInfoTimeout: 250 * time.Millisecond,
guestAgentRetries: 0,
guestAgentNetworkTimeout: 250 * time.Millisecond,
guestAgentOSInfoTimeout: 250 * time.Millisecond,
guestAgentVersionTimeout: 250 * time.Millisecond,
guestMetadataCache: make(map[string]guestMetadataCacheEntry),
guestMetadataLimiter: make(map[string]time.Time),
rateTracker: NewRateTracker(),
metricsHistory: NewMetricsHistory(100, time.Hour),
alertManager: alerts.NewManager(),
stalenessTracker: NewStalenessTracker(nil),
nodeRRDMemCache: make(map[string]rrdMemCacheEntry),
vmRRDMemCache: make(map[string]rrdMemCacheEntry),
vmAgentMemCache: make(map[string]agentMemCacheEntry),
guestAgentWorkSlots: make(chan struct{}, 3),
}
defer m.alertManager.Stop()
client := &delayedGuestAgentClient{
mockPVEClientExtra: mockPVEClientExtra{
vms: []proxmox.VM{
{VMID: 100, Name: "vm100", Node: "node1", Status: "running", CPUs: 2, MaxMem: 8 * 1024, Mem: 4 * 1024, MaxDisk: 100 * 1024 * 1024 * 1024},
{VMID: 101, Name: "vm101", Node: "node1", Status: "running", CPUs: 2, MaxMem: 8 * 1024, Mem: 4 * 1024, MaxDisk: 100 * 1024 * 1024 * 1024},
{VMID: 102, Name: "vm102", Node: "node1", Status: "running", CPUs: 2, MaxMem: 8 * 1024, Mem: 4 * 1024, MaxDisk: 100 * 1024 * 1024 * 1024},
},
vmStatus: &proxmox.VMStatus{
Status: "running",
Agent: proxmox.VMAgentField{Value: 1},
MaxMem: 8 * 1024,
Mem: 4 * 1024,
},
fsInfo: []proxmox.VMFileSystem{
{Mountpoint: "/", TotalBytes: 100 * 1024 * 1024 * 1024, UsedBytes: 40 * 1024 * 1024 * 1024, Type: "ext4", Disk: "/dev/vda"},
},
},
fsDelay: 60 * time.Millisecond,
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
defer cancel()
m.pollVMsWithNodes(ctx, "pve1", "", false, client, []proxmox.Node{{Node: "node1", Status: "online"}}, map[string]string{"node1": "online"})
state := m.GetState()
if len(state.VMs) != 3 {
t.Fatalf("expected all VM disk queries to complete inside poll budget, got %d VMs", len(state.VMs))
}
for _, vm := range state.VMs {
if vm.Disk.Usage <= 0 {
t.Fatalf("expected VM %d to keep guest-agent disk usage, got %+v", vm.VMID, vm.Disk)
}
}
}
func TestMonitor_EnrichContainerMetadata_Extra(t *testing.T) {
m := &Monitor{
state: models.NewState(),

View file

@ -2,22 +2,18 @@ package monitoring
import (
"context"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring/errors"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clusterName string, isCluster bool, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
startTime := time.Now()
// Channel to collect VM results from each node
type nodeResult struct {
node string
vms []models.VM
@ -28,7 +24,6 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
resultChan := make(chan nodeResult, len(nodes))
var wg sync.WaitGroup
// Count online nodes for logging
onlineNodes := 0
for _, node := range nodes {
if nodeEffectiveStatus[node.Node] == "online" {
@ -36,8 +31,6 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
}
}
// Capture the previous guest context once per poll cycle so fallback behavior
// is based on a consistent pre-poll snapshot.
prevGuests := m.previousGuestContextForInstance(instanceName)
prevVMByID := prevGuests.vmsByID
vmIDToHostAgent := prevGuests.hostAgentsByVMID
@ -48,9 +41,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
Int("onlineNodes", onlineNodes).
Msg("Starting parallel VM polling")
// Launch a goroutine for each online node
for _, node := range nodes {
// Skip offline nodes
if nodeEffectiveStatus[node.Node] != "online" {
log.Debug().
Str("node", node.Node).
@ -64,8 +55,6 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
defer wg.Done()
nodeStart := time.Now()
// Fetch VMs for this node
vms, err := client.GetVMs(ctx, n.Node)
if err != nil {
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_vms", instanceName, err).WithNode(n.Node)
@ -74,536 +63,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
return
}
var nodeVMs []models.VM
nodeTemplateSubjects := make(map[string]struct{})
// Process each VM
for _, vm := range vms {
// Skip templates
if vm.Template == 1 {
if key := pveBackupTemplateSubjectKey(instanceName, "qemu", n.Node, vm.VMID); key != "" {
nodeTemplateSubjects[key] = struct{}{}
}
continue
}
// Parse tags
var tags []string
if vm.Tags != "" {
tags = strings.Split(vm.Tags, ";")
}
// Generate canonical guest ID: instance:node:vmid
guestID := makeGuestID(instanceName, n.Node, vm.VMID)
var prevVM *models.VM
if prev, ok := prevVMByID[guestID]; ok {
prevVM = &prev
}
guestRaw := VMMemoryRaw{
ListingMem: vm.Mem,
ListingMaxMem: vm.MaxMem,
Agent: vm.Agent,
}
memorySource := "cluster-resources"
// Initialize metrics from VM listing (may be 0 for disk I/O)
diskReadBytes := int64(vm.DiskRead)
diskWriteBytes := int64(vm.DiskWrite)
networkInBytes := int64(vm.NetIn)
networkOutBytes := int64(vm.NetOut)
// Get memory info for running VMs (and agent status for disk)
memUsed := uint64(0)
memTotal := vm.MaxMem
var vmStatus *proxmox.VMStatus
var ipAddresses []string
var networkInterfaces []models.GuestNetworkInterface
var osName, osVersion, guestAgentVersion string
if vm.Status == "running" {
// Try to get detailed VM status (but don't wait too long)
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
if status, err := client.GetVMStatus(statusCtx, n.Node, vm.VMID); err == nil {
vmStatus = status
guestRaw.StatusMaxMem = status.MaxMem
guestRaw.StatusMem = status.Mem
guestRaw.StatusFreeMem = status.FreeMem
guestRaw.Balloon = status.Balloon
guestRaw.BalloonMin = status.BalloonMin
guestRaw.Agent = status.Agent.Value
memTotal, memUsed, memorySource = m.resolveGuestStatusMemory(
ctx,
client,
instanceName,
vm.Name,
n.Node,
vm.VMID,
guestID,
status,
vmIDToHostAgent,
memTotal,
memorySource,
&guestRaw,
)
// Use actual disk I/O values from detailed status
diskReadBytes = int64(vmStatus.DiskRead)
diskWriteBytes = int64(vmStatus.DiskWrite)
networkInBytes = int64(vmStatus.NetIn)
networkOutBytes = int64(vmStatus.NetOut)
}
cancel()
}
if vm.Status != "running" {
memorySource = "powered-off"
} else if vmStatus == nil {
memorySource = "status-unavailable"
}
now := time.Now()
guestAgentAvailable := vm.Status == "running" &&
(shouldQueryGuestAgent(vmStatus, prevVM, now) ||
m.hasRecentGuestMetadataEvidence(instanceName, n.Node, vm.VMID, now))
if guestAgentAvailable {
guestIPs, guestIfaces, guestOSName, guestOSVersion, agentVersion := m.fetchGuestAgentMetadata(ctx, client, instanceName, n.Node, vm.Name, vm.VMID, vmStatus, vmStatus == nil)
if len(guestIPs) > 0 {
ipAddresses = guestIPs
}
if len(guestIfaces) > 0 {
networkInterfaces = guestIfaces
}
if guestOSName != "" {
osName = guestOSName
}
if guestOSVersion != "" {
osVersion = guestOSVersion
}
if agentVersion != "" {
guestAgentVersion = agentVersion
}
}
// Calculate I/O rates after we have the actual values
sampleTime := time.Now()
currentMetrics := IOMetrics{
DiskRead: diskReadBytes,
DiskWrite: diskWriteBytes,
NetworkIn: networkInBytes,
NetworkOut: networkOutBytes,
Timestamp: sampleTime,
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Debug log disk I/O rates
if diskReadRate > 0 || diskWriteRate > 0 {
log.Debug().
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Float64("diskReadRate", diskReadRate).
Float64("diskWriteRate", diskWriteRate).
Int64("diskReadBytes", diskReadBytes).
Int64("diskWriteBytes", diskWriteBytes).
Msg("VM disk I/O rates calculated")
}
// Set CPU to 0 for non-running VMs
cpuUsage := safeFloat(vm.CPU)
if vm.Status != "running" {
cpuUsage = 0
}
// Calculate disk usage - start with allocated disk size
// NOTE: The Proxmox cluster/resources API always returns 0 for VM disk usage
// We must query the guest agent to get actual disk usage
diskUsed := vm.Disk
diskTotal := vm.MaxDisk
diskFree := diskTotal - diskUsed
diskUsage := safePercentage(float64(diskUsed), float64(diskTotal))
diskFromAgent := false
diskStatusReason := ""
var individualDisks []models.Disk
// For stopped VMs, we can't get guest agent data
if vm.Status != "running" {
// Show allocated disk size for stopped VMs
if diskTotal > 0 {
diskUsage = -1 // Indicates "allocated size only"
diskStatusReason = "vm-stopped"
}
}
// For running VMs, ALWAYS try to get filesystem info from guest agent
// The cluster/resources endpoint always returns 0 for disk usage
if guestAgentAvailable && diskTotal > 0 {
agentValue := 0
if vmStatus != nil {
agentValue = vmStatus.Agent.Value
}
// Log the initial state
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Int("agent", agentValue).
Uint64("diskUsed", diskUsed).
Uint64("diskTotal", diskTotal).
Msg("VM has 0 disk usage, checking guest agent")
}
// Check if agent is enabled
if vmStatus != nil && !vmStatus.Agent.IsAvailable() {
if vmStatus.Agent.IsEnabled() {
diskStatusReason = "agent-not-running"
} else {
diskStatusReason = "agent-disabled"
}
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Bool("agentEnabled", vmStatus.Agent.IsEnabled()).
Msg("Guest agent is not currently queryable")
}
} else {
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent enabled, fetching filesystem info")
}
// Filesystem info with configurable timeout and retry (refs #592)
fsInfoRaw, err := m.retryGuestAgentCall(ctx, m.guestAgentFSInfoTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) {
return client.GetVMFSInfo(ctx, n.Node, vm.VMID)
})
var fsInfo []proxmox.VMFileSystem
if err == nil {
if fs, ok := fsInfoRaw.([]proxmox.VMFileSystem); ok {
fsInfo = fs
}
}
if err != nil {
// Handle errors
errStr := err.Error()
log.Warn().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Str("error", errStr).
Msg("Failed to get VM filesystem info from guest agent")
diskStatusReason = classifyGuestAgentDiskStatusError(err)
switch diskStatusReason {
case "agent-not-running":
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent enabled in VM config but not running inside guest OS. Install and start qemu-guest-agent in the VM")
case "permission-denied":
log.Warn().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Permission denied accessing guest agent. Verify Pulse user has VM.Monitor (PVE 8) or VM.Audit+VM.GuestAgent.Audit (PVE 9) permissions")
}
} else if len(fsInfo) == 0 {
diskStatusReason = "no-filesystems"
log.Warn().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent returned empty filesystem list")
} else {
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Int("filesystems", len(fsInfo)).
Msg("Got filesystem info from guest agent")
// Aggregate disk usage from all filesystems
// Fix for #425: Track seen devices to avoid counting duplicates
var totalBytes, usedBytes uint64
seenDevices := make(map[string]bool)
for _, fs := range fsInfo {
// Log each filesystem for debugging
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("type", fs.Type).
Str("disk", fs.Disk).
Uint64("total", fs.TotalBytes).
Uint64("used", fs.UsedBytes).
Msg("Processing filesystem from guest agent")
// Skip special filesystems and Windows System Reserved
// For Windows, mountpoints are like "C:\\" or "D:\\" - don't skip those
isWindowsDrive := len(fs.Mountpoint) >= 2 && fs.Mountpoint[1] == ':' && strings.Contains(fs.Mountpoint, "\\")
if !isWindowsDrive {
if reason, skip := readOnlyFilesystemReason(fs.Type, fs.TotalBytes, fs.UsedBytes); skip {
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("type", fs.Type).
Str("skipReason", reason).
Uint64("total", fs.TotalBytes).
Uint64("used", fs.UsedBytes).
Msg("Skipping read-only filesystem from guest agent")
continue
}
if fs.Type == "tmpfs" || fs.Type == "devtmpfs" ||
strings.HasPrefix(fs.Mountpoint, "/dev") ||
strings.HasPrefix(fs.Mountpoint, "/proc") ||
strings.HasPrefix(fs.Mountpoint, "/sys") ||
strings.HasPrefix(fs.Mountpoint, "/run") ||
fs.Mountpoint == "/boot/efi" ||
fs.Mountpoint == "System Reserved" ||
strings.Contains(fs.Mountpoint, "System Reserved") ||
strings.HasPrefix(fs.Mountpoint, "/snap") { // Skip snap mounts
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("type", fs.Type).
Msg("Skipping special filesystem")
continue
}
}
// Skip if we've already seen this device (duplicate mount point)
if fs.Disk != "" && seenDevices[fs.Disk] {
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("disk", fs.Disk).
Msg("Skipping duplicate mount of same device")
continue
}
// Only count real filesystems with valid data
if fs.TotalBytes > 0 {
// Mark this device as seen
if fs.Disk != "" {
seenDevices[fs.Disk] = true
}
totalBytes += fs.TotalBytes
usedBytes += fs.UsedBytes
individualDisks = append(individualDisks, models.Disk{
Total: int64(fs.TotalBytes),
Used: int64(fs.UsedBytes),
Free: int64(fs.TotalBytes - fs.UsedBytes),
Usage: safePercentage(float64(fs.UsedBytes), float64(fs.TotalBytes)),
Mountpoint: fs.Mountpoint,
Type: fs.Type,
Device: fs.Disk,
})
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("disk", fs.Disk).
Uint64("added_total", fs.TotalBytes).
Uint64("added_used", fs.UsedBytes).
Msg("Adding filesystem to total")
} else {
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Msg("Skipping filesystem with 0 total bytes")
}
}
// If we got valid data from guest agent, use it
if totalBytes > 0 {
diskTotal = totalBytes
diskUsed = usedBytes
diskFree = totalBytes - usedBytes
diskUsage = safePercentage(float64(usedBytes), float64(totalBytes))
diskFromAgent = true
diskStatusReason = "" // Clear reason on success
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Uint64("totalBytes", totalBytes).
Uint64("usedBytes", usedBytes).
Float64("usage", diskUsage).
Msg("✓ Successfully retrieved disk usage from guest agent")
} else {
// Only special filesystems found - show allocated disk size instead
diskStatusReason = "special-filesystems-only"
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
}
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("filesystems_found", len(fsInfo)).
Msg("Guest agent provided filesystem info but no usable filesystems found (all were special mounts)")
}
}
}
} else if vm.Status == "running" && diskTotal > 0 {
// Running VM but no guest-agent evidence - show allocated disk
diskUsage = -1
if vmStatus == nil {
diskStatusReason = "no-status"
} else {
diskStatusReason = "no-agent"
}
}
if vm.Status == "running" {
var preferred bool
diskTotal, diskUsed, diskFree, diskUsage, individualDisks, diskStatusReason, preferred = preferLinkedHostAgentDiskInventory(
guestID,
vmIDToHostAgent,
diskTotal,
diskUsed,
diskFree,
diskUsage,
individualDisks,
diskStatusReason,
)
if preferred {
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Str("node", n.Node).
Int("vmid", vm.VMID).
Bool("guestAgentDiskAvailable", diskFromAgent).
Float64("usage", diskUsage).
Msg("QEMU disk: preferring linked Pulse host agent disk inventory")
}
}
diskTotal, diskUsed, diskFree, diskUsage, individualDisks, diskStatusReason = stabilizeGuestLowTrustDisk(
prevVM,
vm.Status,
diskTotal,
diskUsed,
diskFree,
diskUsage,
individualDisks,
diskStatusReason,
diskFromAgent,
sampleTime,
)
prevSnapshot := m.previousGuestSnapshot(instanceName, "qemu", n.Node, vm.VMID)
memUsed, memorySource, snapshotNotes := stabilizeGuestLowTrustMemory(
prevSnapshot,
vm.Status,
memorySource,
memTotal,
memUsed,
sampleTime,
guestAgentSignalsHealthy(
vmStatus,
diskFromAgent,
ipAddresses,
networkInterfaces,
osName,
osVersion,
guestAgentVersion,
),
)
memTotalBytes := clampToInt64(memTotal)
memUsedBytes := clampToInt64(memUsed)
if memTotalBytes > 0 && memUsedBytes > memTotalBytes {
memUsedBytes = memTotalBytes
}
memFreeBytes := memTotalBytes - memUsedBytes
if memFreeBytes < 0 {
memFreeBytes = 0
}
memory := models.Memory{
Total: memTotalBytes,
Used: memUsedBytes,
Free: memFreeBytes,
Usage: safePercentage(float64(memUsed), float64(memTotal)),
}
if guestRaw.Balloon > 0 {
memory.Balloon = clampToInt64(guestRaw.Balloon)
}
// Create VM model
modelVM := models.VM{
ID: guestID,
VMID: vm.VMID,
Name: vm.Name,
Node: n.Node,
Pool: strings.TrimSpace(vm.Pool),
Instance: instanceName,
Status: vm.Status,
Type: "qemu",
CPU: cpuUsage,
CPUs: vm.CPUs,
Memory: memory,
Disk: models.Disk{
Total: int64(diskTotal),
Used: int64(diskUsed),
Free: int64(diskFree),
Usage: diskUsage,
},
Disks: individualDisks,
DiskStatusReason: diskStatusReason,
NetworkIn: max(0, int64(netInRate)),
NetworkOut: max(0, int64(netOutRate)),
DiskRead: max(0, int64(diskReadRate)),
DiskWrite: max(0, int64(diskWriteRate)),
Uptime: int64(vm.Uptime),
Template: vm.Template == 1,
LastSeen: sampleTime,
Tags: tags,
IPAddresses: ipAddresses,
OSName: osName,
OSVersion: osVersion,
AgentVersion: guestAgentVersion,
NetworkInterfaces: networkInterfaces,
}
// Zero out metrics for non-running VMs
if vm.Status != "running" {
modelVM.CPU = 0
modelVM.Memory.Usage = 0
modelVM.Disk.Usage = 0
modelVM.NetworkIn = 0
modelVM.NetworkOut = 0
modelVM.DiskRead = 0
modelVM.DiskWrite = 0
}
// Trigger guest metadata migration if old format exists
if m.guestMetadataStore != nil {
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, n.Node, vm.VMID)
}
nodeVMs = append(nodeVMs, modelVM)
m.recordGuestSnapshot(instanceName, modelVM.Type, n.Node, vm.VMID, GuestMemorySnapshot{
Name: vm.Name,
Status: vm.Status,
RetrievedAt: sampleTime,
MemorySource: memorySource,
FallbackReason: guestMemoryFallbackReason(memorySource),
Memory: modelVM.Memory,
Raw: guestRaw,
Notes: snapshotNotes,
})
// Check alerts
m.alertManager.CheckGuest(modelVM, instanceName)
}
nodeVMs, nodeTemplateSubjects := m.pollNodeVMsWithClusterResourceBuilder(ctx, instanceName, n.Node, vms, client, prevVMByID, vmIDToHostAgent)
nodeDuration := time.Since(nodeStart)
log.Debug().
Str("node", n.Node).
@ -615,13 +75,11 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
}(node)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results from all nodes
var allVMs []models.VM
qemuTemplateSubjects := make(map[string]struct{})
successfulNodes := 0
@ -630,20 +88,18 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
for result := range resultChan {
if result.err != nil {
failedNodes++
} else {
successfulNodes++
allVMs = append(allVMs, result.vms...)
for key := range result.templateSubjects {
qemuTemplateSubjects[key] = struct{}{}
}
continue
}
successfulNodes++
allVMs = append(allVMs, result.vms...)
for key := range result.templateSubjects {
qemuTemplateSubjects[key] = struct{}{}
}
}
if failedNodes == 0 && successfulNodes > 0 {
m.updatePVEBackupTemplateSubjectsForType(instanceName, "qemu", qemuTemplateSubjects)
}
// If we got ZERO VMs but had VMs before (likely cluster health issue),
// preserve previous VMs instead of clearing them
if len(allVMs) == 0 && len(nodes) > 0 {
allVMs = append(allVMs, prevGuests.vms...)
prevVMCount := len(prevGuests.vms)
@ -657,10 +113,8 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
}
}
// Update state with all VMs
m.state.UpdateVMsForInstance(instanceName, allVMs)
// Record guest metrics history for running VMs (enables sparkline/trends view)
if !shouldSkipNativeMockStateMetricWrites() {
now := time.Now()
for _, vm := range allVMs {
@ -672,7 +126,6 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
if vm.Disk.Usage >= 0 {
m.metricsHistory.AddGuestMetric(vm.ID, "disk", vm.Disk.Usage, now)
}
// Also write to persistent store
if m.metricsStore != nil {
m.metricsStore.Write("vm", vm.ID, "cpu", vm.CPU*100, now)
m.metricsStore.Write("vm", vm.ID, "memory", vm.Memory.Usage, now)
@ -692,7 +145,3 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
Dur("duration", duration).
Msg("Parallel VM polling completed")
}
// pollContainersWithNodes polls containers from all nodes in parallel using goroutines
// When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates
// when multiple cluster nodes are configured as separate PVE instances.

View file

@ -456,6 +456,25 @@ func (m *Monitor) fetchPVENodes(ctx context.Context, instanceName string, instan
return nodes, client, nil
}
func (m *Monitor) refreshPVETagColors(ctx context.Context, client PVEClientInterface) {
type clusterOptionsGetter interface {
GetClusterOptions(ctx context.Context) (*proxmox.ClusterOptions, error)
}
getter, ok := client.(clusterOptionsGetter)
if !ok || getter == nil {
return
}
opts, err := getter.GetClusterOptions(ctx)
if err != nil || opts == nil {
return
}
if colors := proxmox.ParseTagColorMap(opts.TagStyle); len(colors) > 0 {
m.state.MergeTagColors(colors)
}
}
func (m *Monitor) updatePVEConnectionHealth(ctx context.Context, instanceName string, client PVEClientInterface) string {
connectionHealthStr := "healthy"
if clusterClient, ok := client.(*proxmox.ClusterClient); ok {
@ -1028,6 +1047,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
return
}
client = updatedClient
m.refreshPVETagColors(ctx, client)
// Check if client is a ClusterClient to determine health status
connectionHealthStr := m.updatePVEConnectionHealth(ctx, instanceName, client)

View file

@ -2,6 +2,7 @@ package monitoring
import (
"context"
"sync"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
@ -77,6 +78,7 @@ func (m *Monitor) collectGuestsFromClusterResources(
) ([]models.VM, []models.Container) {
allVMs := make([]models.VM, 0, len(resources))
allContainers := make([]models.Container, 0, len(resources))
vmResources := make([]indexedClusterResource, 0, len(resources))
for _, res := range resources {
// Generate canonical guest ID: instance:node:vmid
@ -92,15 +94,11 @@ func (m *Monitor) collectGuestsFromClusterResources(
switch res.Type {
case "qemu":
var prevVM *models.VM
if prev, ok := prevVMByID[guestID]; ok {
prevVM = &prev
}
vm, ok := m.handleClusterVMResource(ctx, instanceName, res, guestID, client, prevVM, vmIDToHostAgent)
if !ok {
continue
}
allVMs = append(allVMs, vm)
vmResources = append(vmResources, indexedClusterResource{
order: len(vmResources),
resource: res,
guestID: guestID,
})
case "lxc":
container, ok := m.handleClusterContainerResource(ctx, instanceName, res, guestID, client, prevContainerIsOCI)
if !ok {
@ -110,9 +108,93 @@ func (m *Monitor) collectGuestsFromClusterResources(
}
}
if len(vmResources) > 0 {
allVMs = append(allVMs, m.collectClusterVMResources(ctx, instanceName, vmResources, client, prevVMByID, vmIDToHostAgent)...)
}
return allVMs, allContainers
}
type indexedClusterResource struct {
order int
resource proxmox.ClusterResource
guestID string
}
type clusterVMResourceResult struct {
order int
vm models.VM
ok bool
}
func (m *Monitor) collectClusterVMResources(
ctx context.Context,
instanceName string,
resources []indexedClusterResource,
client PVEClientInterface,
prevVMByID map[string]models.VM,
vmIDToHostAgent map[string]models.Host,
) []models.VM {
resultCh := make(chan clusterVMResourceResult, len(resources))
jobCh := make(chan indexedClusterResource, len(resources))
var wg sync.WaitGroup
workerCount := m.efficientQEMUWorkerCount(len(resources))
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for entry := range jobCh {
var result clusterVMResourceResult
ran := m.runGuestAgentVMWork(ctx, func(workCtx context.Context) {
var prevVM *models.VM
if prev, ok := prevVMByID[entry.guestID]; ok {
prevVM = &prev
}
vm, ok := m.handleClusterVMResource(workCtx, instanceName, entry.resource, entry.guestID, client, prevVM, vmIDToHostAgent)
result = clusterVMResourceResult{
order: entry.order,
vm: vm,
ok: ok,
}
})
if !ran {
result = clusterVMResourceResult{order: entry.order, ok: false}
}
resultCh <- result
}
}()
}
for _, entry := range resources {
jobCh <- entry
}
close(jobCh)
go func() {
wg.Wait()
close(resultCh)
}()
orderedVMs := make([]models.VM, len(resources))
orderedOK := make([]bool, len(resources))
for result := range resultCh {
if result.order < 0 || result.order >= len(resources) || !result.ok {
continue
}
orderedVMs[result.order] = result.vm
orderedOK[result.order] = true
}
vms := make([]models.VM, 0, len(resources))
for i, ok := range orderedOK {
if ok {
vms = append(vms, orderedVMs[i])
}
}
return vms
}
func (m *Monitor) handleClusterVMResource(
ctx context.Context,
instanceName string,

Some files were not shown because too many files have changed in this diff Show more