mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
a73a24259c
commit
bd6f77e093
119 changed files with 4753 additions and 2046 deletions
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/create-release.yml
vendored
8
.github/workflows/create-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/deploy-demo-server.yml
vendored
2
.github/workflows/deploy-demo-server.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
14
Dockerfile
14
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
frontend-modern/src/api/dockerMetadata.ts
Normal file
26
frontend-modern/src/api/dockerMetadata.ts
Normal 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
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
|
||||
export { toReportingResourceType, type ReportingResourceType } from '@/utils/reportingResourceTypes';
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const mockWebSocketState: State = {
|
|||
},
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
pveTagColors: {},
|
||||
lastUpdate: 0,
|
||||
resources: [],
|
||||
temperatureMonitoringEnabled: false,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const baseState = (overrides: Partial<State> = {}): State => ({
|
|||
lastUpdate: 0,
|
||||
resources: [],
|
||||
...overrides,
|
||||
pveTagColors: overrides.pveTagColors ?? {},
|
||||
});
|
||||
|
||||
const makeResourceStorage = (overrides: Partial<Resource> = {}): Resource =>
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ describe('useUnifiedResources', () => {
|
|||
},
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
pveTagColors: {},
|
||||
resources: [wsResource],
|
||||
lastUpdate: 0,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface State {
|
|||
recentlyResolved: ResolvedAlert[];
|
||||
lastUpdate: number;
|
||||
temperatureMonitoringEnabled?: boolean;
|
||||
pveTagColors: Record<string, string>;
|
||||
// Unified resources (canonical resource model)
|
||||
resources: Resource[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,7 @@ export interface Resource {
|
|||
// Metadata
|
||||
tags?: string[];
|
||||
labels?: Record<string, string>;
|
||||
customUrl?: string;
|
||||
lastSeen: number; // Unix milliseconds
|
||||
alerts?: ResourceAlert[];
|
||||
incidents?: ResourceIncident[];
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export const useAppRuntimeState = () => {
|
|||
},
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
pveTagColors: {},
|
||||
lastUpdate: 0,
|
||||
resources: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
108
internal/api/state_summary.go
Normal file
108
internal/api/state_summary.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
69
internal/monitoring/guest_agent_work.go
Normal file
69
internal/monitoring/guest_agent_work.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue