feat(devices): add dependency issue warnings

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 21:57:08 -07:00
parent d79e99c21c
commit 0a4cde36d1
6 changed files with 247 additions and 5 deletions

View file

@ -55,6 +55,9 @@ struct DevicesView: View {
.help("Cache the next episodes of your recent shows to this device")
}
if let s = controller.hostStatsByID[d.id] { loadPill(s) }
if let issues = controller.hostStatsByID[d.id]?.deps?.issues, !issues.isEmpty {
depsPill(issues)
}
if case .outdated(let deployed)? = controller.helperFreshness(d.id) {
outdatedPill(deployed: deployed)
}
@ -171,6 +174,11 @@ struct DevicesView: View {
s.load1, s.load5, s.load15, s.cores)
+ (s.mpv_cpu.map { String(format: " · mpv %.0f%% CPU", $0) } ?? ""))
}
if let facts = controller.hostStatsByID[d.id]?.deps?.facts, !facts.isEmpty {
summaryRow("Dependencies", facts.map {
($0.severity == .ok ? "" : "") + $0.text
}.joined(separator: " · "))
}
if let line = deploymentLine(d) { summaryRow("Service", line) }
}
if controller.canRestartService(d.id) || controller.canUpdateService(d.id) {
@ -249,6 +257,21 @@ struct DevicesView: View {
}
}
/// Dependent-service warnings shown only when something is actually wrong
/// (healthy dependencies stay silent; the full report lives in the summary).
/// One issue shows its message outright; several collapse to a count.
private func depsPill(_ issues: [DepFact]) -> some View {
let isError = issues.contains { $0.severity == .error }
let c: Color = isError ? .red : .orange
return Label(issues.count == 1 ? issues[0].text : "\(issues.count) issues",
systemImage: isError ? "exclamationmark.octagon" : "exclamationmark.triangle")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(c.opacity(0.2), in: Capsule())
.foregroundStyle(c)
.help(issues.map(\.text).joined(separator: " · "))
}
/// Stale-deploy badge: the helper script running on the device is not the
/// one vendored in this repo (or predates self-reporting entirely), so the
/// app may be speaking verbs the device doesn't know yet. Redeploy per

View file

@ -12,6 +12,75 @@ public struct HostStats: Decodable, Sendable, Equatable {
/// compares it against the repo's vendored copy to flag stale deploys.
/// Absent from helpers that predate self-reporting (itself a stale sign).
public let helper_sha: String?
/// Dependent-service facts (absent from pre-deps helpers).
public let deps: HostDeps?
}
/// Facts about the services a device's duties depend on, as the helper reports
/// them raw states only. What's *interesting* (worth surfacing in the UI) is
/// judged app-side by `issues`, so the thresholds live in one testable place.
public struct HostDeps: Decodable, Sendable, Equatable {
public let transmission: String? // systemd state: active/inactive/failed/unknown
public let mpv_unit: String? // the player unit; inactive just means idle
public let mpv_socket: Bool?
public let media_root: Bool? // does the media root directory exist
public let display: String? // DRM connector: connected/disconnected/unknown
public let disk_free_gb: Double? // free space under the media root
public let disk_used_pct: Int?
public init(transmission: String? = nil, mpv_unit: String? = nil,
mpv_socket: Bool? = nil, media_root: Bool? = nil, display: String? = nil,
disk_free_gb: Double? = nil, disk_used_pct: Int? = nil) {
self.transmission = transmission; self.mpv_unit = mpv_unit
self.mpv_socket = mpv_socket; self.media_root = media_root; self.display = display
self.disk_free_gb = disk_free_gb; self.disk_used_pct = disk_used_pct
}
}
/// One dependent-service fact, rendered and judged. `error` means a duty is
/// broken right now (transmission down, media root gone, disk effectively
/// full); `warning` means degraded / heading for trouble (disk filling, TV
/// unplugged, stale socket); `ok` is background detail for the summary.
public struct DepFact: Equatable, Sendable {
public enum Severity: Equatable, Sendable, Comparable { case ok, warning, error }
public let text: String
public let severity: Severity
public init(_ text: String, _ severity: Severity = .ok) {
self.text = text; self.severity = severity
}
}
public extension HostDeps {
/// Every reported fact, judged the single place the thresholds live. The
/// summary shows them all; the row surfaces only the non-ok subset.
var facts: [DepFact] {
var out: [DepFact] = []
if let t = transmission {
out.append(DepFact("transmission \(t)", t == "active" ? .ok : .error))
}
if media_root == false {
out.append(DepFact("media root missing", .error))
}
if let pct = disk_used_pct {
let free = disk_free_gb.map {
$0 >= 1024 ? String(format: ", %.1f TB free", $0 / 1024)
: String(format: ", %.0f GB free", $0)
} ?? ""
out.append(DepFact("disk \(pct)% full\(free)",
pct >= 97 ? .error : pct >= 90 ? .warning : .ok))
}
if let d = display {
out.append(DepFact("TV \(d)", d == "connected" ? .ok : .warning))
}
if mpv_socket == true, let u = mpv_unit, u != "active" {
out.append(DepFact("stale mpv socket (unit \(u))", .warning))
}
return out
}
/// The "interesting" subset empty when everything is healthy, which is
/// exactly when the row should stay quiet.
var issues: [DepFact] { facts.filter { $0.severity != .ok } }
}
/// A target that can report its host's load (only black it's a real machine

View file

@ -0,0 +1,60 @@
import XCTest
@testable import TVAnarchyCore
/// Dependent-service reporting: the helper sends raw facts; `HostDeps.facts`
/// is the one place that judges what's interesting. Healthy all-ok facts and
/// an empty `issues` (the row pill stays off); broken duties errors.
final class HostDepsTests: XCTestCase {
func testStatsDecodeWithAndWithoutDeps() throws {
let new = #"""
{"load1":0.5,"load5":0.4,"load15":0.3,"cores":24,"mpv_cpu":null,
"helper_sha":"abc",
"deps":{"transmission":"active","mpv_unit":"inactive","mpv_socket":false,
"media_root":true,"display":"connected",
"disk_free_gb":40982,"disk_used_pct":38}}
"""#
let old = #"{"load1":0.5,"load5":0.4,"load15":0.3,"cores":8,"mpv_cpu":null}"#
let n = try JSONDecoder().decode(HostStats.self, from: Data(new.utf8))
let o = try JSONDecoder().decode(HostStats.self, from: Data(old.utf8))
XCTAssertEqual(n.deps?.transmission, "active")
XCTAssertEqual(n.deps?.disk_used_pct, 38)
XCTAssertNil(o.deps) // pre-deps helper still decodes
}
func testHealthyDepsRaiseNoIssues() {
let deps = HostDeps(transmission: "active", mpv_unit: "active", mpv_socket: true,
media_root: true, display: "connected",
disk_free_gb: 40982, disk_used_pct: 38)
XCTAssertTrue(deps.issues.isEmpty)
XCTAssertTrue(deps.facts.allSatisfy { $0.severity == .ok })
// An idle player (unit inactive, no socket) is normal, not an issue.
XCTAssertTrue(HostDeps(mpv_unit: "inactive", mpv_socket: false).issues.isEmpty)
}
func testBrokenDutiesAreErrors() {
XCTAssertEqual(HostDeps(transmission: "failed").issues,
[DepFact("transmission failed", .error)])
XCTAssertEqual(HostDeps(media_root: false).issues,
[DepFact("media root missing", .error)])
}
func testDiskThresholds() {
XCTAssertEqual(HostDeps(disk_free_gb: 500, disk_used_pct: 38).issues, [])
XCTAssertEqual(HostDeps(disk_free_gb: 500, disk_used_pct: 92).issues,
[DepFact("disk 92% full, 500 GB free", .warning)])
XCTAssertEqual(HostDeps(disk_free_gb: 90, disk_used_pct: 98).issues,
[DepFact("disk 98% full, 90 GB free", .error)])
// 1 TB free renders in TB in the ok fact.
XCTAssertEqual(HostDeps(disk_free_gb: 40982, disk_used_pct: 38).facts,
[DepFact("disk 38% full, 40.0 TB free", .ok)])
}
func testDegradedStatesAreWarnings() {
// TV unplugged/off: mpv launches will fail, but the node is otherwise fine.
XCTAssertEqual(HostDeps(display: "disconnected").issues,
[DepFact("TV disconnected", .warning)])
// The stale-socket state we hit live: socket present, unit dead.
XCTAssertEqual(HostDeps(mpv_unit: "inactive", mpv_socket: true).issues,
[DepFact("stale mpv socket (unit inactive)", .warning)])
}
}

View file

@ -141,10 +141,21 @@ manual `mcp/README.md` deploy step remains for hosts the app can't reach.
Each row expands (chevron) into a **summary section**: backend + endpoints,
role/fleet class + services, live connection/playback line, host load
(1/5/15-min + mpv decode CPU), and the helper-deployment line (deployed vs
(1/5/15-min + mpv decode CPU), the dependent-services line, and the
helper-deployment line (deployed vs
repo hash, judged) — with the Restart / Update & restart buttons inline, so
diagnosing and fixing a wedged device happens in one place.
**Dependent services**: `black-tv stats` also reports a `deps` object —
transmission unit state, mpv unit + socket, media-root presence, DRM display
state, disk used%/free under the media root. The helper sends raw facts;
`HostDeps.facts` (app-side, one testable place) judges severity: transmission
not active / media root missing / disk ≥97% = error, disk ≥90% / TV not
connected / stale socket = warning. The row shows a red/orange pill **only when
something is wrong** (a single issue shows its message, several collapse to a
count); the expanded summary always shows the full facts line with ⚠ on the
interesting ones.
## governor (`portable-net-tv`)
TypeScript/Bun standalone daemon on plum.

View file

@ -18,7 +18,8 @@ Where the project is and the de-risked path to the full vision. Architecture is
| App — VPN subsystem | ✅ Shipped | OVPN profile/credential stores (Keychain), controller, settings UI |
| App — offline cache + Now Playing/media keys | ✅ Shipped | `OfflineCacheController`, `NowPlayingController`, bandwidth policy |
| iOS app (`TVAnarchyiOS`) | ✅ Shipped (companion) | VLCKit player, library, downloads, remote control via HTTP bridge (default `:8787`); the bridge *server* is not in this repo's `mcp/` tree |
| Distribution (release/update) | ✅ Shipped | `tools/release.sh` → Forgejo on forge.black; `tools/update.sh` installs/updates any node without a toolchain |
| Distribution (release/update) | ✅ Shipped (macOS) / 🟡 scaffolded (rest) | `tools/release.sh` → Forgejo; `tools/update.sh` resolves per-OS asset+dest (mac/linux/windows/android/iOS) via the single-source `tools/platform.sh`. Only the macOS asset can exist until the client is cross-platform (see north star) |
| Universal client (one app, all devices) | ❌ Designed | the spine: `blackd` → thin web client → wrap (Tauri/Capacitor) → retire Swift UIs; adult becomes a backend-entitled module. See "North star" |
| `governor` (`portable-net-tv`) | ✅ Shipped (single-host) | watch tracking + prefetch buffer |
| `mcp` (`plum-control-mcp`) | ✅ Shipped | VLC / black-tv / transmission / display tools |
| `recommender` | ✅ Shipped | enrichment + local recs |
@ -38,6 +39,66 @@ reaper → `peers_for`) implemented and tested in the governor; what remains is
actuation (cross-host copies), the WG fabric, and every stage that requires
infrastructure outside this repo (seedbox, friends, Discord).
## North star — one client, every device
The goal: **one app we install on every device** — iOS, Android, macOS, Ubuntu,
Bluefin, Windows — controlling the display endpoints (Roku, smart TVs) it can't
run on, **with the adult feature as an opt-in package on entitled devices only**.
**Why today's app can't be that.** It is Swift + SwiftUI/AppKit/UIKit + VLCKit —
structurally Apple-only (29 UI files; the core leans on Observation/AppKit/
MediaPlayer). It reaches only 2 of the 6 install targets. The per-platform
branches in `tools/update.sh` (linux/windows/android) are honest scaffolding for
release assets a Swift+SwiftUI build **cannot produce**. No amount of packaging
fixes that; the *client technology* is the constraint.
**The unlock is already on the roadmap.** ~20% of `TVAnarchyCore` exists only to
reach black over ssh/Process, and most of the rest *fronts* state that truly
lives on black (the index, the watchlog, transmission, porn-rotation). Once
`blackd` (below) exposes those as HTTP, the client keeps almost no logic — it
becomes **browse + control + a `<video>`**. A thin client is portable; a fat one
isn't. So the dependency order is: **server-side consolidation first, universal
client second.** They're one program, not two.
**Client decision (the one real fork).** To be genuinely *one* codebase across
all six:
- **Recommended — web/PWA, wrapped:** one TypeScript app; Tauri for
macOS/Windows/Linux installs, Capacitor for iOS/Android, raw PWA in any
browser (covers Bluefin and anything else for free). Talks only HTTP+WS to
`blackd`. Reuses the workspace's TS toolchain (governor/mcp). Roku/TVs are
*controlled*, not install sites. **Cost:** the Swift macOS/iOS UIs become
legacy and are retired at parity — a real rewrite, paid down by the logic
moving server-side where it belongs. **Tradeoff to weigh:** native VLCKit
offline playback is best-in-class today; a PWA's offline story (service worker
+ Capacitor filesystem on mobile) is good but not equal — this is the one
capability the pivot risks.
- **Pragmatic alternative:** keep Swift for Apple (where it's excellent), ship
the web client only for the non-Apple platforms. Two clients, not one —
satisfies *reach* but not the literal "one app" goal.
**Adult as a package (honoring "some include it").** In the web-client world the
adult feature becomes a **lazily-loaded module gated by a per-device entitlement
the backend issues** — clients are byte-identical everywhere; black serves the
adult module *and* adult library rows only to entitled devices. That's strictly
stronger than today's client compile flag (an un-entitled install never holds
adult data to leak). The existing `ENABLE_ADULT` compile-strip survives as a
**store-safe variant** for the one case that needs zero adult code present
(an app-store submission) — cut from the same source via the asset-suffix
dimension the release pipeline already supports.
**Sequence (each phase ships):** 1) `blackd` — HTTP/WS service plane on black
(player, index, **`/media`**, verbs); 2) thin **web client** against it
(browse + control + player) reaching parity with the Swift app feature-by-
feature; 3) **wrap** (Tauri/Capacitor) + entitlement-gated adult module;
4) **retire** the Swift UIs and the plum iOS bridge. The `/media` plane in (1)
is the same one the Roku channel needs — bought once.
> Open decision for the operator: commit to the web-thin-client rewrite (true
> one-app, accept the offline-playback tradeoff), or hold Swift-for-Apple +
> web-for-the-rest (reach without a single codebase). Everything before the
> client rewrite — `blackd`, the `/media` plane — is correct either way, so the
> fork can be deferred until phase 2 without stalling phase 1.
### Repo-state note
The `PlumTV → TVAnarchy` rename and the helper subsystems (`governor/`, `mcp/`,
@ -100,7 +161,9 @@ those exist.
### `blackd` — black as a real service (no ssh on the control path) — planned milestone
ssh-as-transport is the app's load-bearing design debt: player control is a
**This is phase 1 of the universal client** (see north star): it's the
prerequisite that thins the client enough to be portable, not merely an ssh
cleanup. ssh-as-transport is the app's load-bearing design debt: player control is a
fresh `ssh → sudo socat → /tmp/mpv.sock` pipeline per command (`MpvTarget`),
the library index is `ssh cat`, launch/stats/releases/restart are ssh-invoked
`black-tv` verbs (whose deploy drift required the `helper_sha` badge), and the

View file

@ -270,6 +270,22 @@ status_json() {
"$(getprop playlist-pos)" "$(getprop playlist-count)"
}
# Dependent-service report for the app's Devices tab: facts only — the app
# decides what's "interesting" (transmission down, disk filling up, TV
# unplugged, stale socket). All cheap probes, no sudo.
deps_json() {
local tr unit sock mroot disp dfree dpct
tr=$(systemctl is-active transmission-daemon 2>/dev/null || true)
unit=$(systemctl is-active "$UNIT" 2>/dev/null || true)
[ -S "$SOCK" ] && sock=true || sock=false
[ -d "$MEDIA_ROOT" ] && mroot=true || mroot=false
disp=$(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null || echo unknown)
set -- $(df -BG --output=avail,pcent "$MEDIA_ROOT" 2>/dev/null | tail -1 | tr -d 'G%')
dfree=${1:-null}; dpct=${2:-null}
printf '{"transmission":"%s","mpv_unit":"%s","mpv_socket":%s,"media_root":%s,"display":"%s","disk_free_gb":%s,"disk_used_pct":%s}' \
"${tr:-unknown}" "${unit:-unknown}" "$sock" "$mroot" "$disp" "$dfree" "$dpct"
}
# Host load: load averages + mpv's instantaneous %CPU (100 = one core). The
# decode cost is what changes with quality; computed from a 0.25s /proc delta so
# it's "right now", not the lifetime average ps reports. No sudo needed.
@ -292,8 +308,8 @@ stats_json() {
'BEGIN{printf "%.1f", 100.0*n*(b-a)/(d-c)}')
fi
fi
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s,"helper_sha":"%s"}\n' \
"$l1" "$l5" "$l15" "$cores" "$mcpu" "$hsha"
printf '{"load1":%s,"load5":%s,"load15":%s,"cores":%s,"mpv_cpu":%s,"helper_sha":"%s","deps":%s}\n' \
"$l1" "$l5" "$l15" "$cores" "$mcpu" "$hsha" "$(deps_json)"
}
# --- dispatch ---------------------------------------------------------------