Commit Graph

1362 Commits

Author SHA1 Message Date
rcourtman 686c2e8716 feat(reporting): availability section computed from the resource state timeline
Performance reports answered 'what were the averages' but never 'was my
infrastructure up' - the question a managed-service client reads a
monthly report for. Reports now carry an Availability summary derived
from the recorded resource change timeline (state_transition entries
keyed by the canonical unified ID):

- uptime percent over the observed portion of the window, outage count,
  total downtime, and longest outage, rendered in the executive summary
  with an explicit semantics note; fleet summaries gain a per-resource
  Uptime column and CSV exports gain availability header lines
- absent/unknown spans are unobserved time: excluded from the uptime
  math entirely and disclosed as coverage, never counted as downtime.
  The journal records a registry absence for every monitor restart, so
  treating gaps as outages would invent fleet-wide downtime every time
  the operator restarts Pulse
- warning states count as up (the resource is reachable and serving);
  the uptime label clamps rounding so any real downtime can never
  display as a clean 100%
- resources with no timeline render no availability section at all
  rather than a fabricated number

Verified live against a real 7-day window: uptime/outage/downtime
figures reconcile with the raw resource_changes journal.
2026-06-10 17:48:11 +01:00
rcourtman e6c0c4d385 fix(reporting): resolve unified resource IDs to metrics targets and render honest report subjects
Performance reports were structurally disconnected from the v6 ID
space: the UI (and any API caller working from /api/state) addresses
resources by canonical unified ID, while the metrics store is keyed by
each platform's native source ID (the resource's metricsTarget). The
engine queried the store with the unified ID verbatim, so every report
rendered 'Data Points: 0' regardless of how much history existed, and
covers showed raw hash IDs a report reader cannot map to a machine.

- MetricReportRequest gains MetricsResourceID: handlers resolve the
  unified ID through the tenant monitor's resource store (new
  Monitor.MetricsTargetForResource accessor; the registry computes
  targets on demand, they are not persisted on snapshot structs) and
  the engine uses it for store queries only. Recovery points and
  Patrol findings stay keyed by the unified ID.
- Legacy snapshot models and their alerts are keyed by the metrics
  target ID, so enrichment now matches either ID space and resource
  names/status resolve again on covers, headers, and fleet rows.
- Fleet summaries mirror the single-report guard: zero data points
  across the fleet renders a muted NO DATA card instead of a green
  HEALTHY 'All systems operating normally' - false reassurance is the
  worst failure mode for a client-facing stability report.
- Em dashes in PDF-bound literals become hyphens; fpdf core fonts are
  cp1252 and rendered them as mojibake.
2026-06-10 17:10:30 +01:00
rcourtman 96001d134e Harden MSP tenant isolation: scope org-bound tokens away from default org, propagate webhook allowlist to all tenants
Two gaps found by exercising the MSP pilot path live on a throwaway
multi-tenant instance:

1. CheckAccess granted any authenticated principal access to the default
   org, so a token bound to a client org could read the provider's own
   default-org estate if it leaked from a client site. Org-bound tokens
   now fall through to the explicit binding check for the default org;
   authenticated users and legacy unbound tokens keep default-org access,
   and binding "default" explicitly still grants it.

2. The webhook private-target allowlist (instance-wide system setting)
   only ever reached the default org's notification manager on
   startup/reload, and only the request-context org on settings update.
   Tenant orgs' webhooks to private targets (per-client Gotify over VPN,
   the canonical MSP alert route) failed SSRF validation with no org-side
   remedy, and any allowlist died with a restart. Settings updates and
   reloads now fan out to every live tenant manager via the new
   MultiTenantMonitor.ForEachMonitor, and tenant monitors inherit the
   persisted allowlist and public URL at creation.

Both fixes verified live: org-bound token vs default org returns 403;
client-org webhooks to a private target succeed after restart and for
orgs created after the allowlist was saved. MSP.md validation checklist
gains the default-org probe and the allowlist guidance; MULTI_TENANT.md
documents the binding semantics. Contracts updated for api-contracts,
security-privacy, and monitoring with adjacency notes for
agent-lifecycle, storage-recovery, and performance-and-scalability.
2026-06-10 11:37:39 +01:00
rcourtman 1cf92e6c10 Pin metadata GET zero-record payload contract
Adds TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404: empty
guest/docker metadata maps must serialize as {} (never null) and a
missing resource must return a 200 zero record echoing the requested ID
(never a 404). This is the proof companion to the
metadata_handlers_shared.go consolidation in the previous commit — it
was authored with that change but lost to a shared-index race at commit
time.
2026-06-10 10:53:57 +01:00
rcourtman 6340cd36f2 Dedupe internal/api handler families behind shared flows and generics
Clears the sixteen dupl pair groups in internal/api plus the pkg/pulsecli
pair:

- router.go: privileged settings endpoints share the
  serveSetupTokenOrSettingsWrite gate; patrol findings convert via one
  unifiedFindingFromAI; the five infrastructure-summary chart loops share
  collectGuestChartData / fillChartSeriesFromBatch; the VM/LXC workloads
  summary loops share appendGuestWorkloadSummaries.
- deploy_handlers.go: preflight and job status/SSE handlers share
  handleDeployJobStatus / handleDeployJobEvents.
- recovery_handlers.go: points series/facets share
  parseRecoveryListPointsOptions.
- truenas_handlers.go / vmware_handlers.go / router_routes_registration.go:
  the connection update flow (locate, decode-with-fallback, normalize,
  preserve masked secrets, validate, save, redact) moves to the new
  platform_connection_shared.go (updatePlatformConnection +
  decodeOptionalInstanceRequest + the admin-gated item-route builder);
  per-platform wrappers carry nolint'd declarative wiring only.
- docker_metadata.go / guest_metadata.go: GET/PUT payload semantics move
  to metadata_handlers_shared.go; the zero-record-instead-of-404 contract
  is pinned by TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404.
- kubernetes_agents.go / docker_agents.go: lifecycle PUTs share
  per-handler action helpers.
- config_node_handlers.go: PBS/PMG probes share
  testProxmoxPlatformConnection.
- cloud_handoff_handlers.go / purchase_return_redemptions.go: secrets
  sqlite stores open through openHardenedSecretsDB so permission
  hardening stays single-sourced.
- ai_handlers.go / chat_service_adapter.go: the GetMessages adapters are
  deliberate contract mirrors — suppressed with nolint and enforced by
  TestOrchestratorAndChatAdaptersMapTheSameMessageFields.
- pkg/pulsecli/actions.go: action subcommands seed env defaults via
  actionAPIDefaults (tested); the audit/events cobra registration pair is
  nolint'd parallel wiring.
- .golangci.yml: exclude gitignored tmp/ from ./... typechecking.
- subsystem_lookup_test.py: refresh the pinned api-contracts.md line
  numbers shifted by the contract additions.

golangci-lint run ./... is now fully green. Full internal/api and
pkg/pulsecli test suites pass.
2026-06-10 10:52:05 +01:00
rcourtman b707512e38 Clear all errcheck and gofmt violations so make lint gates on real findings
golangci-lint run ./... failed on ~190 pre-existing errcheck violations and
5 unformatted files, burying any new regression in noise. Fix all of them:

- Test files that hand-rolled mock-mode set/restore (vmware, truenas, and
  friends) now use the canonical setMockModeForTest/testutil.SetMockMode
  helper instead of drift copies that ignored SetEnabled errors.
- internal/mock and internal/monitoring tests get package-local
  mustSetEnabled/mustSetMockEnabled/mustSetMonitorMockMode helpers that
  fail the test on toggle errors.
- pkg/auth/sqlite_manager.go, pkg/metrics/store.go, pkg/server/server.go:
  rollbacks in defers use the explicit-discard idiom, migration renames and
  rollup commits log failures, the hosted reaper goroutine logs an error
  exit, shutdown mock-disable logs failures.
- Remaining test sites check errors with t.Fatalf/t.Errorf or explicitly
  discard best-effort calls (restore-chmods, handler-closure unmarshals)
  per existing repo style.
- gofmt: internal/api/maintenance_verification.go, internal/ai/demo.go and
  three findings test files.

Only dupl findings remain (44 pre-existing production-code duplication
pairs) — those need real refactors, not mechanical fixes.

Full test suites pass for every touched package.
2026-06-09 21:42:21 +01:00
rcourtman 61c8e8ca00 Make agent command-exec token rejection actionable, not a silent 'Invalid token'
An agent enrolled for metrics but whose token the server doesn't recognise (or
that lacks the agent:exec scope, or is bound to a different agent) was rejected
on the command-exec WebSocket with a bare 'Invalid token' and — for the
token-not-found case — no server log at all. The agent then retried forever,
logging only 'Invalid token', so the operator had no signal that discovery
deep-scan was failing or why. (Confirmed live: delly/minipc agents pointed at a
backend that didn't recognise their token retried thousands of times; discovery
abstained for every guest as a result.)

- agentexec/server.go: the registration-rejection message the agent logs
  verbatim now says 'agent token not authorized for command execution — re-run
  the agent installer to enroll an agent:exec-scoped token'.
- api/agent_exec_token_binding.go: the previously-silent token-not-recognised
  branch now logs the specific reason with the agent hostname.

Contract-neutral: same rejection behaviour, just legible. Regression test:
TestHandleWebSocket_RejectionMessageIsActionable. Verified live end-to-end.
2026-06-09 17:55:09 +01:00
rcourtman bdb212744c Remove the cloud_context_privacy dial; fix cloud context to a lean posture
Per maintainer decision: the cloud-context-privacy feature was bloat. The real
fix for the "useless Assistant on cloud" problem was the earlier sensitivity
recalibration (ordinary workloads = Internal, not redacted); the dial layered a
configurable knob on top of an already-solved problem, guarding mostly-non-secret
data on a destination the operator opted into, and demanded every model-bound path
stay dial-aware (a standing leak surface). The privacy control users actually
understand is the choice of model — cloud provider vs. local Ollama.

Removed entirely:
- AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy /
  NormalizeCloudContextPrivacy, AND the now-dead legacy
  ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud
  (internal/config/ai.go); the config-load migration (persistence.go).
- Both fields from the /api/settings/ai request/response, validation, and sync
  (ai_handlers.go) + the JSON contract snapshots.
- The "Cloud model privacy" 3-option UI control, form field, presentation copy,
  and CloudContextPrivacy type (frontend), plus their tests.
- The dial branching in the seam: chat/service.go cloudPrivacyLevel,
  CloudContextPolicy.Level + local_only suppression + the localOnly directive
  (context_prefetch.go), the inventory resourceLabel dial logic (resource_context*),
  and the modelboundary RedactLocalOnlyResourcesOnly option.

Fixed lean posture (no setting): a cloud-routed model receives real infrastructure
context, with two always-on invariants enforced by the model-boundary sanitizer —
credentials are always stripped, and local-only/Restricted resources (the floor)
never leave the local trust boundary. Local (Ollama) always full. The sanitizer's
default is now the local-only floor; it remains the universal backstop installed on
EVERY model-bound path (chat, session compaction, discovery/report/analysis via the
shared helper). Kept the two standalone fixes from this effort: compaction now
routes through the sanitizer, and directives no longer inject the "redacted by
policy" placeholder.

Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts /
frontend-primitives / agent-lifecycle / storage-recovery dial references removed.
Tests updated to the floor-only behavior (local-only redacted, Sensitive flows,
secrets stripped). Full internal/ai/..., config, api suites green; frontend
type-check + tests + lint green.
2026-06-09 09:43:21 +01:00
rcourtman 7cb45489e8 Add cloud_context_privacy dial (privacy redesign increment 1)
Introduce the single privacy dial that will govern what infrastructure context
cloud models may see, replacing the binary share_operational_context_with_cloud
toggle as the canonical operator control. This increment adds and surfaces the
setting; it does not change the redaction seam (that is increment 2).

Config (internal/config/ai.go): add AIConfig.CloudContextPrivacy with the
full|redacted|local_only levels, default "full", plus NormalizeCloudContextPrivacy
and the nil-safe GetCloudContextPrivacy getter. NewDefaultAIConfig defaults a fresh
self-hosted install to "full" so the Assistant answers with real resource detail
out of the box. The legacy ShareOperationalContextWithCloud boolean is retained as
the field the redaction seam still reads until it is wired into the dial directly.

Migration (internal/config/persistence.go): LoadAIConfig derives the dial from the
legacy toggle for pre-dial configs (legacy on -> full, off/absent -> redacted) and
persists it, leaving the legacy boolean untouched so existing installs keep their
current cloud behavior byte-for-byte. Fresh installs (no config file) default to full.

API (internal/api/ai_handlers.go): round-trip cloud_context_privacy through
/api/settings/ai field-by-field like discovery_enabled. The response always
serializes GetCloudContextPrivacy() (no omitempty) so the UI binds a 3-option
control to the concrete value; the update request carries an optional *string
validated against NormalizeCloudContextPrivacy (unknown values -> 400). When the
dial is provided it supersedes and re-syncs the legacy boolean (full -> true,
redacted/local_only -> false) so the existing seam honors the dial's full/redacted
axis without new redaction code paths.

Frontend: replace the binary "Share operational context with cloud models" toggle
with a "Cloud model privacy" 3-option FormSelect in AIRuntimeControlsSection.tsx,
bound to state.form.cloudContextPrivacy and the cloud_context_privacy payload via
useAISettingsState. CloudContextPrivacy type + payload fields in types/ai.ts;
label/help/option/summary copy in aiSettingsPresentation.ts.

Governance (ai-runtime + frontend-primitives substantive deltas; dependent
api-contracts, agent-lifecycle, storage-recovery notes): the contracts now name the
dial as canonical with the legacy boolean as the synced/migrated seam field.
Proofs: ai_config_test.go (getter/normalize/default), persistence_ai_test.go
(migration cases), ai_handlers_test.go (round-trip + legacy sync + 400),
contract_test.go JSON snapshots, settingsArchitecture + aiSettingsPresentation tests.
Live-verified in the preview drawer: dial renders with all three levels, the
migrated value (redacted) is selected, the summary updates reactively, and an
end-to-end UI save round-trips full (legacy sync true) then restores redacted.
2026-06-08 16:43:50 +01:00
rcourtman 3f76da7932 Surface cloud operational-context sharing opt-in in AI settings
Wire AIConfig.ShareOperationalContextWithCloud through /api/settings/ai so the
existing chat-path opt-in (commit 32d597267) is operator-reachable, not
config-file-only.

Backend (internal/api/ai_handlers.go): add share_operational_context_with_cloud
to the AI settings response (always serialized so a toggle can bind to the
concrete value) and to the update request as an optional *bool, applied
field-by-field exactly like discovery_enabled (omitted = persisted opt-in
unchanged).

Frontend: add a 'Share operational context with cloud models' toggle to the
Assistant runtime controls, bound to the canonical useAISettingsState form and
the api/ai.ts AISettings/AISettingsUpdateRequest payload. Help/summary copy
(PII-free scope, hostnames/IPs/aliases stay redacted, default off, local Ollama
always gets full context) lives in aiSettingsPresentation.ts.

Governance: substantive ai-runtime + frontend-primitives deltas plus
dependent-contract notes (api-contracts, agent-lifecycle, storage-recovery);
path-policy proofs in ai_handlers_test.go (round-trip), settingsArchitecture
and aiSettingsPresentation tests. JSON snapshot contracts updated for the new
always-serialized field.
2026-06-08 10:05:37 +01:00
rcourtman 13b2c33dfd Align Assistant route wait status
Use neutral assistant wait status while keeping selected model route metadata visible. Make transient retry copy explicit that Pulse retries the selected route rather than switching providers.
2026-06-08 06:37:47 +01:00
rcourtman 738fc4eb13 Reject unsupported Assistant session diff/revert 2026-06-07 18:01:55 +01:00
rcourtman 84ec1a7214 Align Assistant provider retry with OpenCode 2026-06-07 17:04:42 +01:00
rcourtman 42f4a7028d Surface Assistant idle progress on SSE streams 2026-06-07 00:06:34 +01:00
rcourtman e44b11f4d0 Improve Assistant tool activity rows 2026-06-06 21:58:17 +01:00
rcourtman 3c74fe2ca0 Start Assistant runtime in mock mode 2026-06-06 21:39:39 +01:00
rcourtman e4ac092876 Add Assistant stream idle progress 2026-06-06 19:59:34 +01:00
rcourtman 44e8a1b2bd Add Assistant turn undo and redo 2026-06-06 19:42:27 +01:00
rcourtman 5503e15d23 Add Assistant session rename
Add persisted session rename support across the chat store, API contract, and Assistant session picker.
2026-06-06 18:32:21 +01:00
rcourtman 2291924cb6 Show Assistant provider retry progress 2026-06-06 15:55:59 +01:00
rcourtman 49c23d6bed Search Assistant session history 2026-06-06 09:14:06 +01:00
rcourtman 0ad040bdf0 Resolve Assistant plain-text resource context
Attach backend-resolved current_resource context for live Assistant read prompts.
2026-06-06 07:13:05 +01:00
rcourtman ab99112ad2 Show Assistant tool starts while streaming 2026-06-06 03:12:25 +01:00
rcourtman 1f4477c27c Show live Assistant tool progress 2026-06-06 01:12:01 +01:00
rcourtman 2ab0a60e99 Speed up Assistant stream startup 2026-06-05 22:54:44 +01:00
rcourtman 0d2f2a3cc8 Anchor Assistant stream and OpenRouter default 2026-06-05 22:02:47 +01:00
rcourtman 7067a51680 Make Assistant provider fallback resilient 2026-06-05 20:15:55 +01:00
rcourtman a648afaff0 Avoid duplicate Assistant stream errors 2026-06-05 19:29:07 +01:00
rcourtman dae5daf6ac Fix Assistant provider route validation 2026-06-05 18:48:57 +01:00
rcourtman 5ea51b4b25 Harden Assistant stream output and OpenRouter token budget 2026-06-05 16:30:07 +01:00
rcourtman 064cd4bc80 Hide Assistant reasoning from message history 2026-06-05 15:43:06 +01:00
rcourtman c5bee4a785 Surface Assistant provider readiness 2026-06-05 12:50:02 +01:00
rcourtman 479ac6db2c Stream Assistant cold sessions directly 2026-06-05 11:54:01 +01:00
rcourtman 6bbbf185bb Make workload discovery repair known service results 2026-06-05 09:21:53 +01:00
rcourtman 91528f5a07 Fix Assistant resource context and model route labels 2026-06-04 23:45:15 +01:00
rcourtman a43f7cbe7f Add discovery readiness to Assistant context 2026-06-04 21:56:36 +01:00
rcourtman 3f8525a7c2 Implement resource-aware Assistant context 2026-06-04 16:36:04 +01:00
rcourtman bd6f77e093 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.
2026-06-04 14:07:14 +01:00
rcourtman efb6976069 Diagnostics: handle blank-name instances; add report-token router test
Two follow-ups caught by independent re-review:
- #29 (8855b78c0): GetConnectionStatuses keys a name-less instance by host,
  but the diagnostics lookup used 'pve-'/'pbs-'+Name only, so the
  monitor-state merge was a silent no-op for unnamed instances. Fall back
  to host to match.
- #22 (d9da9b18f, #1254): add a full-router test asserting an UNBOUND
  agent:report token gets 404 (not 200) on config fetch — continuously
  verifying the token-binding security boundary that the scope change
  relies on.
2026-06-04 11:49:17 +01:00
rcourtman d4edd1264d Fix TestRestart_StartIfServiceMissing expectation for single config load
#23 (a6b773a85) made Restart load the AI config once and pass it to
startWithConfig, so the start path no longer re-loads it. The test still
expected LoadAIConfig().Twice(); updated to .Once() to match the fixed
single-load behavior. (Caught by running the full internal/api suite,
which the per-fix targeted runs had missed.)
2026-06-04 11:23:05 +01:00
rcourtman 1713662379 Stamp AI models with their provider for correct grouping (#1320)
Back-port the core of v5 fix 1de1392c9 to v6 (minimal, not the full
dedup refactor). The model list API now sets ModelInfo.Provider (stamped
in prefixProviderModels and passed through HandleListModels), and the
frontend groupModelsByProvider prefers the server-supplied provider over
splitting the model id, so models with opaque ids (e.g. Ollama-hosted
llama3-8b) group under the correct provider. Adds a grouping regression
test.
2026-06-04 10:47:40 +01:00
rcourtman a6b773a85f Reuse the loaded AI config when restarting the service
Back-port v5 fix 716340dcd to v6. Extracts startWithConfig from Start so
Restart passes the config it already loaded down to the start path, instead
of Start re-reading loadAIConfig a second time. Avoids starting the AI
service with a stale/inconsistent config if it changed between Restart's
load and the start. Both Restart start paths now use startWithConfig.
2026-06-04 10:30:45 +01:00
rcourtman 8855b78c0d Prefer live monitor connection state in diagnostics
Back-port v5 fix d310c257a to v6, adapted to v6's connection-status key
format. computeDiagnostics now merges a failed PVE/PBS diagnostics probe
with the monitor's live connection state: if the long-running poller still
reports the instance connected, a transient probe failure (network blip,
TLS re-check) no longer flips it to 'disconnected' in the UI. Uses v6's
'pve-<name>'/'pbs-<name>' status keys (v5 used a bare node name, which
would not match in v6). Adds a merge-logic regression test.
2026-06-04 10:07:10 +01:00
rcourtman 85ec355268 Preserve webhook mentions in list API and resolved notifications (#1118)
Back-port v5 fixes 5997fd81f and 0a7b93a84 to v6:
- GetWebhooks list response now includes the configured mention so the UI
  shows it after reload instead of blanking it.
- sendResolvedWebhook now assigns data.Mention (v6 set it for grouped/
  firing webhooks but dropped it on resolved), and the Discord/Slack/Teams/
  Mattermost ResolvedPayloadTemplate strings gained {{if .Mention}} guards.

Without these, a configured @everyone/@channel was silently omitted from
resolved/cleared notifications. Adds list-API and per-service resolved
mention regression tests.
2026-06-04 09:29:47 +01:00
rcourtman d9da9b18f2 Allow legacy report-only agent tokens to fetch their own config (#1254)
Back-port v5 fix 04a828264 to v6. canReadConfig now accepts
ScopeAgentReport (legacy host-agent:report) in addition to the config/
manage/settings scopes; resolveConfigAgent already restricts a
report-only token to the host it is bound to, so it can only read its
own config. Without this, a v5 agent upgraded to a v6 server while still
holding a report-only token gets HTTP 403 on config fetch and breaks.

Retargets TestHandleConfigMissingConfigScope to monitoring:read and adds
TestHandleConfigAllowsBoundAgentReportScope as a #1254 regression guard.
2026-06-04 08:39:31 +01:00
rcourtman 5a21f0c288 Make rate limiter sliding-window test deterministic 2026-06-03 23:14:25 +01:00
rcourtman ab2c2453c2 Synchronize auto-register reload test 2026-06-03 22:40:30 +01:00
rcourtman 0d0eb4bf11 Stabilize v6 release dry-run backend gate 2026-06-03 18:12:42 +01:00
rcourtman 7440208163 Project Kubernetes agent versions onto node rows 2026-06-03 15:57:51 +01:00
rcourtman 17e854e7b6 Use agent update target for stale notices 2026-06-03 14:42:43 +01:00