Skip to content

Commit 66e712c

Browse files
AIFlowMLclaude
andcommitted
feat: grouped sidebar navigation, PowerView, SystemInfoView, HardwareView, GPUView
Major UI redesign — Jetson Control Center with grouped sidebar: Navigation: - Replaced horizontal tab bar with HSplitView grouped sidebar - 4 sections: DEVICE (Overview/System/Power/Hardware), RUNTIME (Docker/ROS2/ANIMA), OPERATIONS (Files/Deploy/GPU), OBSERVE (Logs/History) - 12 features accessible from sidebar with SF Symbols PowerView: - Power mode selector (MAXN/30W/15W) with radio buttons - Jetson clocks toggle (on/off) - Fan speed slider (0-255 PWM) - Thermal gauges with color-coded temperature bars - Auto-refresh metrics every 5s SystemInfoView: - System info: model, hostname, OS, kernel, architecture, L4T, uptime - Storage: per-filesystem progress bars with usage percentages - Network: interface list with IP addresses, MAC, state indicators - Users: user list with UID, shell HardwareView: - Cameras: CSI/USB/ZED with type badges and device paths - GPIO: pin grid with direction and value indicators - I2C: bus tree with detected device addresses - USB: device list with vendor/product IDs - Serial: port list with type labels GPUView: - GPU info: name, CUDA version, TensorRT version, temperature, power - Memory gauge: used/total with progress bar - TensorRT engines: file list with sizes - Models: file list with format badges (ONNX/TRT/PT) Terminal toolbar: cleaned up with single apple.terminal.fill icon + dropdown 71/71 tests pass. 97 files, 19,045 lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a6b61cd commit 66e712c

6 files changed

Lines changed: 840 additions & 130 deletions

File tree

Sources/THORApp/Views/DeviceDetailView.swift

Lines changed: 95 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -19,75 +19,37 @@ struct DeviceDetailView: View {
1919
}
2020

2121
var body: some View {
22-
VStack(spacing: 0) {
23-
// Tab bar
24-
tabBar
25-
Divider()
22+
HSplitView {
23+
// Grouped sidebar
24+
List(selection: $selectedTab) {
25+
Section("DEVICE") {
26+
sidebarItem(.overview)
27+
sidebarItem(.system)
28+
sidebarItem(.power)
29+
sidebarItem(.hardware)
30+
}
31+
Section("RUNTIME") {
32+
sidebarItem(.docker)
33+
sidebarItem(.ros2)
34+
sidebarItem(.anima)
35+
}
36+
Section("OPERATIONS") {
37+
sidebarItem(.files)
38+
sidebarItem(.deploy)
39+
sidebarItem(.gpu)
40+
}
41+
Section("OBSERVE") {
42+
sidebarItem(.logs)
43+
sidebarItem(.history)
44+
}
45+
}
46+
.listStyle(.sidebar)
47+
.frame(width: 160)
2648

27-
// Tab content
49+
// Content
2850
ScrollView {
2951
VStack(alignment: .leading, spacing: 24) {
30-
switch selectedTab {
31-
case .overview:
32-
deviceHeader
33-
if let errorMessage {
34-
errorBanner(errorMessage)
35-
}
36-
connectionCard
37-
if isConnected {
38-
metricsCard
39-
}
40-
capabilitiesCard
41-
quickActions
42-
case .files:
43-
if isConnected {
44-
FileTransferView(device: device)
45-
} else {
46-
notConnectedPlaceholder
47-
}
48-
case .anima:
49-
if isConnected {
50-
VStack(alignment: .leading, spacing: 16) {
51-
ANIMAModuleListView(device: device)
52-
PipelineStatusView(device: device)
53-
}
54-
} else {
55-
notConnectedPlaceholder
56-
}
57-
case .ros2:
58-
if isConnected, let id = device.id {
59-
ROS2InspectorView(deviceID: id)
60-
} else {
61-
notConnectedPlaceholder
62-
}
63-
case .docker:
64-
if isConnected, let id = device.id {
65-
DockerView(deviceID: id)
66-
} else {
67-
notConnectedPlaceholder
68-
}
69-
case .deploy:
70-
if isConnected {
71-
DeployView(device: device)
72-
} else {
73-
notConnectedPlaceholder
74-
}
75-
case .logs:
76-
if isConnected, let id = device.id {
77-
LogStreamView(deviceID: id)
78-
} else {
79-
notConnectedPlaceholder
80-
}
81-
case .jetpack:
82-
JetPackView(device: device)
83-
case .history:
84-
if let id = device.id {
85-
VStack(alignment: .leading, spacing: 16) {
86-
EventTimelineView(deviceID: id)
87-
TransferHistoryView(deviceID: id)
88-
}
89-
}
90-
}
52+
featureContent
9153
}
9254
.padding(20)
9355
}
@@ -128,26 +90,54 @@ struct DeviceDetailView: View {
12890
}
12991
}
13092

131-
private var tabBar: some View {
132-
HStack(spacing: 0) {
133-
ForEach(DetailTab.allCases, id: \.self) { tab in
134-
Button {
135-
selectedTab = tab
136-
} label: {
137-
Label(tab.label, systemImage: tab.icon)
138-
.font(.system(size: 12, weight: selectedTab == tab ? .semibold : .regular))
139-
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
140-
.padding(.horizontal, 16)
141-
.padding(.vertical, 8)
142-
.background(selectedTab == tab ? Color.accentColor.opacity(0.1) : .clear)
143-
.clipShape(.rect(cornerRadius: 6))
93+
private func sidebarItem(_ tab: DetailTab) -> some View {
94+
Label(tab.label, systemImage: tab.icon)
95+
.tag(tab)
96+
}
97+
98+
@ViewBuilder
99+
private var featureContent: some View {
100+
switch selectedTab {
101+
case .overview:
102+
deviceHeader
103+
if let errorMessage { errorBanner(errorMessage) }
104+
connectionCard
105+
if isConnected { metricsCard }
106+
capabilitiesCard
107+
quickActions
108+
case .system:
109+
if let id = device.id { SystemInfoView(deviceID: id) } else { notConnectedPlaceholder }
110+
case .power:
111+
if isConnected, let id = device.id { PowerView(deviceID: id) } else { notConnectedPlaceholder }
112+
case .hardware:
113+
if isConnected, let id = device.id { HardwareView(deviceID: id) } else { notConnectedPlaceholder }
114+
case .docker:
115+
if isConnected, let id = device.id { DockerView(deviceID: id) } else { notConnectedPlaceholder }
116+
case .ros2:
117+
if isConnected, let id = device.id { ROS2InspectorView(deviceID: id) } else { notConnectedPlaceholder }
118+
case .anima:
119+
if isConnected {
120+
VStack(alignment: .leading, spacing: 16) {
121+
ANIMAModuleListView(device: device)
122+
PipelineStatusView(device: device)
123+
}
124+
} else { notConnectedPlaceholder }
125+
case .files:
126+
if isConnected { FileTransferView(device: device) } else { notConnectedPlaceholder }
127+
case .deploy:
128+
if isConnected { DeployView(device: device) } else { notConnectedPlaceholder }
129+
case .gpu:
130+
if isConnected, let id = device.id { GPUView(deviceID: id) } else { notConnectedPlaceholder }
131+
case .logs:
132+
if isConnected, let id = device.id { LogStreamView(deviceID: id) } else { notConnectedPlaceholder }
133+
case .history:
134+
if let id = device.id {
135+
VStack(alignment: .leading, spacing: 16) {
136+
EventTimelineView(deviceID: id)
137+
TransferHistoryView(deviceID: id)
144138
}
145-
.buttonStyle(.plain)
146139
}
147-
Spacer()
148140
}
149-
.padding(.horizontal, 20)
150-
.padding(.vertical, 8)
151141
}
152142

153143
private var notConnectedPlaceholder: some View {
@@ -567,25 +557,35 @@ struct DeviceDetailView: View {
567557
// MARK: - Tab Enum
568558

569559
private enum DetailTab: String, CaseIterable {
560+
// DEVICE
570561
case overview
562+
case system
563+
case power
564+
case hardware
565+
// RUNTIME
566+
case docker
567+
case ros2
571568
case anima
569+
// OPERATIONS
572570
case files
573571
case deploy
574-
case ros2
575-
case jetpack
576-
case docker
572+
case gpu
573+
// OBSERVE
577574
case logs
578575
case history
579576

580577
var label: String {
581578
switch self {
582579
case .overview: "Overview"
580+
case .system: "System"
581+
case .power: "Power"
582+
case .hardware: "Hardware"
583+
case .docker: "Docker"
584+
case .ros2: "ROS2"
583585
case .anima: "ANIMA"
584586
case .files: "Files"
585587
case .deploy: "Deploy"
586-
case .ros2: "ROS2"
587-
case .jetpack: "JetPack"
588-
case .docker: "Docker"
588+
case .gpu: "GPU & Models"
589589
case .logs: "Logs"
590590
case .history: "History"
591591
}
@@ -594,12 +594,15 @@ private enum DetailTab: String, CaseIterable {
594594
var icon: String {
595595
switch self {
596596
case .overview: "cpu"
597+
case .system: "info.circle"
598+
case .power: "bolt.fill"
599+
case .hardware: "cable.connector"
600+
case .docker: "shippingbox"
601+
case .ros2: "point.3.connected.trianglepath.dotted"
597602
case .anima: "brain"
598603
case .files: "arrow.up.doc"
599604
case .deploy: "play.rectangle"
600-
case .ros2: "point.3.connected.trianglepath.dotted"
601-
case .jetpack: "memorychip"
602-
case .docker: "shippingbox"
605+
case .gpu: "gpu"
603606
case .logs: "doc.text"
604607
case .history: "clock.arrow.circlepath"
605608
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import SwiftUI
2+
import THORShared
3+
4+
struct GPUView: View {
5+
let deviceID: Int64
6+
@Environment(AppState.self) private var appState
7+
@State private var gpuInfo: GPUDetailResponse?
8+
@State private var engines: TensorRTEnginesResponse?
9+
@State private var models: ModelListResponse?
10+
@State private var isLoading = false
11+
12+
var body: some View {
13+
VStack(alignment: .leading, spacing: 20) {
14+
HStack {
15+
Label("GPU & Models", systemImage: "gpu")
16+
.font(.system(size: 14, weight: .medium))
17+
Spacer()
18+
Button { Task { await loadAll() } } label: { Image(systemName: "arrow.clockwise") }
19+
.buttonStyle(.borderless)
20+
}
21+
22+
if isLoading && gpuInfo == nil {
23+
ProgressView("Loading GPU info...").frame(maxWidth: .infinity, minHeight: 100)
24+
} else {
25+
gpuInfoCard
26+
memoryCard
27+
trtEnginesCard
28+
modelsCard
29+
}
30+
}
31+
.task { await loadAll() }
32+
}
33+
34+
private var gpuInfoCard: some View {
35+
GroupBox("GPU") {
36+
if let g = gpuInfo {
37+
VStack(spacing: 0) {
38+
infoRow("GPU", g.gpuName)
39+
Divider().padding(.leading, 16)
40+
infoRow("CUDA", g.cudaVersion ?? "N/A")
41+
Divider().padding(.leading, 16)
42+
infoRow("TensorRT", g.tensorrtVersion ?? "N/A")
43+
Divider().padding(.leading, 16)
44+
infoRow("Temperature", "\(Int(g.temperatureC))°C")
45+
Divider().padding(.leading, 16)
46+
infoRow("Power", "\(String(format: "%.1f", g.powerDrawW)) W")
47+
Divider().padding(.leading, 16)
48+
infoRow("Utilization", "\(Int(g.utilizationPercent))%")
49+
}
50+
}
51+
}
52+
}
53+
54+
private var memoryCard: some View {
55+
GroupBox("GPU Memory") {
56+
if let g = gpuInfo, g.memoryTotalMb > 0 {
57+
VStack(spacing: 8) {
58+
HStack {
59+
Text("\(g.memoryUsedMb) MB used")
60+
.font(.system(size: 13, design: .monospaced))
61+
Spacer()
62+
Text("\(g.memoryTotalMb) MB total")
63+
.font(.system(size: 13, design: .monospaced))
64+
.foregroundStyle(.secondary)
65+
}
66+
ProgressView(value: Double(g.memoryUsedMb), total: Double(g.memoryTotalMb))
67+
.tint(Double(g.memoryUsedMb) / Double(g.memoryTotalMb) > 0.9 ? .red : .blue)
68+
Text("\(g.memoryTotalMb - g.memoryUsedMb) MB free")
69+
.font(.system(size: 11)).foregroundStyle(.secondary)
70+
}
71+
.padding(8)
72+
} else {
73+
Text("GPU memory info not available").foregroundStyle(.secondary).font(.system(size: 13)).padding(8)
74+
}
75+
}
76+
}
77+
78+
private var trtEnginesCard: some View {
79+
GroupBox("TensorRT Engines (\(engines?.count ?? 0))") {
80+
if let eng = engines, !eng.engines.isEmpty {
81+
VStack(spacing: 0) {
82+
ForEach(eng.engines) { engine in
83+
HStack {
84+
Image(systemName: "gearshape.fill").foregroundStyle(.orange).font(.system(size: 10))
85+
Text(engine.name).font(.system(size: 12, weight: .medium))
86+
Spacer()
87+
Text(formatBytes(engine.sizeBytes))
88+
.font(.system(size: 11, design: .monospaced)).foregroundStyle(.secondary)
89+
}
90+
.padding(.horizontal, 12).padding(.vertical, 4)
91+
}
92+
}
93+
} else {
94+
Text("No TensorRT engines found").foregroundStyle(.secondary).font(.system(size: 13)).padding(8)
95+
}
96+
}
97+
}
98+
99+
private var modelsCard: some View {
100+
GroupBox("Models (\(models?.count ?? 0))") {
101+
if let m = models, !m.models.isEmpty {
102+
VStack(spacing: 0) {
103+
ForEach(m.models) { model in
104+
HStack {
105+
formatBadge(model.format)
106+
Text(model.name).font(.system(size: 12, weight: .medium))
107+
Spacer()
108+
Text(formatBytes(model.sizeBytes))
109+
.font(.system(size: 11, design: .monospaced)).foregroundStyle(.secondary)
110+
}
111+
.padding(.horizontal, 12).padding(.vertical, 4)
112+
}
113+
}
114+
} else {
115+
Text("No models found").foregroundStyle(.secondary).font(.system(size: 13)).padding(8)
116+
}
117+
}
118+
}
119+
120+
private func infoRow(_ label: String, _ value: String) -> some View {
121+
HStack {
122+
Text(label).font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 100, alignment: .leading)
123+
Text(value).font(.system(size: 13, design: .monospaced))
124+
Spacer()
125+
}
126+
.padding(.horizontal, 16).padding(.vertical, 6)
127+
}
128+
129+
private func formatBadge(_ format: String) -> some View {
130+
Text(format.uppercased())
131+
.font(.system(size: 9, weight: .bold, design: .monospaced))
132+
.padding(.horizontal, 4).padding(.vertical, 1)
133+
.background(format == "trt" ? Color.orange.opacity(0.2) : format == "onnx" ? Color.blue.opacity(0.2) : Color.green.opacity(0.2))
134+
.clipShape(.rect(cornerRadius: 3))
135+
}
136+
137+
private func formatBytes(_ bytes: Int) -> String {
138+
if bytes < 1024 { return "\(bytes) B" }
139+
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
140+
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
141+
}
142+
143+
private func loadAll() async {
144+
guard let client = appState.connector?.agentClient(for: deviceID) else { return }
145+
isLoading = true
146+
do {
147+
async let g = client.gpuDetail()
148+
async let e = client.tensorrtEngines()
149+
async let m = client.modelList()
150+
(gpuInfo, engines, models) = try await (g, e, m)
151+
} catch {}
152+
isLoading = false
153+
}
154+
}

0 commit comments

Comments
 (0)