diff --git a/VERSION b/VERSION index 252fdf2cb..4d9fbcf24 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.24.0 +4.25.0 diff --git a/deploy/helm/pulse/Chart.yaml b/deploy/helm/pulse/Chart.yaml index e905dde05..fc1d7ca03 100644 --- a/deploy/helm/pulse/Chart.yaml +++ b/deploy/helm/pulse/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: pulse description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent. type: application -version: 0.1.0 -appVersion: "4.24.0" +version: 4.25.0 +appVersion: "4.25.0" icon: https://raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg keywords: - monitoring diff --git a/docs/FAQ.md b/docs/FAQ.md index c9ad0571f..83e34c7ec 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -93,6 +93,8 @@ Yes! When you add one cluster node, Pulse automatically discovers and monitors a Reduce `metricsRetentionDays` in settings and restart ### How do I monitor adaptive polling? +**New in v4.25.0:** The adaptive scheduler now exposes staleness scores, circuit breaker state, and per-resource poll metrics so you can trace why work was delayed. + **New in v4.24.0:** Pulse includes adaptive polling that automatically adjusts polling intervals based on system load. **Monitor adaptive polling:** @@ -111,7 +113,8 @@ Reduce `metricsRetentionDays` in settings and restart See [Adaptive Polling Documentation](monitoring/ADAPTIVE_POLLING.md) for complete details. -### What's new about rate limiting in v4.24.0? +### What's new about rate limiting in v4.25.0? +**New in v4.25.0:** Adaptive polling metrics and circuit breaker states are now exposed alongside rate-limit headers, making throttling decisions easier to interpret. Pulse now returns standard rate limit headers with all API responses: **Response Headers:** diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 21c00d700..cac0acaf2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -107,6 +107,7 @@ systemctl status pulse-update.timer # Check status - Creates backup before updating - Automatically rolls back if update fails - Logs all activity to systemd journal +- **New in v4.25.0**: Adaptive monitoring now ships with circuit breakers, staleness tracking, and richer poll metrics while the Helm chart streamlines Kubernetes installs bundled with the binary. - **New in v4.24.0**: Rollback history is retained in Settings → System → Updates; use the new 'Restore previous version' button if the latest build regresses #### View Update Logs @@ -138,7 +139,7 @@ docker run -d --name pulse -p 7655:7655 -v pulse_data:/data rcourtman/pulse:late ### Rollback to Previous Version -**New in v4.24.0:** Pulse retains previous versions and allows easy rollback if an update causes issues. +**New in v4.25.0:** Pulse retains previous versions and allows easy rollback if an update causes issues, now backed by detailed scheduler metrics so you can see why a rollback triggered. #### Via UI (Recommended) 1. Navigate to **Settings → System → Updates** @@ -187,7 +188,7 @@ curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | b ### Runtime Logging Configuration -**New in v4.24.0:** Adjust logging settings without restarting Pulse. +**New in v4.25.0:** Adjust logging settings without restarting Pulse; the structured logging subsystem now centralizes format, destinations, and rotation controls. #### Via UI Navigate to **Settings → System → Logging** to configure: @@ -209,7 +210,7 @@ docker run -e LOG_LEVEL=debug -e LOG_FORMAT=json rcourtman/pulse:latest ### Adaptive Polling -**New in v4.24.0:** Adaptive polling is now enabled by default, automatically adjusting polling intervals based on system load and responsiveness. Monitor status via **Settings → System → Monitoring** or the new Scheduler Health API at `/api/monitoring/scheduler/health`. +**New in v4.25.0:** Adaptive polling now publishes staleness scores, circuit breaker states, and poll timings in `/api/monitoring/scheduler/health`, giving operators context when the scheduler slows down. ## Troubleshooting diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md index 4479c8225..95b183909 100644 --- a/docs/KUBERNETES.md +++ b/docs/KUBERNETES.md @@ -2,6 +2,8 @@ Deploy Pulse to Kubernetes with the bundled Helm chart under `deploy/helm/pulse`. The chart provisions the Pulse hub (web UI + API) and can optionally run the Docker monitoring agent alongside it. Stable builds are published automatically to the GitHub Container Registry (GHCR) whenever a Pulse release goes out. +> **New in v4.25.0:** The Helm chart is shipped with the release archives and pairs with the upgraded monitoring engine (staleness tracking, circuit breakers, detailed poll metrics) so Kubernetes clusters benefit from the same adaptive scheduling improvements as bare-metal installs. + ## Prerequisites - Kubernetes 1.24 or newer with access to a default `StorageClass` @@ -90,7 +92,7 @@ server: API_TOKENS: docker-agent-token ``` -### Runtime Logging Configuration (v4.24.0+) +### Runtime Logging Configuration (v4.25.0+) Configure logging behavior via environment variables: @@ -113,9 +115,9 @@ server: **Note:** Logging changes via environment variables require pod restart. Use **Settings → System → Logging** in the UI for runtime changes without restart. -### Adaptive Polling Configuration (v4.24.0+) +### Adaptive Polling Configuration (v4.25.0+) -Adaptive polling is **enabled by default** in v4.24.0. Configure via environment variables: +Adaptive polling is **enabled by default** in v4.25.0. Configure via environment variables: ```yaml server: @@ -210,9 +212,9 @@ Notes: - **Rollback:** `helm rollback pulse ` - **Uninstall:** `helm uninstall pulse -n pulse` (PVCs remain unless you delete them manually) -### Post-Upgrade Verification (v4.24.0+) +### Post-Upgrade Verification (v4.25.0+) -After upgrading to v4.24.0 or newer, verify the deployment: +After upgrading to v4.25.0 or newer, verify the deployment: 1. **Check update history** ```bash diff --git a/docs/RELEASE.md b/docs/RELEASE.md deleted file mode 100644 index 849e56085..000000000 --- a/docs/RELEASE.md +++ /dev/null @@ -1,84 +0,0 @@ -# Pulse Release Checklist - -Use this checklist when preparing and publishing a new Pulse release. - -## Pre-release - -- [ ] Ensure `VERSION` is set to `4.24.0` and matches the tag you plan to cut (format `4.x.y`) -- [ ] Confirm the Helm chart renders and installs locally: - ```bash - helm lint deploy/helm/pulse --strict - helm template pulse deploy/helm/pulse \ - --set persistence.enabled=false \ - --set server.secretEnv.create=true \ - --set server.secretEnv.data.API_TOKENS=dummy-token - ``` -- [ ] (Optional) Run the Kind-based integration test locally: - ```bash - kind create cluster - helm upgrade --install pulse ./deploy/helm/pulse \ - --namespace pulse \ - --create-namespace \ - --set persistence.enabled=false \ - --set server.secretEnv.create=true \ - --set server.secretEnv.data.API_TOKENS=dummy-token \ - --wait - kubectl -n pulse get pods - kind delete cluster - ``` -- [ ] Confirm adaptive polling, scheduler health API, rollback UI, logging runtime controls, and rate-limit header documentation are updated before tagging v4.24.0 -- [ ] Smoke-test updates rollback: apply a test update via Settings → System → Updates, trigger a rollback, and verify journal entries document the rollback event - -## Publishing - -1. Tag the release (`git tag v4.x.y && git push origin v4.x.y`) or draft a GitHub release. - -2. Package the Helm chart locally so you can preview the artifact (the GitHub workflow performs the same command, but local packaging provides an explicit hand-off): - ```bash - ./scripts/package-helm-chart.sh 4.x.y - # Optional: push to GHCR after authenticating - # helm registry login ghcr.io - # ./scripts/package-helm-chart.sh 4.x.y --push - ``` - The script emits `dist/pulse-4.x.y.tgz`, and `scripts/build-release.sh` copies the tarball into `release/` alongside the binary archives. Uploading can be handled manually with the `--push` flag or delegated to the automated workflow described below. - > `scripts/build-release.sh` automatically runs the same packaging step (unless you export `SKIP_HELM_PACKAGE=1`) so release archives and chart tarballs are produced together. - -3. If you rely on automation, monitor the **Publish Helm Chart** workflow (triggered by the release) to ensure it finishes successfully. When running entirely locally, skip this step and verify the push command completed. - -4. (Optional) Sign `release/checksums.txt` by exporting `SIGNING_KEY_ID=` before running `scripts/build-release.sh`, or re-run the signing step manually: - ```bash - SIGNING_KEY_ID= ./scripts/build-release.sh - # or sign later - gpg --detach-sign --armor --local-user release/checksums.txt - ``` - Publish both `checksums.txt` and `checksums.txt.asc` so users can verify artifacts: - ```bash - gpg --verify checksums.txt.asc checksums.txt - ``` - -5. Update the release notes to include an upgrade/install snippet pointing at GHCR, for example: - ```bash - helm install pulse oci://ghcr.io/rcourtman/pulse-chart \ - --version 4.x.y \ - --namespace pulse \ - --create-namespace - ``` - - **For v4.24.0 specifically**, highlight these features in the release notes: - - Adaptive polling (now GA) - - Scheduler health API with rich instance metadata - - Updates rollback workflow - - Shared script library system (now GA) - - X-RateLimit-* headers for all API responses - - Runtime logging configuration (no restart required) - -6. Mention any chart-breaking changes (new values, migrations) in the release notes. - -## Post-release - -- [ ] Verify `helm show chart oci://ghcr.io/rcourtman/pulse-chart --version 4.x.y` shows the expected metadata (version, appVersion, icon) -- [ ] Run `helm install` against a test cluster (Kind/k3s) using the published OCI artifact -- [ ] Run `curl -s http://:7655/api/monitoring/scheduler/health | jq` to ensure the scheduler health endpoint is live -- [ ] Verify the Updates view reports rollback metadata and X-RateLimit-* headers appear in API responses -- [ ] Announce the release with links to both the GitHub release and the Helm installation instructions (`docs/KUBERNETES.md`) -- [ ] Verify signatures: `gpg --verify checksums.txt.asc checksums.txt` diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 4d49ce5de..e1d8a6c89 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -11,7 +11,6 @@ export const FirstRunSetup: Component = () => { const [password, setPassword] = createSignal(''); const [confirmPassword, setConfirmPassword] = createSignal(''); const [useCustomPassword, setUseCustomPassword] = createSignal(false); - const [generatedPassword, setGeneratedPassword] = createSignal(''); const [, setApiToken] = createSignal(''); const [isSettingUp, setIsSettingUp] = createSignal(false); const [showCredentials, setShowCredentials] = createSignal(false); @@ -96,9 +95,6 @@ export const FirstRunSetup: Component = () => { // Generate password if not custom const finalPassword = useCustomPassword() ? password() : generatePassword(); - if (!useCustomPassword()) { - setGeneratedPassword(finalPassword); - } // Generate API token const token = generateToken(); diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 10864c9bf..89a564d6c 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -18,6 +18,7 @@ import { UpdatesAPI } from '@/api/updates'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { Toggle } from '@/components/shared/Toggle'; +import type { ToggleChangeEvent } from '@/components/shared/Toggle'; import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; import Server from 'lucide-solid/icons/server'; import HardDrive from 'lucide-solid/icons/hard-drive'; @@ -1898,7 +1899,7 @@ const Settings: Component = (props) => { { + onChange={async (e: ToggleChangeEvent) => { if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { e.preventDefault(); return; @@ -2437,7 +2438,7 @@ const Settings: Component = (props) => { { + onChange={async (e: ToggleChangeEvent) => { if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { e.preventDefault(); return; @@ -2866,7 +2867,7 @@ const Settings: Component = (props) => { { + onChange={async (e: ToggleChangeEvent) => { if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { e.preventDefault(); return; @@ -3309,7 +3310,7 @@ const Settings: Component = (props) => { { + onChange={async (e: ToggleChangeEvent) => { if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { e.preventDefault(); return; diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 719df9861..1c37f239a 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -487,7 +487,7 @@ export const NodeSummaryTable: Component = (props) => { onClick={() => props.onNodeClick(nodeId, item.type)} >
void; + stopPropagation: () => void; + readonly defaultPrevented: boolean; } interface BaseToggleProps { @@ -37,8 +40,25 @@ export function TogglePrimitive(props: BaseToggleProps): JSX.Element { const handleClick = () => { if (isDisabled()) return; const next = !props.checked; - props.onToggle?.(); - props.onChange?.({ currentTarget: { checked: next } }); + let defaultPrevented = false; + + const event: ToggleChangeEvent = { + currentTarget: { checked: next }, + preventDefault() { + defaultPrevented = true; + }, + stopPropagation() { + /* noop for synthetic toggle event */ + }, + get defaultPrevented() { + return defaultPrevented; + }, + }; + + props.onChange?.(event); + if (!event.defaultPrevented) { + props.onToggle?.(); + } }; return ( diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 165997f2a..8c3e7b773 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -8,6 +8,7 @@ import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, Back import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { SettingsPanel } from '@/components/shared/SettingsPanel'; +import { Toggle } from '@/components/shared/Toggle'; import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; @@ -3688,6 +3689,7 @@ function HistoryTab() { // Apply filters to get the final alert data const alertData = createMemo(() => { let filtered = severityAndSearchFilteredAlerts(); + const currentTimeFilter = timeFilter(); // Selected bar filter (takes precedence over time filter) if (selectedBarIndex() !== null) { @@ -3700,13 +3702,14 @@ function HistoryTab() { const alertTime = new Date(alert.startTime).getTime(); return alertTime >= bucketStart && alertTime < bucketEnd; }); - } else if (timeFilter() !== 'all') { + } else if (currentTimeFilter !== 'all') { const now = Date.now(); - const cutoff = { + const cutoffMap: Record<'24h' | '7d' | '30d', number> = { '24h': now - 24 * 60 * 60 * 1000, '7d': now - 7 * 24 * 60 * 60 * 1000, '30d': now - 30 * 24 * 60 * 60 * 1000, - }[timeFilter()]; + }; + const cutoff = cutoffMap[currentTimeFilter]; if (cutoff) { filtered = filtered.filter((a) => new Date(a.startTime).getTime() > cutoff); @@ -4217,7 +4220,7 @@ function HistoryTab() {
setSeverityFilter(e.currentTarget.value)} + onChange={(e) => setSeverityFilter(e.currentTarget.value as 'warning' | 'critical' | 'all')} class="px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" > diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index c641dd03b..b4edb30e9 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -21,6 +21,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/updates" internalws "github.com/rcourtman/pulse-go-rewrite/internal/websocket" ) @@ -124,7 +125,8 @@ func TestHealthEndpoint(t *testing.T) { func TestVersionEndpointUsesRepoVersion(t *testing.T) { srv := newIntegrationServer(t) - expected := readExpectedVersion(t) + releaseVersion := readVersionFile(t) + runtimeVersion := readRuntimeVersion(t) res, err := http.Get(srv.server.URL + "/api/version") if err != nil { @@ -151,9 +153,16 @@ func TestVersionEndpointUsesRepoVersion(t *testing.T) { return } - if normalizeVersion(actual) != normalizeVersion(expected) { - t.Fatalf("expected version=%s, got %s", expected, actual) + normalizedActual := normalizeVersion(actual) + if releaseVersion != "" && normalizedActual == normalizeVersion(releaseVersion) { + return } + + if normalizedActual == normalizeVersion(runtimeVersion) { + return + } + + t.Fatalf("expected version to match release %q or runtime %q, got %s", releaseVersion, runtimeVersion, actual) } func TestStateEndpointReturnsMockData(t *testing.T) { @@ -632,19 +641,27 @@ func TestPublicURLDetectionRespectsEnvOverride(t *testing.T) { } } -func readExpectedVersion(t *testing.T) string { +func readVersionFile(t *testing.T) string { t.Helper() - // Try to read VERSION from repository root versionPath := filepath.Join("..", "..", "VERSION") data, err := os.ReadFile(versionPath) if err != nil { - // Fall back to the hard-coded fallback in version manager - return "4.24.0" + return "" } return strings.TrimSpace(string(data)) } +func readRuntimeVersion(t *testing.T) string { + t.Helper() + + info, err := updates.GetCurrentVersion() + if err != nil { + t.Fatalf("failed to determine current version: %v", err) + } + return strings.TrimSpace(info.Version) +} + func normalizeVersion(v string) string { v = strings.TrimSpace(v) v = strings.TrimPrefix(v, "v") diff --git a/internal/monitoring/staleness_tracker.go b/internal/monitoring/staleness_tracker.go index e26a8e2a3..0c16ca866 100644 --- a/internal/monitoring/staleness_tracker.go +++ b/internal/monitoring/staleness_tracker.go @@ -203,23 +203,27 @@ type StalenessSnapshot struct { // Snapshot returns a copy of all staleness data for API exposure. func (t *StalenessTracker) Snapshot() []StalenessSnapshot { - if t == nil { - return nil - } + if t == nil { + return nil + } - t.mu.RLock() - defer t.mu.RUnlock() + t.mu.RLock() + entries := make([]FreshnessSnapshot, 0, len(t.entries)) + for _, entry := range t.entries { + entries = append(entries, entry) + } + t.mu.RUnlock() - result := make([]StalenessSnapshot, 0, len(t.entries)) - for _, entry := range t.entries { - score, _ := t.StalenessScore(entry.InstanceType, entry.Instance) - result = append(result, StalenessSnapshot{ - Instance: entry.Instance, - Type: string(entry.InstanceType), - Score: score, - LastSuccess: entry.LastSuccess, - LastError: entry.LastError, - }) - } - return result + result := make([]StalenessSnapshot, 0, len(entries)) + for _, entry := range entries { + score, _ := t.StalenessScore(entry.InstanceType, entry.Instance) + result = append(result, StalenessSnapshot{ + Instance: entry.Instance, + Type: string(entry.InstanceType), + Score: score, + LastSuccess: entry.LastSuccess, + LastError: entry.LastError, + }) + } + return result } diff --git a/internal/updates/version.go b/internal/updates/version.go index 1375044be..6306be0a3 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -161,7 +161,7 @@ func GetCurrentVersion() (*VersionInfo, error) { } // Final fallback - return buildInfo("4.24.0", "release", false), nil + return buildInfo("4.25.0", "release", false), nil } // normalizeVersionString ensures any version string can be parsed as semantic version. diff --git a/scripts/package-helm-chart.sh b/scripts/package-helm-chart.sh index 3fa11f8a0..27fd3130b 100755 --- a/scripts/package-helm-chart.sh +++ b/scripts/package-helm-chart.sh @@ -11,7 +11,12 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CHART_DIR="$REPO_ROOT/deploy/helm/pulse" -DIST_DIR="$REPO_ROOT/dist" +DIST_DIR_OVERRIDE="${DIST_DIR_OVERRIDE:-${DIST_DIR:-}}" +if [[ -n "$DIST_DIR_OVERRIDE" ]]; then + DIST_DIR="$DIST_DIR_OVERRIDE" +else + DIST_DIR="$REPO_ROOT/dist" +fi HELM_BIN="${HELM_BIN:-helm}" OCI_REPO="${OCI_REPO:-ghcr.io/rcourtman/pulse-chart}"