diff --git a/PegasusMonitor.xcodeproj/project.pbxproj b/PegasusMonitor.xcodeproj/project.pbxproj index 82902fc..59f19ce 100644 --- a/PegasusMonitor.xcodeproj/project.pbxproj +++ b/PegasusMonitor.xcodeproj/project.pbxproj @@ -166,7 +166,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; @@ -174,7 +174,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSPrincipalClass = NSApplication; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.2.1; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -189,7 +189,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 39R6U3437D; ENABLE_APP_SANDBOX = NO; GENERATE_INFOPLIST_FILE = YES; @@ -197,7 +197,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSPrincipalClass = NSApplication; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.2.1; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; diff --git a/PegasusMonitor/MonitorMenuView.swift b/PegasusMonitor/MonitorMenuView.swift index 37089b9..e1f9c36 100644 --- a/PegasusMonitor/MonitorMenuView.swift +++ b/PegasusMonitor/MonitorMenuView.swift @@ -35,7 +35,12 @@ struct MonitorMenuView: View { Spacer() if monitor.results[server.id]?.refreshing == true { ProgressView().controlSize(.mini) } else if let report = monitor.results[server.id]?.report { - Text("\(report.counts.ok) OK").font(.caption2).foregroundStyle(.secondary) + VStack(alignment: .trailing, spacing: 2) { + Text("\(report.counts.ok) OK").font(.caption2) + if let uptime = report.uptimeLabel { + Text("Up \(uptime)").font(.caption2) + } + }.foregroundStyle(.secondary) } if server.id == monitor.selectedID { Image(systemName: "checkmark") } }.padding(.horizontal, 14).padding(.vertical, 8) @@ -55,6 +60,17 @@ struct MonitorMenuView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if let report = monitor.report { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + Text("Uptime").font(.system(size: 12, weight: .medium)) + Spacer() + Text(report.uptimeLabel ?? "Not available") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14).padding(.vertical, 10) + Divider() if !monitor.selectedServices.isEmpty { Text("SERVICES") .font(.caption.bold()).foregroundStyle(.secondary) diff --git a/PegasusMonitor/ServerMonitor.swift b/PegasusMonitor/ServerMonitor.swift index fe2d0b5..1fc100a 100644 --- a/PegasusMonitor/ServerMonitor.swift +++ b/PegasusMonitor/ServerMonitor.swift @@ -15,6 +15,16 @@ struct HealthReport: Codable { let schema: Int let host: String? let generatedAt: String? + let uptimeSeconds: Int? + var uptimeLabel: String? { + guard let uptimeSeconds, uptimeSeconds >= 0 else { return nil } + let days = uptimeSeconds / 86400 + let hours = (uptimeSeconds % 86400) / 3600 + let minutes = (uptimeSeconds % 3600) / 60 + if days > 0 { return "\(days)d \(hours)h \(minutes)m" } + if hours > 0 { return "\(hours)h \(minutes)m" } + return "\(minutes)m" + } let overall: HealthStatus let counts: HealthCounts let groups: [HealthGroup] @@ -190,7 +200,7 @@ final class ServerMonitor: ObservableObject { let checks = report.checks + extra let counts = HealthCounts(ok: report.counts.ok, warn: report.counts.warn, fail: report.counts.fail + extra.count) let groups = report.groups + [HealthGroup(name: "Required services", status: .fail, missing: extra.map(\.name))] - return HealthReport(schema: report.schema, host: report.host, generatedAt: report.generatedAt, overall: .fail, + return HealthReport(schema: report.schema, host: report.host, generatedAt: report.generatedAt, uptimeSeconds: report.uptimeSeconds, overall: .fail, counts: counts, groups: groups, checks: checks, diagnostics: report.diagnostics, services: report.services) } var status: HealthStatus { diff --git a/docs/RELEASE-1.2.1.md b/docs/RELEASE-1.2.1.md new file mode 100644 index 0000000..cf52886 --- /dev/null +++ b/docs/RELEASE-1.2.1.md @@ -0,0 +1,26 @@ +# Pegasus Monitor 1.2.1 (Build 4) — Server uptime + +- Display Linux server uptime beneath each server's health count in the menu-bar server list. +- Show an **Uptime** row in the selected-server overview. +- Use compact days, hours and minutes; for example `12d 4h 8m`. +- Include `uptimeSeconds` in JSON from both `server/remote-health-json` and `server/pegasus-health-json`, obtained from `/proc/uptime` without elevated privileges. +- Remain compatible with older monitoring agents: uptime shows **Not available** until the agent is updated. +- Preserve 1.2.0 service discovery, required service rules, configuration, signing and notarization scripts. + +## Deploy the updated agent + +For ordinary Ubuntu/Debian servers: + +```sh +scp server/remote-health-json USER@HOST:/tmp/pegasus-monitor-health-json +ssh USER@HOST 'sudo install -m 755 /tmp/pegasus-monitor-health-json /usr/local/bin/pegasus-monitor-health-json' +``` + +For Pegasus's specialised agent: + +```sh +scp server/pegasus-health-json mag@192.168.1.2:/tmp/pegasus-health-json +ssh mag@192.168.1.2 'sudo install -m 755 /tmp/pegasus-health-json /usr/local/bin/pegasus-health-json' +``` + +Keep the original SSH command configuration for each server. Refresh the Mac application after agent updates. diff --git a/server/pegasus-health-json b/server/pegasus-health-json index 1a42fdf..748c8db 100644 --- a/server/pegasus-health-json +++ b/server/pegasus-health-json @@ -2,6 +2,15 @@ """Read-only JSON adapter for an existing pegasus-health --verbose command.""" import datetime import json + +def system_uptime_seconds(): + """Read Linux kernel uptime without privilege or external commands.""" + try: + with open('/proc/uptime', encoding='ascii') as handle: + return max(0, int(float(handle.read().split()[0]))) + except (OSError, ValueError, IndexError): + return None + import re import subprocess import sys @@ -56,7 +65,7 @@ def output(): if not checks: counts['fail'] = max(1, counts['fail']) overall = 'fail' if counts['fail'] or any(g['status']=='fail' for g in groups) else 'warn' if counts['warn'] or any(g['status']=='warn' for g in groups) else 'ok' - print(json.dumps({'schema':1, 'host':'pegasus', 'generatedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'overall':overall, 'counts':counts,'groups':groups,'checks':checks, 'diagnostics':plain[-24000:], 'commandExitCode':p.returncode}, ensure_ascii=False)) + print(json.dumps({'schema':1, 'host':'pegasus', 'uptimeSeconds':system_uptime_seconds(), 'generatedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'overall':overall, 'counts':counts,'groups':groups,'checks':checks, 'diagnostics':plain[-24000:], 'commandExitCode':p.returncode}, ensure_ascii=False)) return {'ok':0,'warn':1,'fail':2}[overall] if __name__ == '__main__': diff --git a/server/remote-health-json b/server/remote-health-json index eed2714..8363527 100755 --- a/server/remote-health-json +++ b/server/remote-health-json @@ -5,6 +5,15 @@ No third-party Python packages required. """ import datetime import json + +def system_uptime_seconds(): + """Read Linux kernel uptime without privilege or external commands.""" + try: + with open('/proc/uptime', encoding='ascii') as handle: + return max(0, int(float(handle.read().split()[0]))) + except (OSError, ValueError, IndexError): + return None + import os import shutil import socket @@ -115,4 +124,5 @@ for section in dict.fromkeys(c['section'] for c in checks): counts = {s: sum(c['status'] == s for c in checks) for s in ('ok', 'warn', 'fail')} overall = 'fail' if counts['fail'] else 'warn' if counts['warn'] else 'ok' print(json.dumps(dict(schema=1, host=host, generatedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(), - overall=overall, counts=counts, groups=groups, checks=checks, diagnostics=None, services=services))) + overall=overall, counts=counts, groups=groups, checks=checks, diagnostics=None, services=services, + uptimeSeconds=system_uptime_seconds())))