Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,10 @@ venv/
.understand-anything/
.codenomad/
catalog_output.json

# Swift Package Manager build artifacts (apps/gate-bar)
.build/
.swiftpm/
*.xcodeproj/
xcuserdata/
DerivedData/
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# fusionAIze Gate Changelog

## v2.3.0 - 2026-04-19

### Added

- **Brand-first quota widget** (`/dashboard/quotas`): replaces the flat package list with one card per brand (Claude, DeepSeek, Kilo, …). Cards are sorted worst-alert first so the thing about to break is at the top. Each card stacks its sub-packages (session vs weekly, pay-as-you-go vs credits) with a pace marker on every window-based bar and an identity line (`OAuth · claude-code`, `API key · ${KEY}`) so you can tell which account feeds the meter.
- **Per-brand detail view** (`/dashboard/quotas/<brand_slug>`): a focused page that shows the same quota panel plus a 24h totals strip, clients-by-profile table, routes/lanes breakdown, and an hourly sparkline. Shares CSS variables with the overview so scanning between them needs no retraining.
- **Read-only brand endpoints** (`/api/quotas/<slug>/clients`, `/routes`, `/analytics`): the data feed behind the detail view. 404 on unknown brands (distinguishes "typo" from "no traffic yet"); analytics clamps `hours=1..168` and `days=1..90` to prevent URL-typo DB scans. Catalog surfaces `catalog_tagline` so the "Available to add" mini-block can show tier/price/quota shape at a glance.
- **Default landing view** (`dashboard.quotas.default_view` in `config.yaml`): three options — `overview` (default), `brand:<slug>`, `cockpit`. `GET /dashboard/quotas` honors the setting via 302; `?view=overview` is an always-available escape hatch. A `Pin as Home` / `📌 Home` button sits on every brand card and the detail-page header — one-click promotion, no modal. Writes go through `ruamel.yaml` round-trip so the 220+ operator comments in a real `config.yaml` survive a pin toggle (`yaml.safe_dump` would flatten them). `GET/POST /api/dashboard/settings` drives the widget and is available to external consumers.
- **Gate Bar 0.1 (macOS menubar companion)** at `apps/gate-bar/`: SwiftUI `MenuBarExtra` app that reads the same `/api/quotas` feed and shows `fAI · 83%` in the menubar (tightest window across all brands, colour-coded). Popover renders brand cards with the web widget's visual vocabulary; footer links to `Dashboard ↗` (server-side redirect honours `default_view`) and `Cockpit ↗`. Preferences: gateway URL, Cockpit URL, refresh cadence (manual / 1 / 2 / 5 / 15 min). 13 Swift-Testing tests green on both Xcode.app and Command Line Tools via `scripts/swift-test.sh`. Read-only — every write path links out to the Operator Cockpit. Sparkle auto-update + notifications + code signing + Homebrew cask tracked for 0.2+ (`apps/gate-bar/README.md`).

### Changed

- Quota widget groups by `provider_id` first and gates on credential availability so brands without a resolvable API key / OAuth token land in the collapsed "Skipped" block instead of showing a perpetually-empty bar.
- `QuotaStatus` payload carries `brand`, `brand_slug`, `pace_delta`, and `identity` on every package (v1.3 catalog schema). Older v1.2 catalogs still decode through the brand-fallback table in `quota_tracker.py`.

### Upgrade notes

- New runtime dependency: `ruamel.yaml>=0.18.6` (already added to `requirements.txt` + `pyproject.toml`). Fresh `pip install faigate` or `brew upgrade faigate` picks it up automatically.
- The Gate Bar macOS app is a separate artifact. v0.1 is source-only — build it with `cd apps/gate-bar && ./scripts/install-local.sh` for local testing. A notarized Homebrew cask ships with Gate Bar 0.2.

## v2.2.3 - 2026-04-18

### Fixed
Expand Down
44 changes: 44 additions & 0 deletions apps/gate-bar/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// swift-tools-version:5.9
//
// fusionAIze Gate Bar — macOS menubar companion for the faigate local gateway.
//
// Design anchors (see ../../docs/GATE-BAR-DESIGN.md §5):
//
// - macOS 14 (Sonoma) minimum — keeps two-year-old Intel MacBooks alive.
// - Universal binary (x86_64 + arm64). SPM handles this at release time via
// swift build -c release --arch x86_64 --arch arm64
// - SwiftUI surface is the Sonoma subset: ObservableObject, Combine, plain
// Color. No @Observable, no MeshGradient.
// - Pure HTTP consumer of the local gateway — no shared state, no socket,
// no filesystem coupling with the Python daemon.
//
// Why SPM executable instead of an .xcodeproj:
// The monorepo is CLI-first; a hand-crafted Package.swift keeps the app
// reviewable in a diff and builds with `swift build` on any machine with the
// Xcode command-line tools. Opening `apps/gate-bar/Package.swift` in Xcode
// still gives the full GUI editor for anyone who wants one.
import PackageDescription

let package = Package(
name: "GateBar",
platforms: [
.macOS(.v14),
],
products: [
// The app binary itself. Distribution (notarization, .app bundling,
// Sparkle, Homebrew cask) is release-engineering scaffolding tracked
// separately — not wired into the SPM manifest.
.executable(name: "GateBar", targets: ["GateBar"]),
],
targets: [
.executableTarget(
name: "GateBar",
path: "Sources/GateBar"
),
.testTarget(
name: "GateBarTests",
dependencies: ["GateBar"],
path: "Tests/GateBarTests"
),
]
)
117 changes: 117 additions & 0 deletions apps/gate-bar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# fusionAIze Gate Bar

A macOS menubar companion for the [faigate](../../README.md) local gateway.
Shows every active provider's quota at a glance, colour-coded by severity,
so you can answer "am I about to hit a session cap?" in under three seconds
without switching tabs.

> **Status:** v0.1 — scaffold in place, read-only consumer of the local
> gateway's `/api/quotas` endpoint. Sparkle auto-update, notifications,
> code signing, and Homebrew cask distribution are tracked separately and
> not wired up yet.

## What it does today

- **Menubar label** — `fAI · 83%` plus a coloured dot for the tightest
window across all active brands. Click to open the popover.
- **Popover** — one card per brand (Claude, Codex, DeepSeek, …), sorted
worst-alert first. Each card shows package bars with a pace marker,
identity line, and reset time.
- **"Available to add" mini-catalog** — brands the operator hasn't wired
up yet, each with a deep link to the Operator Cockpit's onboarding flow.
- **Preferences** — gateway URL, Cockpit URL, refresh cadence (manual /
1 / 2 / 5 / 15 min; default 5).
- **Privacy posture** — reads from `127.0.0.1` only. Gate Bar never talks
to a fusionAIze-hosted service; the "Cockpit ↗" button just opens a web
page in your default browser.

## Design anchors

The full design doc is at `../../docs/GATE-BAR-DESIGN.md`. Three rules
shape every file in this directory:

1. **Pure HTTP client.** No shared state, no socket, no filesystem
coupling with the Python daemon. Every provider the Gate Bar renders
is discovered at runtime from `GET /api/quotas`.
2. **macOS 14+ Sonoma.** Two-year-old Intel MacBooks still run it.
SwiftUI surface is the Sonoma subset: `ObservableObject` + Combine,
plain `Color`, no `@Observable`, no `MeshGradient`.
3. **Read-only.** Nothing in the menubar writes config or wakes up an
onboarding wizard. Every action that mutates state deep-links to the
Operator Cockpit.

## Build & run

Requires the Xcode Command Line Tools (`xcode-select --install`) or
Xcode.app. Swift 5.9+ toolchain.

```bash
cd apps/gate-bar
swift build # debug build of the executable target
swift run GateBar # launches the menubar app
```

The app launches as a `LSUIElement`-style menubar-only process — there's
no Dock icon or main window. Quit from the popover's "Quit" button or via
`⌘Q` while Gate Bar is the frontmost app.

### Running against a local gateway

By default Gate Bar talks to `http://127.0.0.1:4001` — the faigate
default. If you run the gateway on a different port, update it under
`Preferences → Gateway`.

### Tests

```bash
./scripts/swift-test.sh
```

We use the **Swift Testing** framework (`import Testing`) rather than
XCTest because the Command Line Tools ship Testing but not XCTest. The
wrapper script adds the framework search paths dyld needs at runtime; on
a machine with Xcode.app, plain `swift test` also works.

Current coverage (13 tests, 3 suites):

- JSON-decode round-trip against a canned `/api/quotas` payload.
- Forward compatibility — unknown JSON fields don't break decode.
- `AlertLevel` classification (server-label precedence, ratio fallback,
unknown-string degradation, severity ordering).
- `QuotaStore` transforms (brand grouping, worst-alert sort, tie-break
rules, tightest-window menubar summary, identity propagation).

## File map

```
Package.swift # SPM manifest, .macOS(.v14), executable target
Sources/GateBar/
GateBarApp.swift # @main, MenuBarExtra + Settings scenes
Models.swift # Codable mirrors of /api/quotas (plus BrandGroup, AlertLevel)
QuotaClient.swift # URLSession-backed actor, the only network I/O
QuotaStore.swift # ObservableObject — grouping, sorting, menubar summary, timer
Preferences.swift # UserDefaults-backed @Published wrapper
Theme.swift # Colour palette mirroring the web widget's CSS variables
PopoverView.swift # The popover shell — active cards + catalog + footer
BrandCardView.swift # Per-brand card + per-package row with pace tick
PreferencesView.swift # Settings window — 4 controls, no wizards
Tests/GateBarTests/
ModelsTests.swift # JSON decode + AlertLevel classification
QuotaStoreTests.swift # Grouping / sorting / menubar-summary
scripts/
swift-test.sh # `swift test` with CLT-compatible rpaths
```

## Roadmap (not yet shipped)

- [ ] Sparkle 2 auto-update (EdDSA-signed appcast, notarized .dmg from
GitHub releases).
- [ ] Notifications — threshold alerts (session 80 %, weekly 80 %,
pace +10 %) via `UserNotifications`.
- [ ] Launch at login via `SMAppService.mainApp.register()`.
- [ ] `.app` bundle + notarization pipeline in `/.github/workflows`.
- [ ] Homebrew cask: `brew install --cask fusionaize/tap/gate-bar`.
- [ ] Hide-menubar-icon toggle (keeps the app running but invisible).

These are release-engineering passes, not app-code. Tracked against the
v2.3.x release milestone.
169 changes: 169 additions & 0 deletions apps/gate-bar/Sources/GateBar/BrandCardView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import SwiftUI

/// A single brand card in the popover. Visual parity with the web
/// widget's `.brand` block in `_QUOTAS_DASHBOARD_HTML`:
///
/// - brand name left, identity right
/// - one `PackageRow` per package (bar + pace tick + % + under-bar meta)
/// - coloured left border carries the worst-alert signal
struct BrandCardView: View {
let brand: BrandGroup

var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
ForEach(Array(brand.packages.enumerated()), id: \.element.packageId) { index, pkg in
if index > 0 {
Divider()
.background(Theme.border)
.padding(.vertical, 2)
}
PackageRow(package: pkg)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(Theme.card)
.overlay(
Rectangle()
.fill(Theme.color(for: brand.worstAlert))
.frame(width: 3),
alignment: .leading
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Theme.border, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}

private var header: some View {
HStack(alignment: .firstTextBaseline) {
Text(brand.brand)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Theme.foreground)
Spacer(minLength: 8)
if let identity = brand.identity {
Text("\(identity.loginMethod): \(identity.credential)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Theme.dim)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
}

/// One package inside a brand card.
///
/// Renders the same vocabulary as the web row:
/// - package title (left) + percentage (right)
/// - progress bar with an inline pace tick
/// - under-bar meta: `used / total` (left) · reset / days-left (right)
struct PackageRow: View {
let package: QuotaPackage

private var usedRatio: Double {
max(0, min(1, package.usedRatio ?? 0))
}

private var alert: AlertLevel {
AlertLevel(rawAlert: package.alert, usedRatio: package.usedRatio)
}

private var paceFraction: Double? {
// Pace marker only makes sense when both sides of the computation
// are present (rolling_window + daily). Credits packages return nil.
guard package.paceDelta != nil, let elapsed = package.elapsedRatio else {
return nil
}
return max(0, min(1, elapsed))
}

var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(package.packageName ?? package.packageId)
.font(.system(size: 12))
.foregroundColor(Theme.mid)
Spacer(minLength: 8)
Text(percentageLabel)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(Theme.foreground)
}
bar
HStack {
if let used = package.usedDisplay, let total = package.totalDisplay {
Text("\(used) / \(total)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Theme.dim)
}
Spacer(minLength: 8)
Text(resetLabel)
.font(.system(size: 10))
.foregroundColor(Theme.dim)
.lineLimit(1)
}
}
}

private var percentageLabel: String {
let pct = usedRatio * 100
if pct < 10 {
return String(format: "%.1f%%", pct)
}
return "\(Int(pct.rounded()))%"
}

private var resetLabel: String {
if let reset = package.resetAt, !reset.isEmpty {
return "resets \(formatReset(reset))"
}
if let days = package.projectedDaysLeft {
return "~\(Int(days.rounded()))d left"
}
return ""
}

private func formatReset(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: iso) ?? ISO8601DateFormatter.withFractional.date(from: iso) {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .short
return rel.localizedString(for: date, relativeTo: Date())
}
return iso
}

/// Progress bar with an inline pace tick. `GeometryReader` lets us
/// position the tick at `elapsedRatio * width` without measuring text.
private var bar: some View {
GeometryReader { proxy in
ZStack(alignment: .topLeading) {
Capsule()
.fill(Theme.track)
Capsule()
.fill(Theme.color(for: alert))
.frame(width: proxy.size.width * usedRatio)
if let pace = paceFraction {
Rectangle()
.fill(Theme.accent)
.frame(width: 2, height: proxy.size.height + 4)
.offset(x: (proxy.size.width * pace) - 1, y: -2)
}
}
}
.frame(height: 6)
}
}

private extension ISO8601DateFormatter {
/// `/api/quotas` sometimes emits timestamps with fractional seconds
/// depending on the backend; try both shapes.
static let withFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
}
Loading
Loading