feat(devices): ✨ add dependency issue warnings
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d79e99c21c
commit
0a4cde36d1
6 changed files with 247 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
Tests/TVAnarchyCoreTests/HostDepsTests.swift
Normal file
60
Tests/TVAnarchyCoreTests/HostDepsTests.swift
Normal 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)])
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue