diff --git a/apps/macos/.gitignore b/apps/macos/.gitignore new file mode 100644 index 00000000..f0b71e23 --- /dev/null +++ b/apps/macos/.gitignore @@ -0,0 +1,2 @@ +.build/ +build/ diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 00000000..2c6a2bd9 --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "StackMenu", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget(name: "StackMenu", path: "Sources/StackMenu"), + ] +) diff --git a/apps/macos/README.md b/apps/macos/README.md new file mode 100644 index 00000000..209bdd00 --- /dev/null +++ b/apps/macos/README.md @@ -0,0 +1,74 @@ +# Menu bar app (beta) + +A native macOS menu bar app for the stack on this Mac. **Overview** answers, +in this order: is everything fine, what needs me, what is running, what +broke recently, and how full is the machine. **Setup** shows how the stack is +configured, what is not installed yet, and which checkout the app uses. + +- **Needs attention**: `stack doctor` findings, a backup that failed or is + overdue (when the beta backup stacklet is installed), a disk above 90%. Each item carries its fix command; `up`, + `restart` and `down` run in the background, anything else opens in Terminal. +- **Stacklets**: state, memory, port; start, stop, restart, open, logs. +- **Errors**: containers that logged errors in the last 24 hours, newest first. +- **This Mac**: disk, memory, uptime. +- **Setup**: family members, address mode, language, AI, update time, data + dir, from `stack config --json` (credentials hidden), with buttons that open + `stack.toml` and `users.toml` in the text editor. + +The menu bar shows the famstack mark in monochrome, as macOS expects. It +gets a badge dot only for errors: a failing stacklet, a doctor error, a +failed backup. Warnings stay in the panel. Its dots turn hollow while an +action runs, and the mark dims when the CLI does not answer. + +The mark is drawn from `Sources/StackMenu/LogoOutline.swift`, generated +once from Inter Bold by `script/logo-outline.py`; the app icon is rendered +from the same drawing at build time. + +It is a client of the `stack` CLI and nothing else: `status --json` every +minute, and `doctor --json`, `errors --json`, `host --json`, `config --json` +and `backup status` when the panel opens and every ten minutes. It starts no services and +keeps no state, so the CLI stays the one owner of the lifecycle. Against a +checkout older than those commands, the sections that need them are left out. + +## Build and run + +Needs macOS 14 and the Command Line Tools; no Xcode. + +```bash +./stack app install # builds, puts it in /Applications, opens it +``` + +The build is signed ad hoc, for the Mac it was built on. After +`./stack update`, run `./stack app install` again. For development, +`apps/macos/build.sh` builds `build/famstack.app` without installing it. + +To check a layout change without opening the menu, render both tabs from the +real data to PNG files (`-overview.png`, `-setup.png`): + +```bash +apps/macos/build/famstack.app/Contents/MacOS/StackMenu --snapshot /tmp/panel +``` + +## Which machine + +**Setup → Connection** switches between a checkout on this Mac and one on +another Mac, and remembers both. + +- **This Mac**: the folder you run `./stack` from, chosen with **Change…**. + Without a choice the app tries `~/famstack`. +- **Remote**: an SSH host (an alias from `~/.ssh/config` or `user@host`) and + the checkout path on it, `~/famstack` unless you say otherwise. The app + runs `ssh 'zsh -lc "cd && ./stack …"'` with your SSH key + and never asks for a password, so the key must work without one (or be in + the agent). The server needs **Remote Login** turned on. Logs, doctor and + editing open Terminal with an SSH session to that Mac; stacklets open at the + address the stack names for them, its LAN address in port mode. + +Every call gives up after a minute, lifecycle actions after fifteen, so an +unreachable machine shows an error instead of a panel that never updates. + +To set the local checkout from a shell: + +```bash +defaults write dev.famstack.menubar checkout /path/to/famstack +``` diff --git a/apps/macos/Sources/StackMenu/App.swift b/apps/macos/Sources/StackMenu/App.swift new file mode 100644 index 00000000..642d8d30 --- /dev/null +++ b/apps/macos/Sources/StackMenu/App.swift @@ -0,0 +1,111 @@ +import AppKit +import SwiftUI + +@main +struct StackMenuApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @StateObject private var store = StackStore() + + /// The tool modes render and quit. They must not put a second icon in + /// the menu bar next to the running app's. + private static let isTool = CommandLine.arguments.contains("--snapshot") + + init() { + // Used by build.sh to make the bundle's icon from the same drawing. + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--icon"), i + 1 < args.count { + do { + try Logo.writeIconset(to: URL(fileURLWithPath: args[i + 1])) + exit(0) + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) + } + } + } + + var body: some Scene { + MenuBarExtra(isInserted: .constant(!Self.isTool)) { + MenuPanel(store: store) + } label: { + Image(nsImage: Logo.menuBarImage(store.summary.logo)) + } + .menuBarExtraStyle(.window) + } +} + +/// A menu bar app has no Dock icon. The bundle says so with LSUIElement; +/// setting the policy here as well keeps `swift run` from showing one. +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--snapshot"), i + 1 < args.count { + Task { @MainActor in await Snapshot.write(prefix: args[i + 1]) } + } + } +} + +// ── Snapshots ───────────────────────────────────────────────────────────── +// +// `StackMenu --snapshot /tmp/panel` loads the real data, renders each tab +// into `/tmp/panel-overview.png` and `/tmp/panel-setup.png`, and quits. The +// panel is drawn in an offscreen window of the app's own, so no screen +// recording permission is needed, and a layout change can be checked +// without opening the menu. + +@MainActor +enum Snapshot { + static func write(prefix: String) async { + let store = StackStore() + // The store starts loading on its own; wait for the slow half, but + // not past the read timeout: a machine that does not answer renders + // as the panel shows it then. + let deadline = Date().addingTimeInterval(StackCLI.readTimeout + 10) + while store.checkedAt == nil && store.connection != nil && Date() < deadline { + try? await Task.sleep(for: .milliseconds(200)) + } + render(menuBarStates, to: URL(fileURLWithPath: "\(prefix)-menubar.png")) + for tab in PanelTab.allCases { + let view = MenuPanel(store: store, tab: tab, scrolls: false) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, .dark) + let url = URL(fileURLWithPath: "\(prefix)-\(tab.rawValue.lowercased()).png") + render(view, to: url) + } + NSApp.terminate(nil) + } + + /// Every icon state on a light and a dark bar, tinted as macOS tints + /// a template image. + private static var menuBarStates: some View { + let states: [Logo.State] = [.normal, .attention, .busy, .unreachable] + return VStack(spacing: 0) { + ForEach([Color.white, Color(white: 0.16)], id: \.self) { bar in + HStack(spacing: 18) { + ForEach(states.indices, id: \.self) { i in + Image(nsImage: Logo.menuBarImage(states[i])) + .renderingMode(.template) + .foregroundStyle(bar == .white ? Color.black : Color.white) + } + } + .padding(.horizontal, 14).padding(.vertical, 5) + .background(bar) + } + } + .scaleEffect(3, anchor: .topLeading) + .frame(width: 3 * 150, height: 3 * 56, alignment: .topLeading) + } + + private static func render(_ view: some View, to url: URL) { + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: .darkAqua) + host.frame.size = host.fittingSize + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + host.layoutSubtreeIfNeeded() + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return } + host.cacheDisplay(in: host.bounds, to: rep) + try? rep.representation(using: .png, properties: [:])?.write(to: url) + } +} diff --git a/apps/macos/Sources/StackMenu/Logo.swift b/apps/macos/Sources/StackMenu/Logo.swift new file mode 100644 index 00000000..de096961 --- /dev/null +++ b/apps/macos/Sources/StackMenu/Logo.swift @@ -0,0 +1,129 @@ +import AppKit + +// ── The famstack mark ───────────────────────────────────────────────────── +// +// An "a" with two dots below it, on favicon.svg's 32-unit grid (y down). Two +// renderings: a monochrome template for the menu bar, which macOS tints to +// match it, and the coloured tile for the app icon. + +enum Logo { + private static let dotRadius: CGFloat = 2.8 + private static let dotCentres = [CGPoint(x: 11.5, y: 28), CGPoint(x: 20.5, y: 28)] + + private static let orange = NSColor(srgbRed: 0xF0 / 255, green: 0x7D / 255, blue: 0x45 / 255, alpha: 1) + private static let teal = NSColor(srgbRed: 0x3D / 255, green: 0x8F / 255, blue: 0xA0 / 255, alpha: 1) + private static let tile = NSColor(srgbRed: 0x16 / 255, green: 0x1F / 255, blue: 0x24 / 255, alpha: 1) + + /// The mark's own extent on the grid: the letter and both dots. + private static var markBounds: CGRect { + dotCentres.reduce(LogoOutline.letterA.boundingBoxOfPath) { + $0.union(CGRect(x: $1.x - dotRadius, y: $1.y - dotRadius, width: 2 * dotRadius, height: 2 * dotRadius)) + } + } + + // ── Menu bar ───────────────────────────────────────────────────────── + + enum State { case normal, attention, busy, unreachable } + + /// The mark as a template image. The state stays monochrome: a badge dot + /// when something needs attention, hollow dots while an action runs, + /// dimmed when the CLI does not answer. + static func menuBarImage(_ state: State) -> NSImage { + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size, flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + let mark = markBounds + let scale = (rect.height - 2) / mark.height + ctx.saveGState() + ctx.translateBy(x: (rect.width - mark.width * scale) / 2 - mark.minX * scale, + y: 1 - mark.minY * scale) + ctx.scaleBy(x: scale, y: scale) + ctx.setAlpha(state == .unreachable ? 0.35 : 1) + ctx.setFillColor(NSColor.black.cgColor) + ctx.setStrokeColor(NSColor.black.cgColor) + ctx.addPath(LogoOutline.letterA) + ctx.fillPath() + for centre in dotCentres { + if state == .busy { + ctx.setLineWidth(1.3) + ctx.strokeEllipse(in: dotRect(centre, inset: 0.65)) + } else { + ctx.fillEllipse(in: dotRect(centre)) + } + } + ctx.restoreGState() + if state == .attention { + // A gap around the badge keeps it apart from the letter. + let badge = CGRect(x: rect.maxX - 5.5, y: 0.5, width: 5, height: 5) + ctx.setBlendMode(.clear) + ctx.fillEllipse(in: badge.insetBy(dx: -1.2, dy: -1.2)) + ctx.setBlendMode(.normal) + ctx.setFillColor(NSColor.black.cgColor) + ctx.fillEllipse(in: badge) + } + return true + } + image.isTemplate = true + return image + } + + private static func dotRect(_ centre: CGPoint, inset: CGFloat = 0) -> CGRect { + CGRect(x: centre.x - dotRadius, y: centre.y - dotRadius, width: 2 * dotRadius, height: 2 * dotRadius) + .insetBy(dx: inset, dy: inset) + } + + // ── App icon ───────────────────────────────────────────────────────── + + /// The favicon's tile as a macOS app icon: the body takes 824 of 1024 + /// points with Apple's corner radius, the mark keeps its place on it. + static func drawAppIcon(in ctx: CGContext, side: CGFloat) { + let unit = side / 1024 + let body = CGRect(x: 100 * unit, y: 100 * unit, width: 824 * unit, height: 824 * unit) + ctx.addPath(CGPath(roundedRect: body, cornerWidth: 185 * unit, cornerHeight: 185 * unit, transform: nil)) + ctx.setFillColor(tile.cgColor) + ctx.fillPath() + + ctx.saveGState() + ctx.translateBy(x: body.minX, y: body.minY) + ctx.scaleBy(x: body.width / 32, y: body.height / 32) + ctx.addPath(LogoOutline.letterA) + ctx.setFillColor(orange.cgColor) + ctx.fillPath() + ctx.setFillColor(teal.cgColor) + for centre in dotCentres { ctx.fillEllipse(in: dotRect(centre)) } + ctx.restoreGState() + } + + /// The app icon as an image, for the panel header. + static func appIcon(side: CGFloat) -> NSImage { + NSImage(size: NSSize(width: side, height: side), flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + drawAppIcon(in: ctx, side: rect.width) + return true + } + } + + /// Writes an `.iconset` folder; `iconutil -c icns` turns it into the + /// bundle's icon (build.sh does). + static func writeIconset(to folder: URL) throws { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + for points in [16, 32, 128, 256, 512] { + for scale in [1, 2] { + let pixels = points * scale + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels, bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: rep) else { continue } + // Flip to the grid's y-down orientation. + let ctx = context.cgContext + ctx.translateBy(x: 0, y: CGFloat(pixels)) + ctx.scaleBy(x: 1, y: -1) + drawAppIcon(in: ctx, side: CGFloat(pixels)) + context.flushGraphics() + let name = scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png" + try rep.representation(using: .png, properties: [:])?.write(to: folder.appendingPathComponent(name)) + } + } + } +} diff --git a/apps/macos/Sources/StackMenu/LogoOutline.swift b/apps/macos/Sources/StackMenu/LogoOutline.swift new file mode 100644 index 00000000..0eab7a4b --- /dev/null +++ b/apps/macos/Sources/StackMenu/LogoOutline.swift @@ -0,0 +1,61 @@ +import CoreGraphics + +// Generated by script/logo-outline.py from Inter Bold (SIL Open Font License +// 1.1). The "a" of the famstack mark on favicon.svg's 32-unit grid, y down. + +enum LogoOutline { + static let letterA: CGPath = { + let path = CGMutablePath() + path.move(to: CGPoint(x: 13.891, y: 22.296)) + path.addQuadCurve(to: CGPoint(x: 11.281, y: 21.783), control: CGPoint(x: 12.434, y: 22.296)) + path.addQuadCurve(to: CGPoint(x: 9.468, y: 20.253), control: CGPoint(x: 10.128, y: 21.269)) + path.addQuadCurve(to: CGPoint(x: 8.807, y: 17.726), control: CGPoint(x: 8.807, y: 19.237)) + path.addQuadCurve(to: CGPoint(x: 9.275, y: 15.586), control: CGPoint(x: 8.807, y: 16.443)) + path.addQuadCurve(to: CGPoint(x: 10.556, y: 14.205), control: CGPoint(x: 9.743, y: 14.729)) + path.addQuadCurve(to: CGPoint(x: 12.403, y: 13.409), control: CGPoint(x: 11.369, y: 13.682)) + path.addQuadCurve(to: CGPoint(x: 14.570, y: 13.029), control: CGPoint(x: 13.437, y: 13.136)) + path.addQuadCurve(to: CGPoint(x: 16.706, y: 12.778), control: CGPoint(x: 15.893, y: 12.895)) + path.addQuadCurve(to: CGPoint(x: 17.900, y: 12.409), control: CGPoint(x: 17.520, y: 12.660)) + path.addQuadCurve(to: CGPoint(x: 18.280, y: 11.664), control: CGPoint(x: 18.280, y: 12.158)) + path.addLine(to: CGPoint(x: 18.280, y: 11.597)) + path.addQuadCurve(to: CGPoint(x: 18.002, y: 10.489), control: CGPoint(x: 18.280, y: 10.942)) + path.addQuadCurve(to: CGPoint(x: 17.190, y: 9.796), control: CGPoint(x: 17.724, y: 10.037)) + path.addQuadCurve(to: CGPoint(x: 15.878, y: 9.555), control: CGPoint(x: 16.656, y: 9.555)) + path.addQuadCurve(to: CGPoint(x: 14.507, y: 9.793), control: CGPoint(x: 15.094, y: 9.555)) + path.addQuadCurve(to: CGPoint(x: 13.545, y: 10.430), control: CGPoint(x: 13.919, y: 10.032)) + path.addQuadCurve(to: CGPoint(x: 13.005, y: 11.324), control: CGPoint(x: 13.170, y: 10.828)) + path.addLine(to: CGPoint(x: 9.245, y: 10.700)) + path.addQuadCurve(to: CGPoint(x: 10.550, y: 8.435), control: CGPoint(x: 9.633, y: 9.365)) + path.addQuadCurve(to: CGPoint(x: 12.829, y: 7.014), control: CGPoint(x: 11.467, y: 7.505)) + path.addQuadCurve(to: CGPoint(x: 15.891, y: 6.523), control: CGPoint(x: 14.191, y: 6.523)) + path.addQuadCurve(to: CGPoint(x: 18.283, y: 6.815), control: CGPoint(x: 17.121, y: 6.523)) + path.addQuadCurve(to: CGPoint(x: 20.369, y: 7.727), control: CGPoint(x: 19.446, y: 7.107)) + path.addQuadCurve(to: CGPoint(x: 21.836, y: 9.333), control: CGPoint(x: 21.293, y: 8.348)) + path.addQuadCurve(to: CGPoint(x: 22.380, y: 11.710), control: CGPoint(x: 22.380, y: 10.317)) + path.addLine(to: CGPoint(x: 22.380, y: 22.000)) + path.addLine(to: CGPoint(x: 18.490, y: 22.000)) + path.addLine(to: CGPoint(x: 18.490, y: 19.878)) + path.addLine(to: CGPoint(x: 18.356, y: 19.878)) + path.addQuadCurve(to: CGPoint(x: 17.371, y: 21.133), control: CGPoint(x: 17.985, y: 20.588)) + path.addQuadCurve(to: CGPoint(x: 15.891, y: 21.987), control: CGPoint(x: 16.756, y: 21.678)) + path.addQuadCurve(to: CGPoint(x: 13.891, y: 22.296), control: CGPoint(x: 15.027, y: 22.296)) + path.closeSubpath() + path.move(to: CGPoint(x: 15.045, y: 19.421)) + path.addQuadCurve(to: CGPoint(x: 16.747, y: 19.036), control: CGPoint(x: 16.014, y: 19.421)) + path.addQuadCurve(to: CGPoint(x: 17.895, y: 17.995), control: CGPoint(x: 17.480, y: 18.650)) + path.addQuadCurve(to: CGPoint(x: 18.309, y: 16.536), control: CGPoint(x: 18.309, y: 17.340)) + path.addLine(to: CGPoint(x: 18.309, y: 14.882)) + path.addQuadCurve(to: CGPoint(x: 17.756, y: 15.118), control: CGPoint(x: 18.122, y: 15.008)) + path.addQuadCurve(to: CGPoint(x: 16.941, y: 15.316), control: CGPoint(x: 17.391, y: 15.228)) + path.addQuadCurve(to: CGPoint(x: 16.052, y: 15.467), control: CGPoint(x: 16.491, y: 15.403)) + path.addQuadCurve(to: CGPoint(x: 15.273, y: 15.579), control: CGPoint(x: 15.613, y: 15.531)) + path.addQuadCurve(to: CGPoint(x: 13.944, y: 15.926), control: CGPoint(x: 14.517, y: 15.681)) + path.addQuadCurve(to: CGPoint(x: 13.053, y: 16.580), control: CGPoint(x: 13.371, y: 16.170)) + path.addQuadCurve(to: CGPoint(x: 12.736, y: 17.606), control: CGPoint(x: 12.736, y: 16.989)) + path.addQuadCurve(to: CGPoint(x: 13.036, y: 18.603), control: CGPoint(x: 12.736, y: 18.199)) + path.addQuadCurve(to: CGPoint(x: 13.854, y: 19.214), control: CGPoint(x: 13.335, y: 19.007)) + path.addQuadCurve(to: CGPoint(x: 15.045, y: 19.421), control: CGPoint(x: 14.373, y: 19.421)) + path.closeSubpath() + return path + }() +} diff --git a/apps/macos/Sources/StackMenu/MenuPanel.swift b/apps/macos/Sources/StackMenu/MenuPanel.swift new file mode 100644 index 00000000..b4bf662e --- /dev/null +++ b/apps/macos/Sources/StackMenu/MenuPanel.swift @@ -0,0 +1,764 @@ +import SwiftUI + +// ── The panel under the menu bar icon ───────────────────────────────────── +// +// Two tabs. Overview is ordered by the questions an admin opens the panel +// with: is everything fine, what needs me, what is running, what broke +// recently, how full is the machine. Setup holds what changes rarely: the +// configuration, the stacklets not installed, which checkout this is. + +enum PanelTab: String, CaseIterable { + case overview = "Overview" + case setup = "Setup" +} + +struct MenuPanel: View { + @ObservedObject var store: StackStore + @State var tab: PanelTab = .overview + /// Off for a snapshot, which renders the whole panel at its full height. + var scrolls = true + @State private var contentHeight: CGFloat = 400 + + private static let width: CGFloat = 360 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + Picker("", selection: $tab) { + ForEach(PanelTab.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + if scrolls { + ScrollView { + content + .background(GeometryReader { geo in + Color.clear.preference(key: HeightKey.self, value: geo.size.height) + }) + } + .frame(height: min(contentHeight, Self.maxContentHeight)) + .onPreferenceChange(HeightKey.self) { contentHeight = $0 } + } else { + content + } + footer + } + .padding(14) + .frame(width: Self.width) + .task { await store.panelOpened() } + } + + /// The panel never grows past the screen; beyond that it scrolls. + private static var maxContentHeight: CGFloat { + (NSScreen.main?.visibleFrame.height ?? 900) - 180 + } + + @ViewBuilder private var content: some View { + VStack(alignment: .leading, spacing: 14) { + if store.connection == nil { + Notice(text: store.mode == .local + ? "No checkout found. Choose the folder you run ./stack from in Setup." + : "No remote host yet. Set one in Setup.", tint: .orange) + } + if let banner = store.banner { + BannerView(banner: banner, store: store) + } + if let problem = store.loadProblem { + ProblemNotice(problem: problem, since: store.status == nil ? nil : store.statusAt, store: store) + } + switch tab { + case .overview: overview + case .setup: setup + } + } + } + + // ── Header: the verdict ────────────────────────────────────────────── + + private var header: some View { + HStack(alignment: .center, spacing: 10) { + Image(nsImage: Logo.appIcon(side: 34)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text("famstack").font(.headline) + if store.status != nil { verdict } + } + if let status = store.status { + Text(subtitle(status)) + .font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + Spacer() + if store.refreshing || store.checking { + ProgressView().controlSize(.small) + } else { + Button { + Task { + await store.refresh() + await store.check() + } + } label: { Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .help("Check again") + } + } + } + + private var lookAtCount: (count: Int, worst: Bool) { + let items = store.attention.filter { $0.level >= .warn } + let failing = store.status?.installed.filter { $0.health.needsAttention }.count ?? 0 + return (items.count + failing, failing > 0 || items.contains { $0.level == .error }) + } + + private var verdict: some View { + let (count, worst) = lookAtCount + return Text(count == 0 ? "All good" : "\(count) to look at") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 7).padding(.vertical, 2) + .background((count == 0 ? Color.green : worst ? .red : .orange).opacity(0.2), in: Capsule()) + } + + private func subtitle(_ status: StackStatus) -> String { + let running = status.installed.filter { $0.online }.count + var parts = ["\(running) of \(status.installed.count) running"] + if let up = store.host?.uptimeSeconds { parts.append("up \(Self.uptime(up))") } + if let connection = store.connection, connection.isRemote { parts.append("on \(connection.label)") } + return parts.joined(separator: " · ") + } + + /// Hours for the first day, then days: "up 0 days" said nothing. + private static func uptime(_ seconds: Int) -> String { + let days = seconds / 86400 + if days == 0 { + let hours = max(1, seconds / 3600) + return hours == 1 ? "1 hour" : "\(hours) hours" + } + return days == 1 ? "1 day" : "\(days) days" + } + + // ── Overview ───────────────────────────────────────────────────────── + + @ViewBuilder private var overview: some View { + if let status = store.status { + let attention = store.attention + if !attention.isEmpty || !status.stale.isEmpty { + Card("Needs attention") { + ForEach(attention) { AttentionRow(item: $0, store: store) } + if !status.stale.isEmpty { staleRow(status.stale) } + } + } + Card("Stacklets", spacing: 2) { + ForEach(status.installed) { stacklet in + StackletRow(stacklet: stacklet, memory: store.memory(of: stacklet.id), store: store) + } + } + if let errors = store.errors { + Card("Errors, last \(errors.since)") { ErrorsList(report: errors, store: store) } + } + if let line = backupLine(status) { + Card("Backup") { Text(line).font(.callout) } + } + if let figures = store.hostFigures { + Card(store.connection?.isRemote == true ? store.remoteHost : "This Mac") { + HostMetrics(figures: figures) + } + } + if !store.reportProblems.isEmpty { + ReportProblems(problems: store.reportProblems) + } + } else if store.connection != nil && store.loadProblem == nil { + HStack { Spacer(); ProgressView(); Spacer() }.padding(.vertical, 30) + } + } + + private func staleRow(_ stale: [String]) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.up.circle.fill").foregroundStyle(.blue).frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text("New code is not running yet").font(.callout) + Text(stale.joined(separator: ", ")).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if store.running?.key == "" { + ProgressView().controlSize(.small) + } else { + Button("Restart") { Task { await store.perform("restart") } } + .controlSize(.small) + .disabled(store.running != nil) + } + } + } + + /// The healthy case only; anything wrong with backups is in the + /// attention list, which says what to do about it. + private func backupLine(_ status: StackStatus) -> String? { + guard status.isInstalled("backup"), let report = store.backup else { return nil } + let lines = report.targets.compactMap { target -> String? in + guard let run = target.lastRun, run.success, + let ended = run.endedAt.flatMap(parseTimestamp) else { return nil } + return "Last backup to \(target.disk) \(relative(ended))" + } + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + // ── Setup ──────────────────────────────────────────────────────────── + + @ViewBuilder private var setup: some View { + if let config = store.config { + Card("Configuration") { SetupGrid(report: config, store: store) } + } + if let status = store.status, !status.available.isEmpty { + Card("Not installed", spacing: 2) { + ForEach(status.available) { stacklet in + HStack { + Text(stacklet.name) + Spacer() + Button("Install…") { store.openInTerminal(["up", stacklet.id]) } + .controlSize(.small) + .help("Opens Terminal: the first start asks setup questions") + } + .padding(.vertical, 3) + } + } + } + Card("Connection") { + Picker("", selection: Binding(get: { store.mode }, set: { store.setMode($0) })) { + Text("This Mac").tag(StackStore.Mode.local) + Text("Remote").tag(StackStore.Mode.remote) + } + .pickerStyle(.segmented) + .labelsHidden() + HStack { + VStack(alignment: .leading, spacing: 3) { + switch store.mode { + case .local: + Text(store.checkout?.path ?? "No checkout chosen") + .font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + .foregroundStyle(store.checkout == nil ? .secondary : .primary) + case .remote: + Text(store.remoteHost.isEmpty ? "No host set" : store.remoteHost) + .font(.callout.monospaced()).lineLimit(1) + .foregroundStyle(store.remoteHost.isEmpty ? .secondary : .primary) + Text(store.remoteCheckout) + .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + .truncationMode(.middle) + } + if let version = store.status?.version { + Text(version).font(.caption.monospaced()).foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + Spacer() + Button("Change…") { + if store.mode == .local { store.chooseCheckout() } else { store.editRemote() } + } + .controlSize(.small) + } + } + } + + // ── Notices and footer ─────────────────────────────────────────────── + + private var footer: some View { + HStack(spacing: 14) { + Button("Doctor") { store.openInTerminal(["doctor"]) } + .disabled(store.connection == nil) + Spacer() + if let checked = store.checkedAt { + Text("checked \(relative(checked))").font(.caption).foregroundStyle(.tertiary) + } + Button("Quit") { NSApp.terminate(nil) } + } + .buttonStyle(.borderless) + .font(.callout) + } +} + +private struct HeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + +// ── Building blocks ─────────────────────────────────────────────────────── + +/// A titled group on a faint rounded background, as in Control Center. +struct Card: View { + let title: String + let spacing: CGFloat + @ViewBuilder let content: Content + + init(_ title: String, spacing: CGFloat = 10, @ViewBuilder content: () -> Content) { + self.title = title + self.spacing = spacing + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: spacing) { content } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + } +} + +struct AttentionRow: View { + let item: Attention + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon).foregroundStyle(tint).frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text(item.title).font(.callout) + .fixedSize(horizontal: false, vertical: true) + Text(item.detail).font(.caption).foregroundStyle(.secondary).lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + if let fix = item.fix { + Button { store.apply(fix: fix) } label: { + Text(fix).font(.caption.monospaced()) + } + .buttonStyle(.link) + .help("Run \(fix)") + } + } + Spacer(minLength: 0) + } + } + + private var icon: String { + switch item.level { + case .error: "xmark.octagon.fill" + case .warn: "exclamationmark.triangle.fill" + case .info: "info.circle.fill" + } + } + + private var tint: Color { + switch item.level { + case .error: .red + case .warn: .orange + case .info: .secondary + } + } +} + +struct StackletRow: View { + let stacklet: Stacklet + let memory: Int? + @ObservedObject var store: StackStore + @State private var hovering = false + + var body: some View { + HStack(spacing: 10) { + Circle().fill(stacklet.health.color).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(stacklet.name) + ForEach(stacklet.healthIssues, id: \.self) { issue in + Text(issue).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + Spacer() + if let running = store.running, running.key == stacklet.id { + RunningLabel(action: running) + } else { + Text(detail) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + actions + } + } + .padding(.vertical, 5) + .padding(.horizontal, 6) + .background(hovering ? Color.primary.opacity(0.08) : .clear, in: RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onHover { hovering = $0 } + .onTapGesture { if store.canOpen(stacklet) { store.openInBrowser(stacklet) } } + .help(store.canOpen(stacklet) ? "Open \(stacklet.url ?? "in the browser")" : "") + } + + /// Memory while it runs, otherwise the state. The port is one hover away + /// in the menu; the memory is what an admin compares across rows. + private var detail: String { + if stacklet.health == .healthy, let memory, memory > 0 { + return ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory) + } + return stacklet.health == .healthy ? "" : stacklet.health.label + } + + private var actions: some View { + Menu { + if store.canOpen(stacklet) { + Button("Open in Browser") { store.openInBrowser(stacklet) } + Divider() + } + if stacklet.health == .down { + Button("Start") { Task { await store.perform("up", stacklet.id) } } + } else { + Button("Restart") { Task { await store.perform("restart", stacklet.id) } } + Button("Stop") { Task { await store.perform("down", stacklet.id) } } + } + Divider() + Button("Logs") { store.openInTerminal(["logs", stacklet.id]) } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(store.running != nil) + .help(store.running != nil ? "Another action is running" : "") + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} + +/// "restarting 0:42" while a lifecycle action runs, so a slow `up` that is +/// pulling images reads as working rather than hung. +struct RunningLabel: View { + let action: StackStore.RunningAction + + var body: some View { + TimelineView(.periodic(from: action.startedAt, by: 1)) { context in + let seconds = max(0, Int(context.date.timeIntervalSince(action.startedAt))) + Text("\(Self.progressive(action.verb)) \(seconds / 60):\(String(format: "%02d", seconds % 60))") + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + ProgressView().controlSize(.small) + } + + private static func progressive(_ verb: String) -> String { + switch verb { + case "up": "starting" + case "down": "stopping" + case "restart": "restarting" + default: verb + } + } +} + +/// An action's outcome, or a choice that could not be applied. +struct BannerView: View { + let banner: StackStore.Banner + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon).foregroundStyle(tint).frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + Text(banner.title).font(.callout.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + if let hint = banner.hint { + Text(.init(hint)).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let detail = banner.detail { + Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(6).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + if banner.kind != .success { + HStack(spacing: 12) { + if let terminal = banner.terminal { + Button(terminal.label) { store.openTerminal(terminal) } + } + if let retry = banner.retry { + Button("Run in Terminal") { store.openInTerminal(retry); store.banner = nil } + } + Button("Dismiss") { store.banner = nil } + } + .buttonStyle(.link).font(.caption) + } + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } + + private var icon: String { + switch banner.kind { + case .success: "checkmark.circle.fill" + case .info: "clock.fill" + case .failure: "xmark.octagon.fill" + } + } + + private var tint: Color { + switch banner.kind { + case .success: .green + case .info: .blue + case .failure: .red + } + } +} + +/// Why the stack cannot be read right now. With an older answer on screen, +/// it says how old, so nothing below reads as current. +struct ProblemNotice: View { + let problem: Problem + let since: Date? + @ObservedObject var store: StackStore + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange).frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + Text(problem.title).font(.callout.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + if let hint = problem.hint { + Text(.init(hint)).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let detail = problem.detail { + Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(4).textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + if let since { + Text("Below: the state from \(relative(since)).").font(.caption).foregroundStyle(.secondary) + } + if let terminal = problem.terminal { + Button(terminal.label) { store.openTerminal(terminal) } + .buttonStyle(.link).font(.caption) + } + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } +} + +/// Sections that could not be read, named, instead of silently missing. The +/// same cause is said once: an older checkout lacks several reports at once. +struct ReportProblems: View { + let problems: [(report: String, problem: Problem)] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Label("Not shown: \(problems.map(\.report).joined(separator: ", "))", systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary) + ForEach(causes, id: \.self) { cause in + Text(.init(cause)).font(.caption).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 4) + } + + private var causes: [String] { + var seen: [String] = [] + for (_, problem) in problems { + let line = [problem.title, problem.hint].compactMap { $0 }.joined(separator: ". ") + if !seen.contains(line) { seen.append(line) } + } + return seen + } +} + +struct ErrorsList: View { + let report: ErrorsReport + @ObservedObject var store: StackStore + private static let shown = 4 + + var body: some View { + if report.containers.isEmpty { + Label("No errors in \(report.scanned) containers", systemImage: "checkmark.circle") + .font(.callout).foregroundStyle(.secondary) + } else { + ForEach(report.containers.prefix(Self.shown)) { entry in + Button { store.openInTerminal(["logs", entry.stacklet]) } label: { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(entry.container).font(.callout) + Spacer() + Text(summary(entry)).font(.caption).foregroundStyle(.secondary) + } + if let last = entry.lines.last { + Text(last.text).font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(2).truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open the logs in Terminal") + } + if report.containers.count > Self.shown { + Button("\(report.containers.count - Self.shown) more…") { + store.openInTerminal(["errors"]) + } + .buttonStyle(.link).font(.caption) + } + } + } + + private func summary(_ entry: ErrorsReport.ContainerErrors) -> String { + let when = entry.lastAt.flatMap(parseTimestamp).map { relative($0) } ?? "" + return "\(entry.count)× \(when)" + } +} + +/// The choices in stack.toml and users.toml an admin looks for first. +/// Read-only: the buttons open the files, and doctor then lists every +/// container still running with the old setting, with the command to apply it. +struct SetupGrid: View { + let report: ConfigReport + @ObservedObject var store: StackStore + + private var config: JSONValue { report.config } + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { + row("Family", family) + row("Address", address) + row("Language", language) + aiRow + row("Updates", updates) + row("Data", config["core"]?["data_dir"]?.text ?? "default") + } + .font(.callout) + Divider() + HStack(spacing: 14) { + Button("Edit stack.toml") { store.edit(report.stackToml) } + Button("Edit users.toml") { store.edit(report.usersToml) } + } + .buttonStyle(.link).font(.callout) + Text("A change applies with stack up . Doctor lists what still runs the old setting.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private func row(_ label: String, _ value: String) -> some View { + GridRow { + Text(label).foregroundStyle(.secondary).gridColumnAlignment(.trailing) + Text(value).fixedSize(horizontal: false, vertical: true) + } + } + + // ── What each row says ─────────────────────────────────────────────── + + private var family: String { + let people = report.users.compactMap { user -> String? in + guard let name = user["name"]?.text ?? user["id"]?.text else { return nil } + return user["role"]?.text == "admin" ? "\(name) (admin)" : name + } + if people.isEmpty { return "nobody in users.toml" } + let owner = config["core"]?["stack_owner"]?.text.map { "\($0): " } ?? "" + return owner + people.joined(separator: ", ") + } + + private var address: String { + if let domain = config["core"]?["domain"]?.text { return "Domain mode, *.\(domain)" } + if let host = config["core"]?["host"]?.text { return "Port mode, \(host)" } + return "Port mode" + } + + private var language: String { + let parts = [config["core"]?["language"]?.text, config["core"]?["timezone"]?.text].compactMap { $0 } + return parts.isEmpty ? "default" : parts.joined(separator: ", ") + } + + /// The model on one line, where it is, below it. Model ids are long and + /// hyphenated, so the line is shortened in the middle rather than wrapped. + private var aiRow: some View { + let section = config["ai"] + let model = section?["default"]?.text + let provider = section?["provider"]?.text + let local = provider == nil || provider == "local" + let place = local ? "This Mac" : section?["openai_url"]?.text.flatMap { URL(string: $0)?.host } ?? provider + return GridRow { + Text("AI").foregroundStyle(.secondary).gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 1) { + Text(model ?? "default model").lineLimit(1).truncationMode(.middle).help(model ?? "") + if let place { Text(place).font(.caption).foregroundStyle(.secondary) } + } + } + } + + /// `[updates] schedule` is a cron expression with seconds. The common + /// daily form reads as a time; anything else is shown as written. + private var updates: String { + guard let cron = config["updates"]?["schedule"]?.text else { return "default" } + let f = cron.split(separator: " ").map(String.init) + if f.count == 6, f[3...].allSatisfy({ $0 == "*" }), let m = Int(f[1]), let h = Int(f[2]) { + return String(format: "Daily at %02d:%02d", h, m) + } + return cron + } +} + +struct HostMetrics: View { + let figures: HostFigures + + var body: some View { + HStack(alignment: .top, spacing: 16) { + if let pct = figures.diskUsedPct, let free = figures.diskFreeGb { + Meter(label: "Disk", value: pct / 100, detail: "\(Int(free)) GB free") + } + if let used = figures.memoryUsedGb, let total = figures.memoryTotalGb { + Meter(label: "Memory", value: used / max(total, 1), + detail: String(format: "%.0f of %.0f GB", used, total)) + } + } + } +} + +struct Meter: View { + let label: String + let value: Double + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(label).font(.callout) + Spacer() + Text(detail).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.1)) + Capsule().fill(tint).frame(width: geo.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 5) + } + .frame(maxWidth: .infinity) + } + + private var tint: Color { value > 0.9 ? .red : value > 0.75 ? .orange : .accentColor } +} + +struct Notice: View { + let text: String + let tint: Color + + var body: some View { + Text(text) + .font(.callout) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } +} + +extension Health { + var color: Color { + switch self { + case .healthy: .green + case .remote: .blue + case .starting: .yellow + case .degraded: .orange + case .failing: .red + case .down, .notInstalled: .gray + } + } +} diff --git a/apps/macos/Sources/StackMenu/Model.swift b/apps/macos/Sources/StackMenu/Model.swift new file mode 100644 index 00000000..e498aaac --- /dev/null +++ b/apps/macos/Sources/StackMenu/Model.swift @@ -0,0 +1,331 @@ +import Foundation + +// ── What the CLI returns ────────────────────────────────────────────────── +// +// Only the fields the panel reads are declared; the decoder ignores the rest, +// so the CLI can grow its output without breaking the app. Keys arrive in +// snake_case and are converted. + +/// `stack status --json`: polled every minute, cheap (about half a second). +struct StackStatus: Decodable { + let version: String + let host: HostFigures? + let stacklets: [Stacklet] + let stale: [String] + + var installed: [Stacklet] { stacklets.filter(\.enabled) } + var available: [Stacklet] { stacklets.filter { !$0.enabled } } + func isInstalled(_ id: String) -> Bool { installed.contains { $0.id == id } } +} + +struct HostFigures: Decodable { + let memoryTotalGb: Double? + let memoryUsedGb: Double? + let diskTotalGb: Double? + let diskFreeGb: Double? + let diskUsedPct: Double? +} + +struct Stacklet: Decodable, Identifiable { + let id: String + let name: String + let port: Int? + let enabled: Bool + let online: Bool + let starting: Bool + let failing: Bool + let degraded: Bool + let stale: Bool + let remote: String? + /// Where a person opens it; absent without a web port or from a CLI + /// older than the field. + let url: String? + let healthIssues: [String] + + /// One state per row, derived in the same order `stack list` reads the + /// flags: a remote stacklet is judged by its remote checks alone, and a + /// stacklet that is installed but has no running project counts as down. + var health: Health { + if !enabled { return .notInstalled } + if remote != nil { return degraded ? .degraded : .remote } + if failing { return .failing } + if starting { return .starting } + if !online { return .down } + if degraded { return .degraded } + return .healthy + } +} + +enum Health { + case healthy, remote, starting, degraded, failing, down, notInstalled + + /// Down is not a fault: `stack down` leaves a stacklet exactly like this, + /// and a crashing one restarts (`unless-stopped`) and reads as failing. + var needsAttention: Bool { + switch self { + case .degraded, .failing: true + default: false + } + } + + var label: String { + switch self { + case .healthy: "running" + case .remote: "remote" + case .starting: "starting" + case .degraded: "degraded" + case .failing: "failing" + case .down: "down" + case .notInstalled: "not installed" + } + } +} + +/// `stack doctor --json`. +struct DoctorReport: Decodable { + let summary: String + let findings: [Finding] + + struct Finding: Decodable { + let level: String + let title: String + let detail: String + let fix: String + } +} + +/// `stack errors --json`. +struct ErrorsReport: Decodable { + let since: String + let scanned: Int + let containers: [ContainerErrors] + + struct ContainerErrors: Decodable, Identifiable { + let stacklet: String + let container: String + let count: Int + let lastAt: String? + let lines: [Line] + + var id: String { container } + } + + struct Line: Decodable { + let at: String? + let text: String + } +} + +/// `stack host --json`. +struct HostReport: Decodable { + let memoryTotalGb: Double? + let memoryUsedGb: Double? + let diskTotalGb: Double? + let diskFreeGb: Double? + let diskUsedPct: Double? + let uptimeSeconds: Int? + let stacklets: [Usage] + + struct Usage: Decodable { + let id: String + let memoryBytes: Int + let cpuPct: Double + } + + var figures: HostFigures { + HostFigures(memoryTotalGb: memoryTotalGb, memoryUsedGb: memoryUsedGb, + diskTotalGb: diskTotalGb, diskFreeGb: diskFreeGb, diskUsedPct: diskUsedPct) + } +} + +/// `stack backup status`, which prints JSON whenever its output is piped. +struct BackupReport: Decodable { + let targets: [Target] + + struct Target: Decodable { + let name: String + let disk: String + let schedule: String + let diskMounted: Bool + let cronInstalled: Bool + let canary: String + let lastRun: Run? + } + + struct Run: Decodable { + let success: Bool + let failureReason: String? + let endedAt: String? + } +} + +// ── Needs attention ─────────────────────────────────────────────────────── + +/// One thing the admin should look at, with the command that addresses it. +struct Attention: Identifiable { + enum Level: Int, Comparable { + case info, warn, error + static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } + } + + let id: String + let level: Level + let title: String + let detail: String + /// A command line as the CLI prints it, `stack up core`, or nil. + let fix: String? + + /// Everything that needs the admin, worst first. Stacklet rows already + /// show which stacklets are failing; this adds what a row cannot: the + /// doctor's diagnoses, a backup that failed and the disk. + static func collect(status: StackStatus?, doctor: DoctorReport?, backup: BackupReport?, + disk: HostFigures?, now: Date = Date()) -> [Attention] { + var items: [Attention] = [] + + // Stale code already has its own notice with one button for all of + // it; doctor's per-stacklet `stack restart ` would repeat it. + let staleFixes = Set((status?.stale ?? []).map { "stack restart \($0)" }) + + for (i, finding) in (doctor?.findings ?? []).enumerated() where !staleFixes.contains(finding.fix) { + let level: Level = switch finding.level { + case "error": .error + case "warn": .warn + default: .info + } + items.append(Attention(id: "doctor-\(i)", level: level, title: finding.title, + detail: finding.detail, fix: finding.fix.isEmpty ? nil : finding.fix)) + } + + // Backup is a beta stacklet, so not having it is not a problem to + // report. Once installed, a failed or missed run is. + if let status, status.isInstalled("backup"), let backup { + items += backupItems(backup, now: now) + } + + if let pct = disk?.diskUsedPct, pct >= 90 { + let free = disk?.diskFreeGb.map { "\(Int($0)) GB left" } ?? "Little space left" + items.append(Attention( + id: "disk", level: pct >= 95 ? .error : .warn, + title: "Disk is \(Int(pct))% full", + detail: "\(free) on the disk that holds the data dir.", fix: nil)) + } + + return items.sorted { $0.level > $1.level } + } + + /// A scheduled backup older than two days has missed at least one run. + private static let overdue: TimeInterval = 2 * 24 * 3600 + + private static func backupItems(_ report: BackupReport, now: Date) -> [Attention] { + report.targets.flatMap { target -> [Attention] in + var items: [Attention] = [] + let id = "backup-\(target.name)" + if target.canary == "tampered" { + items.append(Attention( + id: id + "-canary", level: .error, title: "Backup tripwire on \(target.disk) changed", + detail: "Something rewrote files on the backup disk. Check it before the next run.", + fix: "stack backup status")) + } + guard let run = target.lastRun else { + items.append(Attention(id: id, level: .error, title: "No backup to \(target.disk) has run yet", + detail: "Schedule: \(target.schedule)", fix: "stack backup sync")) + return items + } + if !run.success { + items.append(Attention(id: id, level: .error, title: "Last backup to \(target.disk) failed", + detail: run.failureReason ?? "No reason recorded.", + fix: "stack backup status")) + } else if let ended = run.endedAt.flatMap(parseTimestamp), now.timeIntervalSince(ended) > overdue { + items.append(Attention(id: id, level: .warn, + title: "Last backup to \(target.disk) was \(relative(ended, now))", + detail: target.diskMounted ? "The disk is connected." : "The disk is not connected.", + fix: "stack backup sync")) + } + return items + } + } +} + +// ── Time ────────────────────────────────────────────────────────────────── + +/// The CLI's timestamps are ISO 8601 in UTC, from whole seconds (backups) to +/// nanoseconds (`docker logs`). Fractions beyond milliseconds are dropped, +/// which the system parser needs and a panel does not miss. +func parseTimestamp(_ text: String) -> Date? { + var s = text + if let dot = s.firstIndex(of: "."), let z = s.firstIndex(of: "Z"), dot < z { + let fraction = s[s.index(after: dot).. String { + if abs(now.timeIntervalSince(date)) < 60 { return "just now" } + return Formatters.relative.localizedString(for: date, relativeTo: now) +} + +/// Built once: the panel formats dates on every redraw. +private enum Formatters { + static let iso: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + static let isoFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + static let relative: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() +} + +// ── Configuration ───────────────────────────────────────────────────────── + +/// `stack config --json`: stack.toml and users.toml as written, credentials +/// hidden. The files are free-form TOML, so their content stays generic and +/// the panel picks out what it shows. +struct ConfigReport: Decodable { + let stackToml: String + let usersToml: String + let config: JSONValue + let users: [JSONValue] + + enum CodingKeys: String, CodingKey { + case stackToml = "stack_toml", usersToml = "users_toml", config, users + } +} + +enum JSONValue: Decodable { + case string(String), number(Double), bool(Bool), array([JSONValue]), object([String: JSONValue]), null + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else if let v = try? c.decode(Double.self) { self = .number(v) } + else if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode([JSONValue].self) { self = .array(v) } + else { self = .object(try c.decode([String: JSONValue].self)) } + } + + subscript(key: String) -> JSONValue? { + if case .object(let o) = self { return o[key] } + return nil + } + + /// A string value, with an empty one read as absent. + var text: String? { + if case .string(let v) = self, !v.isEmpty { return v } + return nil + } + + var texts: [String] { + if case .array(let a) = self { return a.compactMap(\.text) } + return [] + } +} diff --git a/apps/macos/Sources/StackMenu/StackCLI.swift b/apps/macos/Sources/StackMenu/StackCLI.swift new file mode 100644 index 00000000..ea62857a --- /dev/null +++ b/apps/macos/Sources/StackMenu/StackCLI.swift @@ -0,0 +1,474 @@ +import AppKit +import Foundation + +// ── The stack CLI as the app's only backend ─────────────────────────────── +// +// The app holds no state about the stack and starts no services itself. It +// runs `./stack` in a checkout and renders what comes back, so the CLI stays +// the one owner of the lifecycle, as it is for every other client. +// +// The checkout is on this Mac or on another one reached over SSH. Every +// command goes through `invocation`, so reading, acting, Terminal links and +// editing all reach the same machine the panel shows. + +enum Connection: Equatable { + case local(checkout: URL) + /// `host` is anything `ssh` accepts: an alias from ~/.ssh/config or + /// `user@host`. `checkout` is a path on that machine; `~/` is expanded + /// there. + case remote(host: String, checkout: String) + + var label: String { + switch self { + case .local: "This Mac" + case .remote(let host, _): host + } + } + + var isRemote: Bool { + if case .remote = self { return true } + return false + } +} + +// ── What went wrong, for a person ───────────────────────────────────────── + +/// A failure as the panel shows it: what happened, what to do, and the raw +/// output for anyone who wants it. +struct Problem: Equatable, Error { + enum Kind { case connection, timeout, command } + + let kind: Kind + let title: String + /// What to do about it, when there is something to do. + var hint: String? + /// The last lines the command printed. + var detail: String? + /// A command line that helps, run in Terminal on this Mac. + var terminal: TerminalCommand? + + struct TerminalCommand: Equatable { + let label: String + let line: String + } +} + +struct StackCLI { + let connection: Connection + + struct Result { + var exitCode: Int32 = 0 + var stdout = "" + var stderr = "" + /// Stopped because it ran past its time. + var timedOut = false + /// Past its time but left running (lifecycle actions). + var stillRunning = false + /// The process could not be started at all. + var launchError: String? + + var succeeded: Bool { exitCode == 0 && !timedOut && !stillRunning && launchError == nil } + + /// The last lines are where the CLI says what went wrong. Both + /// streams, because commands print their progress to either. + func tail(_ lines: Int = 6) -> String { + (stdout + "\n" + stderr).split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .suffix(lines).joined(separator: "\n") + } + } + + /// What happens when a command runs past its time. + enum Overrun { + /// Stop it. For reads: an answer that late is worth nothing. + case stop + /// Stop waiting but let it finish, calling back when it does. For + /// lifecycle actions: a slow `up` is usually still pulling images, + /// and killing it halfway helps nobody. + case detach(whenDone: @Sendable (Result) -> Void) + } + + static let readTimeout: TimeInterval = 60 + static let actionTimeout: TimeInterval = 15 * 60 + + /// A local checkout is a directory holding the `stack` wrapper and + /// `stacklets/`, the same marker the CLI itself walks up to. + static func isCheckout(_ url: URL) -> Bool { + let fm = FileManager.default + return fm.isExecutableFile(atPath: url.appendingPathComponent("stack").path) + && fm.fileExists(atPath: url.appendingPathComponent("stacklets").path) + } + + // ── Reading ────────────────────────────────────────────────────────── + + func status() async throws -> StackStatus { try await json(["status", "--json"]) } + func doctor() async throws -> DoctorReport { try await json(["doctor", "--json"]) } + func errors() async throws -> ErrorsReport { try await json(["errors", "--json"]) } + func host() async throws -> HostReport { try await json(["host", "--json"]) } + /// Prints JSON because its output is a pipe; it takes no `--json`. + func backup() async throws -> BackupReport { try await json(["backup", "status"]) } + /// Keys are TOML keys, kept exactly as written. + func config() async throws -> ConfigReport { try await json(["config", "--json"], snakeCase: false) } + + /// Runs a command and decodes its JSON output, or throws a `Problem`. + /// + /// The exit code alone does not decide: `doctor` exits 1 when it has + /// found an error and still prints its report. Anything before the first + /// `{` line is skipped, such as a one-time notice the CLI prints on the + /// first run after a config change. + private func json(_ arguments: [String], snakeCase: Bool = true) async throws -> T { + let result = await run(arguments, timeout: Self.readTimeout, overrun: .stop) + // A run that was killed or never started may have printed half a + // document; what it printed says nothing. + if result.timedOut || result.launchError != nil { throw diagnose(result, arguments) } + let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: false) + guard let start = lines.firstIndex(where: { $0.hasPrefix("{") }) else { + throw readProblem(result, arguments) + } + let body = Data(lines[start...].joined(separator: "\n").utf8) + let decoder = JSONDecoder() + if snakeCase { decoder.keyDecodingStrategy = .convertFromSnakeCase } + do { + return try decoder.decode(T.self, from: body) + } catch { + // A failing command answers `{"error": "..."}` (the CLI's + // contract): that message is for people, so it is the title. + if let reply = try? JSONDecoder().decode(ErrorReply.self, from: body) { + throw Problem(kind: .command, title: reply.error) + } + throw Problem(kind: .command, + title: "The app does not understand what stack \(arguments[0]) returned", + hint: "The app and the checkout are probably from different versions.", + detail: String(describing: error)) + } + } + + private struct ErrorReply: Decodable { let error: String } + + // ── Running ────────────────────────────────────────────────────────── + + /// What to execute for `./stack `. + /// + /// Locally the working directory is the checkout, because the CLI finds + /// its root by walking up from the current directory, not from where the + /// script lives. Remotely a login shell runs it, which gives the remote + /// Python and docker their PATH, as it does for a person logging in. + private func invocation(_ arguments: [String]) -> (executable: URL, arguments: [String], directory: URL?) { + switch connection { + case .local(let checkout): + return (checkout.appendingPathComponent("stack"), arguments, checkout) + case .remote(let host, let checkout): + let line = Self.inRemoteCheckout(checkout, "./stack " + arguments.map(Self.shellQuote).joined(separator: " ")) + return (URL(fileURLWithPath: "/usr/bin/ssh"), + Self.sshOptions(batch: true) + ["--", host, "zsh -lc " + Self.shellQuote(line)], nil) + } + } + + /// Runs `./stack ` off the main thread. Stdin is empty, so a + /// hook that prompts fails instead of waiting for an answer nobody can + /// give. + func run(_ arguments: [String], timeout: TimeInterval, overrun: Overrun) async -> Result { + let (executable, argv, directory) = invocation(arguments) + return await withCheckedContinuation { continuation in + let process = Process() + process.executableURL = executable + process.arguments = argv + if let directory { process.currentDirectoryURL = directory } + process.environment = Self.environment() + process.standardInput = FileHandle.nullDevice + let run = ProcessRun(process: process, continuation: continuation) + run.start(timeout: timeout, overrun: overrun) + } + } + + // ── Terminal ───────────────────────────────────────────────────────── + + /// Opens Terminal on `./stack ` on the connected machine. + /// Used for anything interactive or long to read: installs, logs, doctor. + @discardableResult + func openInTerminal(_ arguments: [String]) -> Bool { + openInTerminal(inCheckout: "./stack " + arguments.map(Self.shellQuote).joined(separator: " ")) + } + + /// Opens Terminal on a command line exactly as the CLI printed it, such + /// as a doctor fix. `stack …` runs the checkout's wrapper. + @discardableResult + func openInTerminal(fix: String) -> Bool { + openInTerminal(inCheckout: fix.hasPrefix("stack ") ? "./" + fix : fix) + } + + /// Opens a file of the instance in a text editor: the default editor for + /// a local file, the remote `$EDITOR` in Terminal for a remote one. + @discardableResult + func edit(_ path: String) -> Bool { + switch connection { + case .local: + return NSWorkspace.shared.open(URL(fileURLWithPath: path)) || Self.openWithTextEdit(path) + case .remote: + return openInTerminal(inCheckout: "${EDITOR:-nano} " + Self.shellQuote(path)) + } + } + + private static func openWithTextEdit(_ path: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-t", path] + return (try? process.run()) != nil + } + + /// `line`, run in the checkout on the connected machine. + private func openInTerminal(inCheckout line: String) -> Bool { + switch connection { + case .local(let checkout): + // Terminal does not inherit this app's environment. An app + // pointed at another instance with STACK_DIR passes it on. + let stackDir = ProcessInfo.processInfo.environment["STACK_DIR"] + .map { "export STACK_DIR=\(Self.shellQuote($0))\n" } ?? "" + return Self.openShell("\(stackDir)cd \(Self.shellQuote(checkout.path)) || exit 1\n\(line)") + case .remote(let host, let checkout): + let remote = Self.inRemoteCheckout(checkout, line) + let ssh = (["ssh", "-t"] + Self.sshOptions(batch: false) + ["--", host]).map(Self.shellQuote) + .joined(separator: " ") + return Self.openShell("\(ssh) \(Self.shellQuote("zsh -lc " + Self.shellQuote(remote)))") + } + } + + /// Runs `script` in a new Terminal window on this Mac. A `.command` file + /// opens there through Launch Services, which needs no Automation + /// permission, unlike scripting Terminal directly. Each gets its own + /// file, so two windows never share one that is being rewritten. + @discardableResult + static func openShell(_ script: String) -> Bool { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("famstack-\(UUID().uuidString.prefix(8)).command") + do { + try ("#!/bin/zsh -l\nrm -f -- \"$0\"\n" + script + "\n").write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: file.path) + } catch { + return false + } + return NSWorkspace.shared.open(file) + } + + // ── Diagnosis ──────────────────────────────────────────────────────── + + /// A read that returned no JSON. Its stdout is never shown: an older CLI + /// that ignores `--json` can print a whole file there, stack.toml with + /// its credentials included. + private func readProblem(_ result: Result, _ arguments: [String]) -> Problem { + var problem = diagnose(result, arguments) + guard problem.kind == .command else { return problem } + let machine = connection.isRemote ? connection.label : "this Mac" + let stderr = result.stderr.split(whereSeparator: \.isNewline).suffix(2).joined(separator: "\n") + // argparse exits 2 for a subcommand or flag it does not know. + if result.exitCode == 2 { + return Problem(kind: .command, title: "Not in the famstack version on \(machine)", + hint: "Update that checkout with `./stack update`.") + } + problem = Problem(kind: .command, title: "stack \(arguments[0]) gave no report the app can read", + hint: "The famstack on \(machine) may be older than the app; `./stack update` brings it level.", + detail: stderr.isEmpty ? nil : stderr) + return problem + } + + /// Turns a failed run into what the panel says. It reads only what the + /// app controls (the process, its timeout, ssh's documented exit code, + /// the exit code of its own remote wrapper) and otherwise shows the + /// CLI's last lines as they are: those are written for people already. + func diagnose(_ result: Result, _ arguments: [String]) -> Problem { + let command = "stack " + arguments.joined(separator: " ") + let detail = result.tail(4).isEmpty ? nil : result.tail(4) + + if let launch = result.launchError { + return Problem(kind: .connection, title: "Could not run ./stack", + hint: "Choose the checkout again in Setup → Connection.", detail: launch) + } + if result.stillRunning { + return Problem(kind: .timeout, title: "\(command) is still running", + hint: "It continues in the background; the panel updates when it finishes.") + } + if result.timedOut { + return Problem(kind: .timeout, title: "\(command) did not answer in time", + hint: "Docker on \(connection.label) may be stuck, or the connection dropped.", + detail: detail) + } + if case .remote(let host, let checkout) = connection { + switch result.exitCode { + case Self.sshFailed: + return Problem(kind: .connection, title: "Could not connect to \(host) over SSH", + hint: "`ssh \(host)` has to work without a password, and that Mac needs Remote Login.", + detail: result.stderr.split(whereSeparator: \.isNewline).last + .map { $0.trimmingCharacters(in: .whitespaces) }, + terminal: .init(label: "Test in Terminal", + line: "ssh -- \(Self.shellQuote(host)) exit && echo 'SSH works.'")) + case Self.noRemoteCheckout: + return Problem(kind: .connection, title: "No famstack checkout at \(checkout) on \(host)", + hint: "Set the checkout path in Setup → Connection.") + default: + break + } + } + return Problem(kind: .command, title: "\(command) failed", detail: detail) + } + + /// ssh's own failures (it documents 255 for them), and the code the + /// remote wrapper exits with when the path holds no checkout. + private static let sshFailed: Int32 = 255 + private static let noRemoteCheckout: Int32 = 96 + + /// `line` run in the remote checkout, or exit 96 when there is none. + private static func inRemoteCheckout(_ checkout: String, _ line: String) -> String { + "{ cd \(remotePath(checkout)) 2>/dev/null && [ -x ./stack ]; } || exit \(noRemoteCheckout); \(line)" + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// `BatchMode` makes ssh fail instead of asking for a password or a + /// host key nobody can type into; Terminal sessions may ask. + private static func sshOptions(batch: Bool) -> [String] { + (batch ? ["-o", "BatchMode=yes"] : []) + + ["-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15", "-o", "LogLevel=ERROR"] + } + + /// A remote path quoted for the remote shell, with a leading `~/` left + /// outside the quotes so that shell expands it. + private static func remotePath(_ path: String) -> String { + if path == "~" { return "~" } + if path.hasPrefix("~/") { return "~/" + shellQuote(String(path.dropFirst(2))) } + return shellQuote(path) + } + + /// An app started from Finder or at login gets launchd's minimal PATH, + /// in which the wrapper finds only Apple's Python 3.9 and no docker. + /// Prepend where Homebrew and OrbStack install them. + private static func environment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + let extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "\(home)/.orbstack/bin"] + env["PATH"] = (extra + [env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"]).joined(separator: ":") + return env + } + + static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + fileprivate static func stripANSI(_ s: String) -> String { + s.replacingOccurrences(of: "\u{1B}\\[[0-9;?]*[A-Za-z]", with: "", options: .regularExpression) + } +} + +// ── One process ─────────────────────────────────────────────────────────── + +/// Runs one process and collects both of its streams. +/// +/// It is done when the process exits, not when its pipes close: `stack` +/// starts docker, and a docker child can hold the pipes open after `stack` +/// itself is gone. So after the exit the readers get a moment to drain, and +/// then the result is handed back regardless. +private final class ProcessRun: @unchecked Sendable { + private let process: Process + private let lock = NSLock() + private var stdout = Data() + private var stderr = Data() + private var continuation: CheckedContinuation? + private var whenDone: (@Sendable (StackCLI.Result) -> Void)? + private var timedOut = false + private var exited = false + private let drained = DispatchGroup() + + init(process: Process, continuation: CheckedContinuation) { + self.process = process + self.continuation = continuation + } + + func start(timeout: TimeInterval, overrun: StackCLI.Overrun) { + let out = Pipe(), err = Pipe() + process.standardOutput = out + process.standardError = err + collect(out) { self.stdout.append($0) } + collect(err) { self.stderr.append($0) } + + process.terminationHandler = { [self] process in + lock.withLock { exited = true } + DispatchQueue.global().async { [self] in + _ = drained.wait(timeout: .now() + 1) + out.fileHandleForReading.readabilityHandler = nil + err.fileHandleForReading.readabilityHandler = nil + finish(exitCode: process.terminationReason == .uncaughtSignal + ? 128 + process.terminationStatus : process.terminationStatus) + } + } + + do { + try process.run() + } catch { + out.fileHandleForReading.readabilityHandler = nil + err.fileHandleForReading.readabilityHandler = nil + deliver(StackCLI.Result(exitCode: -1, launchError: error.localizedDescription)) + return + } + + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [self] in + // Decided under the lock that `finish` also takes, so an exit in + // the same instant either wins completely or not at all. + let overran = lock.withLock { () -> Bool in + guard !exited else { return false } + switch overrun { + case .stop: timedOut = true + case .detach(let callback): whenDone = callback + } + return true + } + guard overran else { return } + switch overrun { + case .stop: + process.terminate() + // A process that ignores SIGTERM gets SIGKILL. + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [self] in + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + case .detach: + deliver(StackCLI.Result(stillRunning: true)) + } + } + } + + private func collect(_ pipe: Pipe, into append: @escaping (Data) -> Void) { + drained.enter() + pipe.fileHandleForReading.readabilityHandler = { [self] handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + drained.leave() + } else { + lock.withLock { append(data) } + } + } + } + + private func finish(exitCode: Int32) { + let result = lock.withLock { + StackCLI.Result(exitCode: exitCode, + stdout: StackCLI.stripANSI(String(decoding: stdout, as: UTF8.self)), + stderr: StackCLI.stripANSI(String(decoding: stderr, as: UTF8.self)), + timedOut: timedOut) + } + // Whoever is still waiting gets the result; a caller that was + // already told "still running" hears it through the callback. + if !deliver(result), let callback = lock.withLock({ whenDone }) { callback(result) } + } + + /// Resumes the caller exactly once, whichever of exit, timeout or launch + /// failure comes first. Returns whether this call was the one. + @discardableResult + private func deliver(_ result: StackCLI.Result) -> Bool { + let pending = lock.withLock { () -> CheckedContinuation? in + defer { continuation = nil } + return continuation + } + pending?.resume(returning: result) + return pending != nil + } +} diff --git a/apps/macos/Sources/StackMenu/StackStore.swift b/apps/macos/Sources/StackMenu/StackStore.swift new file mode 100644 index 00000000..cc15e6c5 --- /dev/null +++ b/apps/macos/Sources/StackMenu/StackStore.swift @@ -0,0 +1,478 @@ +import AppKit +import Foundation + +// ── App state: what the CLI last said, what is running, what failed ─────── +// +// Two refresh rates. `status` is cheap and drives the icon, so it runs every +// minute. The rest (doctor, errors, host, backup, config) take two to three +// seconds each, so they run when the panel opens, at most once a minute, and +// every ten minutes in the background so the icon also reflects the doctor. +// +// The stack is on this Mac or on another one over SSH. Both are remembered and +// a switch picks one, so going back and forth needs no retyping. + +@MainActor +final class StackStore: ObservableObject { + enum Mode: String { case local, remote } + + @Published private(set) var mode: Mode + @Published private(set) var checkout: URL? + @Published private(set) var remoteHost: String + @Published private(set) var remoteCheckout: String + + @Published private(set) var status: StackStatus? + /// When `status` was last read successfully. + @Published private(set) var statusAt: Date? + /// Why the last status read failed; the panel then marks what it shows + /// as the last known state. + @Published private(set) var loadProblem: Problem? + @Published private(set) var refreshing = false + + @Published private(set) var doctor: DoctorReport? + @Published private(set) var errors: ErrorsReport? + @Published private(set) var host: HostReport? + @Published private(set) var backup: BackupReport? + @Published private(set) var config: ConfigReport? + /// Reports that could not be read, by name, so a missing section is + /// explained instead of silently absent. + @Published private(set) var reportProblems: [(report: String, problem: Problem)] = [] + @Published private(set) var checking = false + @Published private(set) var checkedAt: Date? + + /// The one lifecycle action running. Actions run one at a time: two + /// `up`s racing each other on one Docker is not something to invite. + @Published private(set) var running: RunningAction? + @Published var banner: Banner? + + struct RunningAction: Equatable { + let id = UUID() + /// A stacklet id, or "" for an action on the whole stack. + let key: String + let verb: String + let startedAt: Date + } + + /// A message at the top of the panel: an action's outcome, or a choice + /// that could not be applied. + struct Banner: Identifiable { + enum Kind { case success, info, failure } + + let id = UUID() + let kind: Kind + let title: String + var hint: String? + var detail: String? + /// Stack arguments to run again in Terminal, where the output stays. + var retry: [String]? + var terminal: Problem.TerminalCommand? + + init(_ kind: Kind, _ title: String, hint: String? = nil) { + self.kind = kind + self.title = title + self.hint = hint + } + + init(_ kind: Kind, _ problem: Problem, title: String? = nil, retry: [String]? = nil) { + self.kind = kind + self.title = title ?? problem.title + hint = problem.hint + detail = problem.detail + terminal = problem.terminal + self.retry = retry + } + } + + private static let checkoutKey = "checkout" + private static let modeKey = "mode" + private static let remoteHostKey = "remoteHost" + private static let remoteCheckoutKey = "remoteCheckout" + private static let defaultRemoteCheckout = "~/famstack" + private static let statusInterval: TimeInterval = 60 + private static let detailInterval: TimeInterval = 600 + private static let successBannerSeconds: UInt64 = 6 + + private var timers: [Timer] = [] + private var wakeObserver: NSObjectProtocol? + /// Bumped on every switch of machine, so an answer that arrives after + /// the switch is dropped instead of shown for the wrong machine. + private var generation = 0 + /// A read asked for while one runs is not dropped: it runs right after, + /// against whatever machine is selected by then. + private var refreshQueued = false + private var checkQueued = false + + init() { + let defaults = UserDefaults.standard + mode = Mode(rawValue: defaults.string(forKey: Self.modeKey) ?? "") ?? .local + checkout = Self.resolveCheckout() + remoteHost = defaults.string(forKey: Self.remoteHostKey) ?? "" + remoteCheckout = defaults.string(forKey: Self.remoteCheckoutKey) ?? Self.defaultRemoteCheckout + timers = [ + Timer.scheduledTimer(withTimeInterval: Self.statusInterval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.refresh() } + }, + Timer.scheduledTimer(withTimeInterval: Self.detailInterval, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.check() } + }, + ] + // After sleep the last answer is hours old. The network needs a + // moment to come back, a remote machine especially. + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + await self?.refresh() + await self?.check() + } + } + Task { + await refresh() + await check() + } + } + + /// The machine the panel shows, or nil until one is set up. + var connection: Connection? { + switch mode { + case .local: checkout.map { .local(checkout: $0) } + case .remote: remoteHost.isEmpty ? nil : .remote(host: remoteHost, checkout: remoteCheckout) + } + } + + private var cli: StackCLI? { connection.map(StackCLI.init) } + + // ── Reading ────────────────────────────────────────────────────────── + + func refresh() async { + if refreshing { refreshQueued = true; return } + refreshing = true + defer { refreshing = false } + repeat { + refreshQueued = false + guard let cli else { return } + let started = generation + do { + let fresh = try await cli.status() + guard started == generation else { continue } + status = fresh + statusAt = Date() + loadProblem = nil + } catch { + guard started == generation else { continue } + loadProblem = error as? Problem + ?? Problem(kind: .command, title: "Could not read the stack's status", detail: "\(error)") + } + } while refreshQueued + } + + /// The slower reports, all at once. One failing leaves the others. + func check() async { + if checking { checkQueued = true; return } + checking = true + defer { checking = false } + repeat { + checkQueued = false + guard let cli else { return } + let started = generation + // Right after a switch of machine there is no status yet, and + // without it the backup report would be skipped. + if status == nil { await refresh() } + let wantsBackup = status?.isInstalled("backup") ?? false + async let doctor = Self.attempt { try await cli.doctor() } + async let errors = Self.attempt { try await cli.errors() } + async let host = Self.attempt { try await cli.host() } + async let backup = wantsBackup ? Self.attempt { try await cli.backup() } : (nil, nil) + async let config = Self.attempt { try await cli.config() } + let (d, e, h, b, c) = await (doctor, errors, host, backup, config) + guard started == generation else { continue } + (self.doctor, self.errors, self.host, self.backup, self.config) = (d.0, e.0, h.0, b.0, c.0) + reportProblems = [("Doctor", d.1), ("Errors", e.1), ("Memory", h.1), ("Backup", b.1), ("Setup", c.1)] + .compactMap { name, problem in problem.map { (name, $0) } } + checkedAt = Date() + } while checkQueued + } + + private static func attempt(_ read: () async throws -> T) async -> (T?, Problem?) { + do { + return (try await read(), nil) + } catch let problem as Problem { + return (nil, problem) + } catch { + return (nil, Problem(kind: .command, title: "\(error)")) + } + } + + /// When the panel opens: status always, the rest unless just done. + func panelOpened() async { + await refresh() + if checkedAt.map({ Date().timeIntervalSince($0) > 60 }) ?? true { + await check() + } + } + + // ── Derived ────────────────────────────────────────────────────────── + + var hostFigures: HostFigures? { host?.figures ?? status?.host } + + var attention: [Attention] { + Attention.collect(status: status, doctor: doctor, backup: backup, disk: hostFigures) + } + + func memory(of stacklet: String) -> Int? { + host?.stacklets.first { $0.id == stacklet }?.memoryBytes + } + + func name(of stacklet: String) -> String { + status?.stacklets.first { $0.id == stacklet }?.name ?? stacklet + } + + /// What the menu bar icon shows, worst state first. Only errors turn it + /// into a warning: a warning that never clears (no backup set up) would + /// teach the admin to stop looking at the icon. + var summary: Summary { + guard connection != nil else { return .unconfigured } + guard let status, loadProblem == nil else { return .unreachable } + if running != nil { return .busy } + if status.installed.contains(where: { $0.health.needsAttention }) { return .attention } + if attention.contains(where: { $0.level == .error }) { return .attention } + return .healthy + } + + enum Summary { + case healthy, attention, busy, unreachable, unconfigured + + var logo: Logo.State { + switch self { + case .healthy: .normal + case .attention: .attention + case .busy: .busy + case .unreachable, .unconfigured: .unreachable + } + } + } + + // ── Acting ─────────────────────────────────────────────────────────── + + /// Runs a lifecycle verb in the background, says how it went, and + /// re-reads everything. + func perform(_ verb: String, _ stacklet: String? = nil) async { + guard let cli, running == nil else { return } + let key = stacklet ?? "" + let command = [verb] + (stacklet.map { [$0] } ?? []) + let started = generation + let action = RunningAction(key: key, verb: verb, startedAt: Date()) + running = action + banner = nil + + let result = await cli.run(command, timeout: StackCLI.actionTimeout, overrun: .detach { [weak self] late in + Task { @MainActor in self?.finish(command, late, generation: started) } + }) + // Only this action's own indicator is cleared: after a detach or a + // switch of machine, another action may be the running one by now. + if running?.id == action.id { running = nil } + if result.stillRunning { + // Left running; its callback reports the outcome later. The + // panel is free for other actions meanwhile. + show(Banner(.info, cli.diagnose(result, command))) + return + } + finish(command, result, generation: started) + } + + private func finish(_ command: [String], _ result: StackCLI.Result, generation started: Int) { + guard started == generation, let cli else { return } + let target = command.count > 1 ? name(of: command[1]) : nil + if result.succeeded { + show(Banner(.success, Self.done(command[0], target))) + } else { + let problem = cli.diagnose(result, command) + let title = problem.kind == .command ? Self.failed(command[0], target) : nil + show(Banner(.failure, problem, title: title, retry: command)) + } + Task { + await refresh() + await check() + } + } + + private static func done(_ verb: String, _ target: String?) -> String { + guard let target else { return verb == "restart" ? "Restarted what was running old code" : "Done" } + return switch verb { + case "up": "\(target) started" + case "down": "\(target) stopped" + case "restart": "\(target) restarted" + default: "Done" + } + } + + private static func failed(_ verb: String, _ target: String?) -> String { + let what = target ?? "the stacklets running old code" + return switch verb { + case "up": "Could not start \(what)" + case "down": "Could not stop \(what)" + case "restart": "Could not restart \(what)" + default: "stack \(verb) failed" + } + } + + /// Shows a banner; a success clears itself after a few seconds. + private func show(_ new: Banner) { + banner = new + guard new.kind == .success else { return } + Task { + try? await Task.sleep(nanoseconds: Self.successBannerSeconds * 1_000_000_000) + if banner?.id == new.id { banner = nil } + } + } + + /// A doctor fix: `stack up|restart|down ` runs in the background like + /// the row actions; anything else opens in Terminal, where it can be read. + func apply(fix: String) { + let words = fix.split(separator: " ").map(String.init) + if words.count == 3, words[0] == "stack", ["up", "restart", "down"].contains(words[1]) { + Task { await perform(words[1], words[2]) } + } else if cli?.openInTerminal(fix: fix) == false { + show(Banner(.failure, "Could not open Terminal")) + } + } + + func openInTerminal(_ arguments: [String]) { + if cli?.openInTerminal(arguments) == false { + show(Banner(.failure, "Could not open Terminal")) + } + } + + func openTerminal(_ command: Problem.TerminalCommand) { + if !StackCLI.openShell(command.line) { + show(Banner(.failure, "Could not open Terminal")) + } + } + + func edit(_ path: String) { + if cli?.edit(path) == false { + show(Banner(.failure, "Could not open \((path as NSString).lastPathComponent)", + hint: "Open it yourself: \(path)")) + } + } + + /// The address the CLI names for the stacklet. A checkout older than + /// that field names none; on this Mac, localhost then reaches every UI, + /// because ports bind here in both port and domain mode. + func openInBrowser(_ stacklet: Stacklet) { + var address = stacklet.url + if address == nil, mode == .local, let port = stacklet.port { address = "http://localhost:\(port)" } + guard let address, let url = URL(string: address), NSWorkspace.shared.open(url) else { + show(Banner(.failure, "Could not open \(stacklet.name) in the browser")) + return + } + } + + func canOpen(_ stacklet: Stacklet) -> Bool { + stacklet.online && (stacklet.url != nil || (mode == .local && stacklet.port != nil)) + } + + // ── Which machine and checkout ─────────────────────────────────────── + + func setMode(_ new: Mode) { + guard new != mode else { return } + // Switching to a remote that was never set up asks for it first, + // and a cancel leaves the switch where it was. + if new == .remote && remoteHost.isEmpty && !editRemote(switching: true) { return } + mode = new + UserDefaults.standard.set(new.rawValue, forKey: Self.modeKey) + reconnect() + } + + /// Asks for the SSH host and the checkout path on it. Returns whether + /// something was saved. + @discardableResult + func editRemote(switching: Bool = false) -> Bool { + let host = NSTextField(string: remoteHost) + host.placeholderString = "ssh alias or user@host" + let path = NSTextField(string: remoteCheckout) + path.placeholderString = Self.defaultRemoteCheckout + for field in [host, path] { field.frame.size.width = 280 } + let fields = NSStackView(views: [label("SSH host"), host, label("Checkout on that machine"), path]) + fields.orientation = .vertical + fields.alignment = .leading + fields.spacing = 6 + fields.frame = NSRect(x: 0, y: 0, width: 280, height: 110) + + let alert = NSAlert() + alert.messageText = "Connect to a stack on another Mac" + alert.informativeText = "The app runs ./stack there over SSH with your key, without a password. " + + "That Mac needs Remote Login turned on." + alert.accessoryView = fields + alert.addButton(withTitle: "Connect") + alert.addButton(withTitle: "Cancel") + NSApp.activate(ignoringOtherApps: true) + alert.window.initialFirstResponder = host + guard alert.runModal() == .alertFirstButtonReturn else { return false } + + let newHost = host.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let typedPath = path.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + // Whatever is typed becomes an ssh argument: a leading dash would be + // read as an option, and whitespace is never part of a host. + guard !newHost.isEmpty, !newHost.hasPrefix("-"), !newHost.contains(where: \.isWhitespace) else { + show(Banner(.failure, "“\(newHost)” is not an SSH host", + hint: "Use an alias from ~/.ssh/config or user@host.")) + return false + } + guard !typedPath.contains(where: \.isNewline) else { + show(Banner(.failure, "The checkout path cannot span lines")) + return false + } + remoteHost = newHost + remoteCheckout = typedPath.isEmpty ? Self.defaultRemoteCheckout : typedPath + UserDefaults.standard.set(remoteHost, forKey: Self.remoteHostKey) + UserDefaults.standard.set(remoteCheckout, forKey: Self.remoteCheckoutKey) + if mode == .remote && !switching { reconnect() } + return true + } + + private func label(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.textColor = .secondaryLabelColor + return label + } + + /// Forgets everything read from the previous machine and reads again. + private func reconnect() { + generation += 1 + status = nil; statusAt = nil; loadProblem = nil; banner = nil; running = nil + doctor = nil; errors = nil; host = nil; backup = nil; config = nil + reportProblems = []; checkedAt = nil + Task { + await refresh() + await check() + } + } + + func chooseCheckout() { + let panel = NSOpenPanel() + panel.message = "Choose the folder you run ./stack from" + panel.canChooseDirectories = true + panel.canChooseFiles = false + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + guard StackCLI.isCheckout(url) else { + show(Banner(.failure, "\(url.lastPathComponent) is not a famstack checkout", + hint: "Choose the folder that holds ./stack and stacklets/.")) + return + } + UserDefaults.standard.set(url.path, forKey: Self.checkoutKey) + checkout = url + if mode == .local { reconnect() } + } + + /// The saved choice, else `~/famstack`, where the README's clone lands + /// when run from the home folder. + private static func resolveCheckout() -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [UserDefaults.standard.string(forKey: checkoutKey).map { URL(fileURLWithPath: $0) }, + home.appendingPathComponent("famstack")] + return candidates.compactMap { $0 }.first(where: StackCLI.isCheckout) + } +} diff --git a/apps/macos/build.sh b/apps/macos/build.sh new file mode 100755 index 00000000..2936d839 --- /dev/null +++ b/apps/macos/build.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build the menu bar app into build/famstack.app. +# +# SwiftPM builds the binary; this wraps it in the minimal bundle macOS needs +# to treat it as a menu bar app (LSUIElement, a bundle id for its settings) +# and signs it ad hoc so it runs on this Mac. Command Line Tools are enough, +# no Xcode project. +# +# apps/macos/build.sh release build +# apps/macos/build.sh debug debug build +set -euo pipefail + +cd "$(dirname "$0")" +config="${1:-release}" + +swift build -c "$config" + +app="build/famstack.app" +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" +cp ".build/$config/StackMenu" "$app/Contents/MacOS/StackMenu" + +# The icon comes from the same drawing as the menu bar mark. +mkdir -p "$app/Contents/Resources" +rm -rf build/AppIcon.iconset +".build/$config/StackMenu" --icon build/AppIcon.iconset +iconutil -c icns -o "$app/Contents/Resources/AppIcon.icns" build/AppIcon.iconset + +# The app is versioned with the stack it ships with. macOS wants digits +# only here, so a release candidate's label is left off: 0.4.0-rc.1 -> 0.4.0. +# The build number tells a release candidate from the release: the commit +# count, which only grows. The Setup tab shows the stack's full version. +version="$(sed -n 's/^VERSION = "\([0-9.]*\).*"/\1/p' ../../lib/stack/cli.py)" +build="$(git rev-list --count HEAD 2>/dev/null || echo 0)" + +cat > "$app/Contents/Info.plist" < + + + + CFBundleIdentifier dev.famstack.menubar + CFBundleName famstack + CFBundleExecutable StackMenu + CFBundleIconFile AppIcon + CFBundlePackageType APPL + CFBundleShortVersionString ${version} + CFBundleVersion ${build} + LSMinimumSystemVersion 14.0 + LSUIElement + + +PLIST + +codesign --force --sign - "$app" +echo "$(pwd)/$app" diff --git a/apps/macos/script/logo-outline.py b/apps/macos/script/logo-outline.py new file mode 100644 index 00000000..71bd9c21 --- /dev/null +++ b/apps/macos/script/logo-outline.py @@ -0,0 +1,73 @@ +"""Write Sources/StackMenu/LogoOutline.swift: the outline of the famstack "a". + +The mark is an "a" set in Inter Bold with two dots below it, laid out as in +famstack.dev's favicon.svg on a 32-unit grid: font size 28, centred at x 16, +baseline at y 22 (y grows downwards, as in the SVG). The app draws the +outline instead of shipping the font. Inter is under the SIL Open Font +License 1.1. + + uvx --with brotli --from fonttools python apps/macos/script/logo-outline.py \ + ../website/node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2 +""" + +import sys +from pathlib import Path + +from fontTools.pens.basePen import BasePen +from fontTools.ttLib import TTFont +from fontTools.varLib.instancer import instantiateVariableFont + +FONT_SIZE, CENTER_X, BASELINE = 28, 16, 22 +OUT = Path(__file__).resolve().parent.parent / "Sources/StackMenu/LogoOutline.swift" + + +class SwiftPen(BasePen): + """Emits CGMutablePath calls in favicon grid coordinates.""" + + def __init__(self, glyphset, scale, x0): + super().__init__(glyphset) + self.scale, self.x0, self.lines = scale, x0, [] + + def pt(self, p): + x = self.x0 + p[0] * self.scale + y = BASELINE - p[1] * self.scale + return f"CGPoint(x: {x:.3f}, y: {y:.3f})" + + def _moveTo(self, p): + self.lines.append(f" path.move(to: {self.pt(p)})") + + def _lineTo(self, p): + self.lines.append(f" path.addLine(to: {self.pt(p)})") + + def _qCurveToOne(self, c, p): + self.lines.append(f" path.addQuadCurve(to: {self.pt(p)}, control: {self.pt(c)})") + + def _curveToOne(self, c1, c2, p): + self.lines.append(f" path.addCurve(to: {self.pt(p)}, control1: {self.pt(c1)}, control2: {self.pt(c2)})") + + def _closePath(self): + self.lines.append(" path.closeSubpath()") + + +font = instantiateVariableFont(TTFont(sys.argv[1]), {"wght": 700}) +glyphs = font.getGlyphSet() +name = font.getBestCmap()[ord("a")] +scale = FONT_SIZE / font["head"].unitsPerEm +x0 = CENTER_X - glyphs[name].width * scale / 2 +pen = SwiftPen(glyphs, scale, x0) +glyphs[name].draw(pen) + +OUT.write_text(f"""import CoreGraphics + +// Generated by script/logo-outline.py from Inter Bold (SIL Open Font License +// 1.1). The "a" of the famstack mark on favicon.svg's 32-unit grid, y down. + +enum LogoOutline {{ + static let letterA: CGPath = {{ + let path = CGMutablePath() +{chr(10).join(pen.lines)} + return path + }}() +}} +""") +print(f"{OUT}: {len(pen.lines)} path elements") diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 26eacf2f..5db54227 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -739,6 +739,42 @@ Key things to know: All commands output JSON when piped. Use `--json` to force it, `--pretty` to force human output. +### The menu bar app (beta) + +A macOS menu bar app shows the stack at a glance: what needs you (with the +command that fixes it), each stacklet with start, stop, restart, open and +logs, errors from the last 24 hours, and the Mac's disk and memory. A +**Setup** tab shows the family, address mode, language, AI and data dir, and +opens `stack.toml` and `users.toml` for editing. It runs the `stack` CLI and +nothing else, so it starts no services and keeps no state of its own. + +Install it from the checkout. It needs macOS 14 and the Command Line Tools, +which Homebrew already installed: + +```bash +./stack app install +``` + +That builds the app (about a minute the first time), puts it in +`/Applications` (or `~/Applications` when that is not writable) and opens it. +The app is signed for the Mac it was built on, so install it on the Mac that +runs it. After `./stack update`, run `./stack app install` again to update the +app; `update` reminds you when the app is installed. + +**Which machine.** **Setup → Connection** switches between this Mac and +another one: + +- **This Mac**: the checkout you run `./stack` from (`~/famstack` unless you + choose another with **Change…**). +- **Remote**: the server Mac over SSH, from a laptop for example. Give its + SSH host (an alias from `~/.ssh/config` or `user@host`) and its checkout + path. The server needs **Remote Login** on (System Settings → General → + Sharing), and your SSH key must work without a password prompt. Logs and + editing open Terminal with an SSH session to the server. + +Against a checkout older than 0.4.0 the app leaves out the sections that need +newer commands (doctor, errors, memory, setup) and says so. + --- ## Backups diff --git a/docs/agent/ops.md b/docs/agent/ops.md index af26ca85..b7e53a74 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -39,6 +39,7 @@ If a precondition is missing, `./stack` prints exactly what to do. Don't improvi | `./stack logs ` | yes | no | tail container logs | | `./stack errors` | yes | no | recent error logs (24h) | | `./stack host` | yes | no | disk / memory / uptime | +| `./stack app install` | yes | no | builds the menu bar app from the checkout, puts it in `/Applications` and opens it; again after an update | | `./stack config [--secrets]` | yes | no | prints resolved config | | `./stack ai models` | yes | no | lists installed AI models | | `./stack ai switch ` | yes | no | points `[ai]` at an AI server the stack does not manage; checks it, installs nothing, restarts nothing. Warns when the address is outside the home network | @@ -132,6 +133,7 @@ A release is a git tag. `./stack update` moves the checkout to one and names wha - **Works from a branch or a fork.** On `main` it moves to the tag and says the branch is left behind (`git switch main` returns). Tags are fetched from every remote, so a fork needs the project added as a remote or there is nothing to update to. - **A tag checkout is a detached HEAD.** `git pull` fails there. Never suggest `git pull origin main` as the fix: it succeeds and silently moves the instance onto unreleased code while `./stack version` still reports the last release. - **`./stack version` now says what is running,** not what the source constant claims: `0.3.0-beta.3-104-g9a5bdc9-dirty` is the tag, the distance past it, the commit, and an unclean tree. `version`, `status`, `list`, `doctor` and `update` all print the same string. `./stack doctor` adds a warning when a newer release exists, without fetching, so that half is as fresh as the last `git fetch`. +- **The menu bar app is not updated with the checkout.** If the admin uses it (`/Applications/famstack.app`), run `./stack app install` again after an update; `update` reminds the admin when the app is installed. See the admin guide, "The menu bar app". - **The instance survives a tag switch.** Config, secrets, data and `~/-extensions/` are all outside git. Full prose: [../admin-guide.md](../admin-guide.md) § Updating. diff --git a/lib/stack/app.py b/lib/stack/app.py new file mode 100644 index 00000000..1e7ae030 --- /dev/null +++ b/lib/stack/app.py @@ -0,0 +1,90 @@ +"""The menu bar app: built from the checkout, installed into Applications. + +The app is signed ad hoc by `apps/macos/build.sh`, which makes it run on the +Mac that built it and nowhere else, so it ships as source and every install +is a build. Installing again after `stack update` is how it is updated. + + result = install(repo_root) # {"installed": "/Applications/famstack.app"} + installed() # the bundle's path, or None +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +BUNDLE = "famstack.app" +# The system's Applications folder first; the user's own when that one is +# not writable, which is the case for an account that is not an admin. +FOLDERS = [Path("/Applications"), Path(os.path.expanduser("~/Applications"))] +# A release build with SwiftPM takes about a minute on an M1. +BUILD_TIMEOUT_SECS = 900 + + +def installed(folders: list[Path] = FOLDERS) -> Path | None: + """Where the app is installed, or None.""" + for folder in folders: + if (folder / BUNDLE).exists(): + return folder / BUNDLE + return None + + +def install(repo_root: Path, *, folders: list[Path] = FOLDERS, open_after: bool = True) -> dict: + """Build the app from the checkout and put it in the first writable folder. + + An app already there is replaced as a whole, so nothing of an older + build survives. A failed build installs nothing. + """ + script = Path(repo_root) / "apps" / "macos" / "build.sh" + if not script.exists(): + return {"error": "this checkout has no menu bar app (apps/macos); it arrived in 0.4.0"} + + built, error = _build(script) + if built is None: + return {"error": error} + + target = _writable(folders) + if target is None: + return {"error": "no Applications folder to write to: " + ", ".join(str(f) for f in folders)} + destination = target / BUNDLE + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(built, destination, symlinks=True) + + if open_after: + subprocess.run(["open", str(destination)], check=False) + return {"installed": str(destination)} + + +def _build(script: Path) -> tuple[Path | None, str]: + """Run the build script; the bundle it prints, or why it failed.""" + try: + done = subprocess.run([str(script)], capture_output=True, text=True, + timeout=BUILD_TIMEOUT_SECS) + except FileNotFoundError: + return None, f"cannot run {script}" + except subprocess.TimeoutExpired: + return None, f"the build took longer than {BUILD_TIMEOUT_SECS // 60} minutes" + if done.returncode != 0: + tail = (done.stderr or done.stdout).strip().splitlines()[-5:] + hint = ("" if shutil.which("swift") else + " Swift is missing: install the Command Line Tools with `xcode-select --install`.") + return None, "the build failed: " + " / ".join(tail) + hint + lines = done.stdout.strip().splitlines() + built = Path(lines[-1]) if lines else None + if built is None or not built.is_dir(): + return None, "the build printed no app bundle" + return built, "" + + +def _writable(folders: list[Path]) -> Path | None: + for folder in folders: + try: + folder.mkdir(parents=True, exist_ok=True) + except OSError: + continue + if os.access(folder, os.W_OK): + return folder + return None diff --git a/lib/stack/cli.py b/lib/stack/cli.py index b964712b..3803dadb 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -22,14 +22,21 @@ import argparse import json import os +import re +import subprocess import sys +import time import tomllib +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict from pathlib import Path +from . import app as menubar_app from . import caddy from . import docker from . import doctor from . import global_command +from . import logscan from .commands import COMMANDS from .prompt import ORANGE, TEAL, GREEN, RED, DIM, BOLD, RESET from .stack import Stack @@ -1140,6 +1147,17 @@ def missing_secrets(stacklet_id): if release: findings.insert(0, release) + if args.json: + json.dump({ + "version": running_version_of(stck), + "summary": doctor.summarise(findings), + "findings": [asdict(f) for f in findings], + }, sys.stdout, indent=2) + print() + if any(f.is_error for f in findings): + sys.exit(1) + return + print() if position: print(f" {ORANGE}{BOLD}{stck.product_name()}{RESET} " @@ -1170,13 +1188,59 @@ def handle_list(stck, args): print_list(stck.list(), stck) +# A key named like a credential. Matched on the name, not the value, so a +# secret added to stack.toml or users.toml later is hidden without a change here. +_SECRET_KEY = re.compile(r"(key|password|passwd|secret|token)$", re.IGNORECASE) +_HIDDEN = "(hidden)" + + +def _redact(value): + """`value` with every credential replaced by a marker. An empty one stays + empty, so the reader can still tell set from unset.""" + if isinstance(value, dict): + return {k: (_HIDDEN if _SECRET_KEY.search(k) and v not in ("", None) else _redact(v)) + for k, v in value.items()} + if isinstance(value, list): + return [_redact(v) for v in value] + return value + + +def _read_toml(path: Path) -> dict: + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return {} + + +def config_report(instance_dir: Path) -> dict: + """stack.toml and users.toml as written, without their credentials. + + The raw files, not resolved values: a key the admin left out is absent + here and its default applies. Both paths are included so a reader can + open the file to change it. + """ + stack_toml = instance_dir / "stack.toml" + users_toml = instance_dir / "users.toml" + return { + "stack_toml": str(stack_toml), + "users_toml": str(users_toml), + "config": _redact(_read_toml(stack_toml)), + "users": _redact(_read_toml(users_toml).get("users", [])), + } + + def handle_config(stck, args): """Config subcommands. Bare 'stack config' prints stack.toml.""" action = getattr(args, "config_action", None) if action == "admin": _config_admin(stck) return - path = stck.root / "stack.toml" + if getattr(args, "json", False): + json.dump(config_report(stck.instance_dir), sys.stdout, indent=2, default=str) + print() + return + path = stck.instance_dir / "stack.toml" if not path.exists(): print(" No stack.toml found.") return @@ -1284,6 +1348,147 @@ def handle_logs(stck, args): print(output) +# ── errors, host: what an admin checks first ────────────────────────────── + +_DURATION = re.compile(r"^\d+[smh]$") + + +def handle_errors(stck, args): + """Error lines from every stacklet's containers over a recent window. + + One question across the whole stack, so a failure in a sidecar nobody + watches is seen without opening each stacklet's log. What counts as an + error lives in logscan.py; this is the I/O around it. + """ + since = args.since + if not _DURATION.match(since): + print_error({"error": f"--since takes a duration such as 30m or 24h, not '{since}'"}) + sys.exit(1) + + known = {s["id"] for s in stck.discover()} + containers = [c for c in docker.stack_containers() if c["stacklet"] in known] + with ThreadPoolExecutor(max_workers=8) as pool: + scans = list(pool.map( + lambda c: logscan.scan(docker.container_logs(c["name"], since)), containers)) + + found = [{"stacklet": c["stacklet"], "container": c["name"], **scan} + for c, scan in zip(containers, scans) if scan["count"]] + found.sort(key=lambda e: e["last_at"] or "", reverse=True) + report = {"since": since, "scanned": len(containers), "containers": found} + + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + + print() + if not found: + print(f" {GREEN}✓{RESET} No errors in the last {since} " + f"across {len(containers)} containers.\n") + return + for entry in found: + noun = "error" if entry["count"] == 1 else "errors" + print(f" {RED}✗{RESET} {BOLD}{entry['container']}{RESET} " + f"{entry['count']} {noun} in the last {since}") + for line in entry["lines"][-3:]: + print(f" {DIM}{line['text'][:160]}{RESET}") + print(f" {DIM}more:{RESET} {TEAL}./stack logs {entry['stacklet']}{RESET}\n") + + +APP_VERBS = {"install": "build the menu bar app from this checkout and put it in Applications"} + + +def handle_app(stck, args): + """The menu bar app. `install` builds it here and installs it; running + it again after `stack update` updates it. Without a verb, the verbs.""" + if not args.verb: + report = {"commands": APP_VERBS} + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + print() + for verb, what in APP_VERBS.items(): + print(f" {TEAL}./stack app {verb}{RESET} {DIM}{what}{RESET}") + print() + return + if args.verb not in APP_VERBS: + print_error({"error": f"Unknown verb '{args.verb}'. Use: {', '.join(APP_VERBS)}"}) + sys.exit(1) + + if not args.json: + print("\n Building the menu bar app (about a minute)...") + result = menubar_app.install(stck.root, open_after=not args.json) + if "error" in result: + print_error(result) + sys.exit(1) + if args.json: + json.dump(result, sys.stdout, indent=2) + print() + return + print(f" {GREEN}\u2713{RESET} Installed {TEAL}{result['installed']}{RESET}") + print(f" {DIM}It sits in the menu bar. Setup \u2192 Connection picks this Mac " + f"or another one over SSH.{RESET}\n") + + +def _uptime_seconds() -> int | None: + """Seconds since boot, from `sysctl kern.boottime` (`{ sec = 1727..., ...}`).""" + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], + capture_output=True, text=True, timeout=5) + match = re.search(r"sec = (\d+)", r.stdout) + return int(time.time()) - int(match.group(1)) if match else None + except Exception: + return None + + +def handle_host(stck, args): + """The machine: memory, disk, uptime, and what each stacklet uses. + + Stacklet figures come from `docker stats`, so a stacklet that runs on + the host itself (the AI server) has none: its processes are not + containers. + """ + known = {s["id"] for s in stck.discover()} + stats = docker.container_stats() + per_stacklet: dict[str, dict] = {} + for c in docker.stack_containers(): + if c["stacklet"] not in known or c["name"] not in stats: + continue + entry = per_stacklet.setdefault( + c["stacklet"], {"id": c["stacklet"], "memory_bytes": 0, "cpu_pct": 0.0, "containers": 0}) + entry["memory_bytes"] += stats[c["name"]]["memory_bytes"] + entry["cpu_pct"] = round(entry["cpu_pct"] + stats[c["name"]]["cpu_pct"], 1) + entry["containers"] += 1 + + report = { + **stck._host_stats(), + "uptime_seconds": _uptime_seconds(), + "stacklets": sorted(per_stacklet.values(), key=lambda e: e["memory_bytes"], reverse=True), + } + + if args.json: + json.dump(report, sys.stdout, indent=2) + print() + return + + print(f"\n {BOLD}Host{RESET}") + if "memory_total_gb" in report: + print(f" Memory {report.get('memory_used_gb', '?')} of {report['memory_total_gb']} GB used") + if "disk_total_gb" in report: + print(f" Disk {report['disk_free_gb']} GB free of {report['disk_total_gb']} GB " + f"({report['disk_used_pct']}% used)") + if report["uptime_seconds"] is not None: + days, rest = divmod(report["uptime_seconds"], 86400) + print(f" Up {days} days, {rest // 3600} hours") + if report["stacklets"]: + print(f"\n {BOLD}Stacklets by memory{RESET}") + for e in report["stacklets"]: + print(f" {e['id']:<12}{e['memory_bytes'] / 1024 ** 3:>6.2f} GB" + f" {e['cpu_pct']:>5.1f}% CPU {DIM}{e['containers']} containers{RESET}") + print() + + def handle_restart(stck, args): cli = CLI(stck) @@ -1743,6 +1948,11 @@ def handle_update(stck, args): print(f"\n {GREEN}\u2713{RESET} Updated to {TEAL}{target}{RESET}") _print_restart_advice(targets, changed, running, touches_framework, touched_stacklets) + # The app is built from the checkout, so moving the checkout leaves the + # installed one behind until it is built again. + if menubar_app.installed(): + print(f" {DIM}The menu bar app is built from the checkout:{RESET} " + f"{TEAL}./stack app install{RESET} {DIM}updates it.{RESET}\n") def print_restart_call_to_action(targets, framework=False) -> None: @@ -1887,6 +2097,9 @@ def _plugin_help(module_path: str): "restart": handle_restart, "setup": handle_setup, "logs": handle_logs, + "errors": handle_errors, + "host": handle_host, + "app": handle_app, "version": handle_version, "update": handle_update, } @@ -1908,9 +2121,12 @@ def _plugin_help(module_path: str): ("config admin", "Print tech admin credentials"), ("env ", "Print rendered environment variables"), ("logs ", "Tail container logs"), + ("errors [--since 24h]", "Recent error lines across every stacklet"), + ("host", "Memory, disk, uptime, and what each stacklet uses"), ]), ("Setup", [ ("update []", "Move the checkout to a release (says what to restart)"), + ("app install", "Build the menu bar app and put it in Applications"), ("install", "Interactive setup wizard"), ("uninstall", "Remove all services, config, and data"), ("init", "Create Docker network and data directories"), @@ -1990,9 +2206,12 @@ def main(): ) sub.add_parser("init") sub.add_parser("status") - sub.add_parser("doctor") + p = sub.add_parser("doctor") + p.add_argument("--json", action="store_true", help="Output as JSON") sub.add_parser("list") p = sub.add_parser("config") + p.add_argument("--json", action="store_true", + help="stack.toml and users.toml as JSON, credentials hidden") config_sub = p.add_subparsers(dest="config_action") config_sub.add_parser("admin") sub.add_parser("help") @@ -2028,6 +2247,14 @@ def main(): p.add_argument("--tail", default=200, type=int) p.add_argument("--grep", default=None, help="Filter log lines with grep pattern") p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("errors") + p.add_argument("--since", default="24h", help="How far back to look: 30m, 24h, 168h") + p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("app") + p.add_argument("verb", nargs="?", default=None, help="install") + p.add_argument("--json", action="store_true", help="Output as JSON") + p = sub.add_parser("host") + p.add_argument("--json", action="store_true", help="Output as JSON") # Stacklet CLI plugins stacklet_cmds = {} diff --git a/lib/stack/docker.py b/lib/stack/docker.py index ff7199b6..51e5faeb 100644 --- a/lib/stack/docker.py +++ b/lib/stack/docker.py @@ -330,42 +330,128 @@ def project_states() -> dict[str, str]: return {} -def containers_for(stacklet_id: str) -> list[dict]: - """Every container of a stacklet, running or not. +_PROJECT_LABEL = "com.docker.compose.project" +_PROJECT_PREFIX = "stack-" + - Returns dicts with name, state, exit_code and a human "since" string. - `stack status` only reports the stacklet as a whole, so a single dead - sidecar shows up as "failing" with no clue which one died. +def _parse_ps_line(line: str) -> dict | None: + """One `docker ps` row (name, project, state, status) as a container dict. + + The stacklet id comes from the compose project, `stack-`. A + container that belongs to another project yields None. """ + parts = line.split("\t") + if len(parts) != 4: + return None + name, project, state, status = parts + if not project.startswith(_PROJECT_PREFIX): + return None + # "Exited (128) 3 weeks ago" -> code 128, "3 weeks ago" + code, since = 0, status + if status.startswith("Exited ("): + head, _, tail = status.partition(")") + try: + code = int(head[len("Exited ("):]) + except ValueError: + code = 1 + since = tail.strip() + return {"name": name, "stacklet": project[len(_PROJECT_PREFIX):], + "state": state, "exit_code": code, "since": since} + + +def _ps(label_filter: str) -> list[dict]: try: r = _docker( - "ps", "-a", "--filter", f"name=^stack-{stacklet_id}-", - "--format", "{{.Names}}\t{{.State}}\t{{.Status}}", + "ps", "-a", "--filter", f"label={label_filter}", + "--format", f'{{{{.Names}}}}\t{{{{.Label "{_PROJECT_LABEL}"}}}}' + "\t{{.State}}\t{{.Status}}", capture_output=True, text=True, timeout=10, ) if r.returncode != 0: return [] - out = [] - for line in r.stdout.strip().splitlines(): - parts = line.split("\t") - if len(parts) != 3: - continue - name, state, status = parts - # "Exited (128) 3 weeks ago" -> code 128, "3 weeks ago" - code, since = 0, status - if status.startswith("Exited ("): - head, _, tail = status.partition(")") - try: - code = int(head[len("Exited ("):]) - except ValueError: - code = 1 - since = tail.strip() - out.append({"name": name, "state": state, "exit_code": code, "since": since}) - return out + rows = (_parse_ps_line(line) for line in r.stdout.strip().splitlines()) + return [row for row in rows if row] except Exception: return [] +def containers_for(stacklet_id: str) -> list[dict]: + """Every container of a stacklet, running or not. + + Returns dicts with name, stacklet, state, exit_code and a human "since" + string. `stack status` only reports the stacklet as a whole, so a single + dead sidecar shows up as "failing" with no clue which one died. + + Matched by compose project rather than by name: a stacklet with one + service is named `stack-`, without the `-` suffix a name + pattern would need. + """ + return _ps(f"{_PROJECT_LABEL}={_PROJECT_PREFIX}{stacklet_id}") + + +def stack_containers() -> list[dict]: + """Every container of every stacklet, in one query. Same dicts as + `containers_for`. Projects of other tools on the same Docker are left + out; a `stack-` project whose stacklet no longer exists is not, so + callers filter by the stacklets they know.""" + return _ps(_PROJECT_LABEL) + + +def container_logs(name: str, since: str) -> str: + """A container's log since `since` (a docker duration such as `24h`), + stdout and stderr together, each line prefixed with its timestamp so + the two streams can be put back in order.""" + try: + r = _docker("logs", "--since", since, "--timestamps", name, + capture_output=True, text=True, timeout=60) + return r.stdout + r.stderr + except Exception: + return "" + + +_SIZE_UNITS = {"b": 1, "kb": 1000, "mb": 1000 ** 2, "gb": 1000 ** 3, "tb": 1000 ** 4, + "kib": 1024, "mib": 1024 ** 2, "gib": 1024 ** 3, "tib": 1024 ** 4} + + +def parse_size(text: str) -> int: + """`docker stats` sizes (`512MiB`, `1.2GiB`, `0B`) as bytes; 0 if unreadable.""" + text = text.strip() + digits = text.rstrip("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + unit = text[len(digits):].lower() + try: + return int(float(digits) * _SIZE_UNITS[unit]) + except (ValueError, KeyError): + return 0 + + +def container_stats() -> dict[str, dict]: + """Memory and CPU of every running container, keyed by name. + + `docker stats --no-stream` samples for about two seconds, so this is + for a command someone asked for, not for anything run on every poll. + """ + try: + r = _docker("stats", "--no-stream", "--format", + "{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}", + capture_output=True, text=True, timeout=30) + if r.returncode != 0: + return {} + except Exception: + return {} + stats = {} + for line in r.stdout.strip().splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + name, mem, cpu = parts + try: + cpu_pct = float(cpu.strip().rstrip("%")) + except ValueError: + cpu_pct = 0.0 + stats[name] = {"memory_bytes": parse_size(mem.split("/")[0]), "cpu_pct": cpu_pct} + return stats + + def _parse_env(text: str) -> dict: """Turn `docker inspect`'s KEY=VALUE lines into a dict.""" env = {} diff --git a/lib/stack/doctor.py b/lib/stack/doctor.py index 3e69d702..36feb942 100644 --- a/lib/stack/doctor.py +++ b/lib/stack/doctor.py @@ -99,7 +99,7 @@ def check_env_drift(stacklet: str, container: str, drifted: list[str]) -> Findin ) -def check_exited(container: str, exit_code: int, since: str) -> Finding | None: +def check_exited(stacklet: str, container: str, exit_code: int, since: str) -> Finding | None: """A container that stopped and stayed stopped. `stack status` reports the stacklet as failing without naming which @@ -112,7 +112,7 @@ def check_exited(container: str, exit_code: int, since: str) -> Finding | None: level=ERROR, title=f"{container} exited ({exit_code})", detail=f"Stopped {since} and has not come back.", - fix=f"stack logs {container.split('-')[1] if '-' in container else container}", + fix=f"stack logs {stacklet}", ) @@ -315,7 +315,7 @@ def diagnose(stacklets, expected_env, containers_for, container_env, for container in containers: name = container["name"] if container["state"] != "running": - found = check_exited(name, container["exit_code"], container["since"]) + found = check_exited(stacklet, name, container["exit_code"], container["since"]) if found: findings.append(found) # A stopped container's environment says nothing useful. diff --git a/lib/stack/logscan.py b/lib/stack/logscan.py new file mode 100644 index 00000000..4c1d819a --- /dev/null +++ b/lib/stack/logscan.py @@ -0,0 +1,68 @@ +"""Find the error lines in container logs. + +`stack errors` answers "what went wrong recently, anywhere?" without the +reader opening every stacklet's log. Each service writes its own format, so +a line is an error when it carries an error *level* in one of the formats +the stack's services use. The word alone does not count: "0 errors", +"error_count=0" and a request path containing "error" are ordinary lines. + +This module is pure: it takes log text and returns a summary. Reading the +logs is the caller's job. +""" + +from __future__ import annotations + +import re + +# One alternative per log format, each anchored on how that format writes +# its level, never on the message text. +_ERROR_LEVEL = re.compile("|".join([ + # A level token as a word: Python logging and loguru (`| ERROR |`, + # `- ERROR -`, `[ERROR]`), Postgres (`ERROR:`, `FATAL:`, `PANIC:`), + # Java/Nest style (`ERROR [main]`). + r"\b(?:ERROR|CRITICAL|FATAL|PANIC)\b", + # Forgejo/Gitea: a one-letter level in brackets. E error, C critical, F fatal. + r"\s\[[ECF]\]\s", + # nginx error log: lowercase level in brackets. + r"\[(?:error|crit|alert|emerg)\]", + # logfmt (Watchtower, Go services) and JSON loggers (Caddy). + r"\blevel=(?:error|fatal|panic)\b", + r"\"level\":\s*\"(?:error|fatal|panic)\"", + # A Python traceback's first line; the frames after it are not counted. + r"^Traceback \(most recent call last\):", +])) + + +def is_error(line: str) -> bool: + return bool(_ERROR_LEVEL.search(line)) + + +def _split_timestamp(line: str) -> tuple[str, str]: + """`docker logs --timestamps` puts an RFC 3339 time and a space in front + of every line. A line without one sorts first.""" + stamp, sep, rest = line.partition(" ") + if sep and len(stamp) >= 20 and stamp[4] == "-" and stamp[10] == "T": + return stamp, rest + return "", line + + +def scan(log: str, keep: int = 5) -> dict: + """Count the error lines in a timestamped log and keep the newest. + + `log` is stdout and stderr concatenated, so lines are put back in time + order first. Returns `count`, `last_at` (the newest error's time, or + None) and `lines`, the newest `keep` errors oldest first, each as + `{"at", "text"}`. + """ + errors = [] + for raw in log.splitlines(): + at, text = _split_timestamp(raw) + if is_error(text): + errors.append((at, text.rstrip())) + errors.sort(key=lambda e: e[0]) + newest = errors[-keep:] if keep else [] + return { + "count": len(errors), + "last_at": (errors[-1][0] or None) if errors else None, + "lines": [{"at": at or None, "text": text} for at, text in newest], + } diff --git a/lib/stack/stack.py b/lib/stack/stack.py index ed242e9a..3ed586b8 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -751,6 +751,10 @@ def list(self) -> dict: s["online"] = docker_state == "running" s["degraded"] = False s["health_issues"] = [] + # The address a person opens, for clients that are not on this + # Mac and cannot use localhost. + if s.get("port"): + s["url"] = self._public_url(sid, s["port"]) # A stacklet whose manifest renders a `remote` address is # served by another machine. Its row reports that server, diff --git a/tests/unit/framework/test_app_install.py b/tests/unit/framework/test_app_install.py new file mode 100644 index 00000000..81a408b6 --- /dev/null +++ b/tests/unit/framework/test_app_install.py @@ -0,0 +1,107 @@ +"""`stack app install`: the menu bar app, built from the checkout and installed. + +The app is built on the Mac that runs it (it is signed for that Mac only), +so installing means: run the checkout's build script, put the bundle in an +Applications folder, replace whatever version was there. Running it again +after `stack update` is how the app is updated. + +These tests drive `install` against a checkout whose build script writes a +stand-in bundle, so they run without Swift. +""" + +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "lib")) + +from stack import app # noqa: E402 + +BUILD = """#!/bin/sh +set -e +cd "$(dirname "$0")" +mkdir -p build/famstack.app/Contents +echo "{marker}" > build/famstack.app/Contents/marker +echo "$(pwd)/build/famstack.app" +""" + + +def _checkout(tmp_path: Path, marker: str = "new", script: str = BUILD) -> Path: + root = tmp_path / "checkout" + build = root / "apps" / "macos" / "build.sh" + build.parent.mkdir(parents=True, exist_ok=True) + build.write_text(script.format(marker=marker)) + build.chmod(build.stat().st_mode | stat.S_IEXEC) + return root + + +def _marker(bundle: Path) -> str: + return (bundle / "Contents" / "marker").read_text().strip() + + +class TestInstallingTheApp: + + def test_the_built_app_lands_in_applications(self, tmp_path): + root = _checkout(tmp_path) + apps = tmp_path / "Applications" + apps.mkdir() + + result = app.install(root, folders=[apps], open_after=False) + + assert result["installed"] == str(apps / "famstack.app") + assert _marker(apps / "famstack.app") == "new" + + def test_installing_again_replaces_the_old_app(self, tmp_path): + # The update path: an older build is there and must not survive in parts. + apps = tmp_path / "Applications" + old = apps / "famstack.app" / "Contents" + old.mkdir(parents=True) + (old / "marker").write_text("old") + (old / "left-over").write_text("from the old build") + + app.install(_checkout(tmp_path), folders=[apps], open_after=False) + + assert _marker(apps / "famstack.app") == "new" + assert not (apps / "famstack.app" / "Contents" / "left-over").exists() + + def test_a_folder_that_cannot_be_written_falls_back_to_the_next(self, tmp_path): + locked = tmp_path / "Applications" + locked.mkdir() + locked.chmod(0o555) + home_apps = tmp_path / "home" / "Applications" + try: + result = app.install(_checkout(tmp_path), folders=[locked, home_apps], open_after=False) + finally: + locked.chmod(0o755) + + assert result["installed"] == str(home_apps / "famstack.app") + + def test_a_failed_build_installs_nothing_and_says_why(self, tmp_path): + apps = tmp_path / "Applications" + apps.mkdir() + root = _checkout(tmp_path, script="#!/bin/sh\necho 'error: no such module SwiftUI' >&2\nexit 1\n") + + result = app.install(root, folders=[apps], open_after=False) + + assert "error" in result + assert not (apps / "famstack.app").exists() + + def test_a_checkout_without_the_app_says_so(self, tmp_path): + result = app.install(tmp_path, folders=[tmp_path], open_after=False) + + assert "apps/macos" in result["error"] + + +def test_installed_finds_the_app_in_either_folder(tmp_path): + home_apps = tmp_path / "home" / "Applications" + (home_apps / "famstack.app").mkdir(parents=True) + + assert app.installed(folders=[tmp_path / "Applications", home_apps]) == home_apps / "famstack.app" + assert app.installed(folders=[tmp_path / "nowhere"]) is None + + +def test_the_default_folders_are_the_system_then_the_users_applications(): + assert app.FOLDERS == [Path("/Applications"), Path(os.path.expanduser("~/Applications"))] diff --git a/tests/unit/framework/test_config_report.py b/tests/unit/framework/test_config_report.py new file mode 100644 index 00000000..d5f017e1 --- /dev/null +++ b/tests/unit/framework/test_config_report.py @@ -0,0 +1,82 @@ +"""`stack config --json` shows an instance's setup without its secrets. + +Anything that reads the report (the menu bar app, a script, an agent) sees +the admin's choices, never a credential: stack.toml can hold an API key for +a remote AI server, and users.toml can hold passwords. +""" + +import json + +from stack.cli import config_report + + +STACK_TOML = """ +[core] +name = "famstack" +domain = "home.example" +language = "de" + +[ai] +provider = "remote" +openai_url = "https://ai.example/v1" +openai_key = "sk-live-4f9a" +whisper_key = "" + +[backup.targets.attic] +disk = "Attic" +token = "tok-8812" +""" + +USERS_TOML = """ +[[users]] +id = "homer" +name = "Homer" +role = "admin" +password = "donuts-4ever" + +[[users]] +id = "lisa" +name = "Lisa" +role = "member" +stacklets = ["photos", "docs"] +""" + + +def _instance(tmp_path, stack: str | None = STACK_TOML, users: str | None = USERS_TOML): + if stack is not None: + (tmp_path / "stack.toml").write_text(stack) + if users is not None: + (tmp_path / "users.toml").write_text(users) + return tmp_path + + +def test_no_secret_value_appears_anywhere_in_the_report(tmp_path): + text = json.dumps(config_report(_instance(tmp_path))) + for secret in ("sk-live-4f9a", "tok-8812", "donuts-4ever"): + assert secret not in text + + +def test_the_admins_choices_are_shown_as_written(tmp_path): + report = config_report(_instance(tmp_path)) + assert report["config"]["core"] == {"name": "famstack", "domain": "home.example", "language": "de"} + assert report["config"]["ai"]["openai_url"] == "https://ai.example/v1" + assert [(u["name"], u["role"]) for u in report["users"]] == [("Homer", "admin"), ("Lisa", "member")] + assert report["users"][1]["stacklets"] == ["photos", "docs"] + + +def test_a_hidden_secret_still_says_whether_it_is_set(tmp_path): + ai = config_report(_instance(tmp_path))["config"]["ai"] + assert ai["openai_key"] != "" and ai["openai_key"] != "sk-live-4f9a" + assert ai["whisper_key"] == "" + + +def test_the_report_names_both_files_so_they_can_be_opened(tmp_path): + report = config_report(_instance(tmp_path)) + assert report["stack_toml"] == str(tmp_path / "stack.toml") + assert report["users_toml"] == str(tmp_path / "users.toml") + + +def test_an_instance_without_config_reports_nothing_rather_than_failing(tmp_path): + report = config_report(_instance(tmp_path, stack=None, users=None)) + assert report["config"] == {} + assert report["users"] == [] diff --git a/tests/unit/framework/test_docker_stats.py b/tests/unit/framework/test_docker_stats.py new file mode 100644 index 00000000..5056567d --- /dev/null +++ b/tests/unit/framework/test_docker_stats.py @@ -0,0 +1,17 @@ +"""`stack host` reads memory from `docker stats`, which prints sizes in its +own units: binary (`MiB`, `GiB`) for memory, decimal (`kB`, `MB`) for I/O.""" + +from stack.docker import parse_size + + +def test_docker_stats_sizes_read_as_bytes(): + assert parse_size("512MiB") == 512 * 1024 ** 2 + assert parse_size("1.5GiB") == int(1.5 * 1024 ** 3) + assert parse_size("7.8kB") == 7800 + assert parse_size("0B") == 0 + + +def test_an_unreadable_size_counts_as_nothing(): + # A container that is starting reports `--` until its first sample. + assert parse_size("--") == 0 + assert parse_size("") == 0 diff --git a/tests/unit/framework/test_doctor.py b/tests/unit/framework/test_doctor.py index 5d2fd377..48c6c3d6 100644 --- a/tests/unit/framework/test_doctor.py +++ b/tests/unit/framework/test_doctor.py @@ -249,19 +249,27 @@ def test_no_drift_produces_no_finding(): def test_clean_exit_is_not_a_finding(): - assert check_exited("stack-core-job", 0, "2 minutes ago") is None + assert check_exited("core", "stack-core-job", 0, "2 minutes ago") is None def test_nonzero_exit_names_the_container_and_code(): # The real case: watchtower Exited(128) three weeks ago, while status # only said the stacklet was failing. - finding = check_exited("stack-core-watchtower", 128, "3 weeks ago") + finding = check_exited("core", "stack-core-watchtower", 128, "3 weeks ago") assert finding.is_error assert "stack-core-watchtower" in finding.title assert "128" in finding.title assert "3 weeks ago" in finding.detail +def test_the_fix_reads_the_logs_of_the_containers_stacklet(): + # A container left from an older version of a stacklet keeps its old + # name ("famstack-wiki-preview-de" in the memory project); the name says + # nothing about which stacklet's logs to read. + finding = check_exited("memory", "famstack-wiki-preview-de", 1, "11 days ago") + assert finding.fix == "stack logs memory" + + # ── missing credentials ────────────────────────────────────────────────── # # The real incident: `memory` mints its Forgejo write token in diff --git a/tests/unit/framework/test_list_urls.py b/tests/unit/framework/test_list_urls.py new file mode 100644 index 00000000..92ed9860 --- /dev/null +++ b/tests/unit/framework/test_list_urls.py @@ -0,0 +1,52 @@ +"""`stack list` names the address a person opens for each stacklet. + +A client on another machine (the menu bar app over SSH, a script) cannot +use `localhost`, and should not rebuild the address rule itself: the +stack already knows it, because it hands the same URL to the stacklets as +`{url}`. In port mode that is this Mac's LAN address and the port; in +domain mode the stacklet's name under the domain. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import stack.docker +from stack import Stack + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.fixture +def instance(tmp_path, monkeypatch): + def make(core: str) -> Stack: + (tmp_path / "stack.toml").write_text( + f'[core]\ndata_dir = "{tmp_path / "data"}"\n{core}\n' + '\n[messages]\nserver_name = "simpson"\n') + (tmp_path / ".stack").mkdir(exist_ok=True) + return Stack(REPO_ROOT, tmp_path / "data", instance_dir=tmp_path) + # No Docker in the unit lane: every stacklet reads as not running. + monkeypatch.setattr(stack.docker, "project_states", lambda: {}) + return make + + +def _rows(stck: Stack) -> dict: + return {s["id"]: s for s in stck.list()["stacklets"]} + + +def test_port_mode_names_this_macs_address_and_the_port(instance): + rows = _rows(instance('host = "server.lan"')) + assert rows["photos"]["url"] == "http://server.lan:42010" + assert rows["docs"]["url"] == "http://server.lan:42020" + + +def test_domain_mode_names_the_stacklet_under_the_domain(instance): + rows = _rows(instance('domain = "home.example"')) + assert rows["photos"]["url"] == "http://photos.home.example" + + +def test_a_stacklet_without_a_web_port_has_no_address(instance): + rows = _rows(instance('host = "server.lan"')) + assert "url" not in rows["core"] diff --git a/tests/unit/framework/test_logscan.py b/tests/unit/framework/test_logscan.py new file mode 100644 index 00000000..09bb5be0 --- /dev/null +++ b/tests/unit/framework/test_logscan.py @@ -0,0 +1,79 @@ +"""`stack errors` reports a line only when its log format marks it as an error. + +The lines below are written in the formats the stack's services actually +emit. The ordinary ones are copied from a running instance; an error line is +the same format with its level changed, because the counterpart of every +INFO line is what each service prints when something fails. +""" + +from stack.logscan import is_error, scan + + +# ── What counts as an error ──────────────────────────────────────────────── + +ERROR_LINES = { + "synapse": "2026-09-24 22:26:57,521 - synapse.http.server - 124 - ERROR - GET-42 - Failed handle request", + "loguru bot": "2026-09-25 13:01:35 | ERROR | [archivist-bot] Paperless upload failed", + "loguru curator": "2026-09-25 08:48:40.162 | ERROR | __main__:_run_command:789 - [curator] wiki build failed", + "paperless": "[2026-09-25 15:30:00,167] [ERROR] [paperless.consumer] Error while consuming document", + "postgres": "2026-09-25 13:27:57.951 UTC [28] ERROR: relation \"x\" does not exist", + "postgres fatal": "2026-09-25 13:27:57.951 UTC [28] FATAL: password authentication failed for user \"synapse\"", + "forgejo": "2026/09/25 15:31:07 ...eb/routing/logger.go:102:func1() [E] router: failed to serve", + "tika": "ERROR [main] 13:00:36,716 org.apache.tika.server.core.TikaServerProcess crashed", + "nginx": "2026/09/25 15:31:07 [error] 29#29: *1 open() \"/usr/share/nginx/html/x\" failed", + "watchtower": "time=\"2026-09-25T15:31:07Z\" level=error msg=\"Could not do a head request\"", + "caddy": "{\"level\":\"error\",\"ts\":1727270000.1,\"logger\":\"http.log.error\",\"msg\":\"dial tcp: connection refused\"}", + "python traceback": "Traceback (most recent call last):", +} + +ORDINARY_LINES = { + "synapse warning": "2026-09-24 22:26:57,522 - synapse.config.logger - 378 - WARNING - main - Server version 1.161.0", + "loguru info": "2026-09-25 13:01:35 | INFO | [stacker-bot] Running", + "paperless info": "[2026-09-25 15:30:00,256] [INFO] [celery.app.trace] Task succeeded: 'No new documents were added'", + "forgejo info": "2026/09/25 15:31:07 ...eb/routing/logger.go:102:func1() [I] router: completed GET /family/brain.git 200 OK", + "postgres log": "2026-09-25 13:28:02.511 UTC [28] LOG: checkpoint complete: wrote 45 buffers (0.3%)", + "the word, lowercase": "2026-09-25 13:01:35 | INFO | [archivist-bot] retried: 0 errors", + "a path": "2026/09/25 15:31:07 ...logger.go:102:func1() [I] router: completed GET /api/error_reports 200 OK", + "a traceback frame": ' File "/app/bot.py", line 12, in run', +} + + +def test_every_services_error_format_is_recognised(): + missed = [name for name, line in ERROR_LINES.items() if not is_error(line)] + assert missed == [] + + +def test_ordinary_lines_that_mention_errors_are_not_counted(): + flagged = [name for name, line in ORDINARY_LINES.items() if is_error(line)] + assert flagged == [] + + +# ── Summarising a container's log ────────────────────────────────────────── + +def _stamped(at: str, line: str) -> str: + return f"2026-09-25T{at}.000000000Z {line}" + + +def test_a_quiet_log_reports_nothing(): + log = "\n".join(_stamped("10:00:00", line) for line in ORDINARY_LINES.values()) + assert scan(log) == {"count": 0, "last_at": None, "lines": []} + + +def test_stdout_and_stderr_are_merged_back_into_time_order(): + # docker logs hands both streams back one after the other, so the + # newest error can come first in the text. + stdout = _stamped("12:00:00", ERROR_LINES["loguru bot"]) + stderr = _stamped("09:00:00", ERROR_LINES["python traceback"]) + result = scan(stdout + "\n" + stderr) + assert result["count"] == 2 + assert result["last_at"] == "2026-09-25T12:00:00.000000000Z" + assert [line["text"] for line in result["lines"]] == [ + ERROR_LINES["python traceback"], ERROR_LINES["loguru bot"]] + + +def test_only_the_newest_errors_are_kept_but_all_are_counted(): + log = "\n".join(_stamped(f"10:00:{s:02d}", f"| ERROR | failure {s}") for s in range(20)) + result = scan(log, keep=3) + assert result["count"] == 20 + assert [line["text"] for line in result["lines"]] == [ + "| ERROR | failure 17", "| ERROR | failure 18", "| ERROR | failure 19"]