File size: 30,559 Bytes
0dc8570 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | import SwiftUI
import AppKit
import WebKit
import UniformTypeIdentifiers
@MainActor final class ExplorerDelegate:NSObject,NSApplicationDelegate {
weak var workspace:Workspace?
func applicationShouldTerminate(_ sender:NSApplication)->NSApplication.TerminateReply{guard workspace?.confirmDiscardRemote() ?? true,workspace?.confirmEndTerminal() ?? true else{return .terminateCancel};workspace?.save();return .terminateNow}
func applicationShouldTerminateAfterLastWindowClosed(_ sender:NSApplication)->Bool{true}
}
@main struct ExplorerApp:App {
@NSApplicationDelegateAdaptor(ExplorerDelegate.self) var delegate
@StateObject var state=Workspace()
var body:some Scene {
WindowGroup {MainView().environmentObject(state).onAppear{delegate.workspace=state}.frame(minWidth:1100,minHeight:740).preferredColorScheme(state.appearance=="Dark" ? .dark : .light)}
.defaultSize(width:1360,height:900)
.commands {CommandGroup(replacing:.newItem){};CommandGroup(after:.saveItem){Button(state.tab == "Files" ? "Save ARC file":"Save workspace"){if state.tab == "Files"{state.saveRemoteFile()}else{state.save()}}.keyboardShortcut("s")};CommandGroup(replacing:.help){Button("ARC documentation"){state.openWeb("https://www.docs.arc.vt.edu")};Button("Ask ARC LLM"){state.tab="Models + help"}}}
}
}
struct MonochromeActionStyle:ButtonStyle {
@Environment(\.colorScheme) var scheme
@Environment(\.isEnabled) var enabled
func makeBody(configuration:Configuration)->some View {
configuration.label.foregroundStyle(scheme == .dark ? Color.black:Color.white)
.padding(.horizontal,12).padding(.vertical,6)
.background(scheme == .dark ? Color.white:Color.black,in:RoundedRectangle(cornerRadius:6))
.opacity(enabled ? (configuration.isPressed ? 0.75:1):0.4)
}
}
struct Panel<Content:View>:View {
var title:String;@ViewBuilder var content:Content
var body:some View {GroupBox{VStack(alignment:.leading,spacing:12){Text(title.uppercased()).font(.system(size:10,weight:.semibold,design:.monospaced)).tracking(1.4);content}.padding(8).frame(maxWidth:.infinity,alignment:.leading)}}
}
struct LabeledField:View {
var label:String;@Binding var value:String
var body:some View{HStack{Text(label).foregroundStyle(.secondary).frame(width:135,alignment:.leading);TextField(label,text:$value).textFieldStyle(.roundedBorder)}}
}
struct LogView:View {
var text:String
var body:some View{ScrollView([.vertical,.horizontal]){Text(text.isEmpty ? "Nothing to display yet.":text).font(.system(size:12,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.topLeading).padding(12)}.background(.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:8))}
}
struct MainView:View {
@EnvironmentObject var s:Workspace
var group:Int {switch s.tab{case "Connection":return 0;case "Overview","Allocations","Files":return 1;case "Submit job","Jobs","Software":return 2;case "Sessions","Terminal":return 3;default:return 4}}
var groupBinding:Binding<Int>{Binding(get:{group},set:{s.tab=["Connection","Overview","Submit job","Terminal","Models + help"][$0]})}
var body:some View {
VStack(spacing:0){header;Divider();HStack(spacing:0){if s.tab != "Terminal"{sidebar;Divider()};VStack(alignment:.leading,spacing:18){
Picker("Workspace",selection:groupBinding){Text("01 Connection").tag(0);Text("02 Resources").tag(1);Text("03 Jobs + software").tag(2);Text("04 Sessions").tag(3);Text("05 Help").tag(4)}.pickerStyle(.segmented)
if group==1 {Picker("Resources",selection:$s.tab){Text("Availability").tag("Overview");Text("Allocation usage").tag("Allocations");Text("Remote files").tag("Files")}.pickerStyle(.segmented)}
if group==2 {Picker("Compute workspace",selection:$s.tab){Text("GPU request + script").tag("Submit job");Text("Jobs + results").tag("Jobs");Text("Install / update code").tag("Software")}.pickerStyle(.segmented)}
if group==3 {Picker("Sessions",selection:$s.tab){Text("Web / Jupyter").tag("Sessions");Text("Terminal").tag("Terminal")}.pickerStyle(.segmented)}
if group==4 {Picker("Help tools",selection:$s.tab){Text("ARC LLM assistant").tag("Models + help");Text("Command reference").tag("Commands")}.pickerStyle(.segmented)}
Group{switch s.tab {
case "Connection":ConnectionView()
case "Overview":OverviewView()
case "Allocations":AllocationView()
case "Jobs":JobsView()
case "Submit job":SubmitView()
case "Files":FilesView()
case "Sessions":SessionsView()
case "Terminal":ConsoleView(console:s.terminal)
case "Software":SoftwareView()
case "Models + help":HelpView()
default:CommandsView()
}}.frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading)
}.padding(22)}}.background(s.appearance=="Dark" ? Color.black:Color.white).foregroundStyle(.primary)
.alert("ARC-Explorer",isPresented:Binding(get:{!s.error.isEmpty},set:{if !$0{s.error=""}})){Button("OK"){s.error=""}}message:{Text(s.error)}
.onChange(of:s.tab){_,new in guard s.connected,!s.busy else{return};if new=="Jobs"{s.refreshJobs()};if new=="Files" && s.files.isEmpty{s.browse()}}
}
var header:some View {
HStack(alignment:.top){
VStack(alignment:.leading,spacing:3){HStack(alignment:.firstTextBaseline,spacing:10){Text("ARC").font(.system(size:48,weight:.black)).tracking(-2.5);Text("Explorer").font(.system(size:28,weight:.light))};Text("CONNECT → REQUEST RESOURCES → COMPUTE → EXPLORE").font(.system(size:9,design:.monospaced)).tracking(1.4)}
Spacer(minLength:20)
VStack(alignment:.trailing,spacing:10){HStack(spacing:7){Circle().fill(s.connected ? Color.green:Color.gray).frame(width:7,height:7);Text(s.connected ? s.profile.name.uppercased()+" CONNECTED":"OFFLINE").font(.system(size:10,design:.monospaced));if s.busy{ProgressView().controlSize(.small)}};Text(s.status).font(.system(size:11,design:.monospaced)).lineLimit(3).multilineTextAlignment(.trailing).frame(maxWidth:400,minHeight:32);Picker("Appearance",selection:$s.appearance){Text("Light").tag("Light");Text("Dark").tag("Dark")}.pickerStyle(.segmented).labelsHidden().frame(width:145).onChange(of:s.appearance){s.save()}}
}.padding(24)
}
func label(_ text:String)->some View{Text(text).font(.system(size:10,weight:.semibold,design:.monospaced)).tracking(1.4)}
var sidebar:some View {
ScrollView{VStack(alignment:.leading,spacing:14){label("RESOURCE LIBRARY")
ScrollView{VStack(alignment:.leading,spacing:4){ForEach(s.profiles){p in Button{s.selectProfile(p.id)}label:{VStack(alignment:.leading,spacing:4){Text(p.name).font(.system(size:13,weight:.medium));Text(p.host).font(.system(size:9,design:.monospaced)).foregroundStyle(.secondary)}.frame(maxWidth:.infinity,alignment:.leading).padding(10).background(p.id==s.profile.id ? Color.primary.opacity(0.08):Color.clear)}.buttonStyle(.plain).disabled(s.connected || s.busy)}}}.frame(minHeight:70,maxHeight:160)
HStack{Menu("New"){ForEach(clusters){c in ForEach(c.hosts,id:\.self){host in Button(c.name+" · "+host){s.addProfile(c.name,host);s.tab="Connection"}}};Button("Custom host"){s.addProfile("Custom cluster","");s.tab="Connection"}}.disabled(s.connected || s.busy);Button("Save"){s.save()}}
VStack(alignment:.leading,spacing:12){label("CURRENT GPU REQUEST");HStack(alignment:.firstTextBaseline){Text(String(s.profile.gpus)).font(.system(size:42,weight:.light));Text(s.profile.gpus==1 ? "GPU":"GPUs").font(.system(size:11,design:.monospaced))};Text(s.profile.partition.isEmpty ? "Choose a GPU partition":s.profile.partition).font(.system(size:10,design:.monospaced));Text("\(s.profile.cpus) CPUs · \(s.profile.memory) GB · \(s.profile.walltime)").font(.system(size:10,design:.monospaced));Button("Edit resource request"){s.tab="Submit job"}}.padding(12).frame(maxWidth:.infinity,alignment:.leading).overlay(Rectangle().stroke(.gray.opacity(0.25)))
Text("GPU requests wait for Slurm. Compute runs on the assigned node, never on the login node.").font(.system(size:11)).foregroundStyle(.secondary)
label("ARC SERVICES")
VStack(alignment:.leading,spacing:7){Button("Jupyter / Open OnDemand"){s.openWeb("https://ood.arc.vt.edu")};Button("Allocation usage"){s.tab="Allocations"};Button("Cluster dashboards"){s.openWeb("https://dashboard.arc.vt.edu/dashboards?kiosk")};Button("Documentation"){s.openWeb("https://www.docs.arc.vt.edu")}}.font(.system(size:11))
Spacer(minLength:10)
Button("Local files…"){NSWorkspace.shared.open(s.support)}.font(.system(size:11))
Text("MACOS 15+ · SSH + SLURM\nGPU COMPUTE ON ARC\nEXPERIMENTAL RESEARCH SOFTWARE").font(.system(size:9,design:.monospaced)).lineSpacing(5).foregroundStyle(.secondary)
}.padding(20)}.frame(width:245)
}
}
struct ConnectionView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(alignment:.leading,spacing:18){
Text("One workspace for ARC.").font(.system(size:25,weight:.semibold))
Text("Connect with your VT credentials, inspect availability and allocation usage, then request GPU resources. SSH and Duo prompts open in native dialogs.").font(.system(size:13)).foregroundStyle(.secondary)
Panel(title:"Saved connections"){
HStack{Picker("Profile",selection:Binding(get:{s.profile.id},set:{s.selectProfile($0)})){ForEach(s.profiles){p in Text(p.name+" · "+p.host).tag(p.id)}}.disabled(s.connected || s.busy);Menu("Add cluster"){ForEach(clusters){c in ForEach(c.hosts,id:\.self){host in Button(c.name+" · "+host){s.addProfile(c.name,host)}}};Button("Custom host"){s.addProfile("Custom cluster","")}}.disabled(s.connected || s.busy)}
Text("Profiles are independent. Disconnect before switching clusters. Availability is queried live after connecting; it is not a reservation.").font(.caption).foregroundStyle(.secondary)
}
Panel(title:"SSH connection"){
VStack(spacing:12){LabeledField(label:"Connection name",value:$s.profile.name);LabeledField(label:"Login host",value:$s.profile.host);LabeledField(label:"VT PID",value:$s.profile.user);HStack{Text("SSH port").frame(width:135,alignment:.leading).foregroundStyle(.secondary);TextField("Port",value:$s.profile.port,format:.number).textFieldStyle(.roundedBorder)}
Picker("Authentication",selection:$s.profile.auth){Text("SSH key / agent").tag("SSH key");Text("Password + Duo").tag("Password + Duo")}.pickerStyle(.segmented)
if s.profile.auth=="SSH key"{HStack{LabeledField(label:"Private key path",value:$s.profile.identity);Button("Choose…"){let panel=NSOpenPanel();panel.showsHiddenFiles=true;panel.directoryURL=FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh");if panel.runModal() == .OK,let url=panel.url{s.profile.identity=url.path}}}}
LabeledField(label:"ARC workspace",value:$s.profile.workspace)
}.disabled(s.connected || s.busy)
HStack{Button(s.connected ? "Disconnect":"Connect to ARC"){if s.connected{s.disconnect()}else{s.connect()}}.buttonStyle(MonochromeActionStyle()).disabled(s.busy);Button("Save profile"){s.save()}.disabled(s.busy);Spacer();Text("Use the VT VPN off campus").font(.caption).foregroundStyle(.secondary)}
Text("SSH passphrases and Duo responses appear in native dialogs. ARC-Explorer never saves them. Leave the key path blank to use your SSH agent.").font(.caption).foregroundStyle(.secondary)
}
Panel(title:"How this workspace runs"){Text("SSH connects to the login node for lightweight scheduler queries and file navigation. Compute workloads and package installation use explicit GPU resources. The Login Node Terminal is for lightweight commands. A queued job has not started computing yet.").font(.callout);HStack{Button("Request GPU job"){s.tab="Submit job"};Button("Open OnDemand"){s.openWeb("https://ood.arc.vt.edu")};Button("ARC documentation"){s.openWeb("https://www.docs.arc.vt.edu")}}}
}.padding(.bottom,4)}}
}
struct OverviewView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(spacing:20){
HStack{Button("Refresh live availability"){s.refreshOverview()}.disabled(!s.connected || s.busy);Button("All-cluster dashboards"){s.openWeb("https://dashboard.arc.vt.edu/dashboards?kiosk")};Spacer();Text(s.profile.host).font(.caption).foregroundStyle(.secondary)}
if s.overview.isEmpty{Panel(title:"Connect to explore"){Text("Connect to TinkerCliffs, Falcon, Owl or a custom host. Inspect GPU partitions and your allocations here before submitting work.");Button("Connection settings"){s.tab="Connection"}}}
ForEach(s.overview){section in Panel(title:section.title){if !section.ok{Text("Unavailable on this cluster").font(.caption).foregroundStyle(.secondary)};LogView(text:section.text).frame(minHeight:100,maxHeight:300)}}
}.padding(.bottom,4)}}
}
struct AllocationView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(spacing:20){
Panel(title:"Allocation usage"){
LabeledField(label:"Slurm allocation",value:$s.profile.account)
HStack{DatePicker("Jobs from",selection:$s.usageStart,displayedComponents:.date);DatePicker("through",selection:$s.usageEnd,displayedComponents:.date);Spacer()}
HStack{Button("Check allocation usage"){s.refreshAllocation()}.disabled(!s.connected || s.busy);Button("Allocation portal"){s.openWeb("https://coldfront.arc.vt.edu")}}
Text("ARC’s getusage and quota reports keep their own reporting periods. The date filter applies to the job table only. Slurm fair-share usage is not a remaining balance; account-wide records depend on your permissions.").font(.caption).foregroundStyle(.secondary)
}
ForEach(s.allocation){section in Panel(title:section.title){if !section.ok{Text("The cluster could not provide this report.").font(.caption)};LogView(text:section.text).frame(minHeight:160,maxHeight:350)}}
}.padding(.bottom,4)}}
}
struct ResourceView:View {
@EnvironmentObject var s:Workspace
var body:some View{VStack(spacing:12){
LabeledField(label:"Allocation",value:$s.profile.account);LabeledField(label:"GPU partition",value:$s.profile.partition);LabeledField(label:"Wall time",value:$s.profile.walltime)
HStack(spacing:24){Stepper("GPUs: \(s.profile.gpus)",value:$s.profile.gpus,in:1...8);Stepper("CPUs: \(s.profile.cpus)",value:$s.profile.cpus,in:1...256);Stepper("Memory: \(s.profile.memory) GB",value:$s.profile.memory,in:1...2048)}
LabeledField(label:"Working directory",value:$s.workdir)
Text("The partition must offer GPUs. Check Overview for actual partition names and limits. This requests resources; queue time depends on availability and your allocation.").font(.caption).foregroundStyle(.secondary)
}}
}
struct SubmitView:View {
@EnvironmentObject var s:Workspace
@State var confirm=false
var body:some View{ScrollView{VStack(spacing:20){
Panel(title:"1 · Request GPU resources"){ResourceView()}
Panel(title:"2 · Script to run on the compute node"){
TextEditor(text:$s.script).font(.system(size:12,design:.monospaced)).frame(minHeight:240).overlay(RoundedRectangle(cornerRadius:5).stroke(.primary.opacity(0.15)))
HStack{Button("Open script…"){let p=NSOpenPanel();if p.runModal() == .OK,let u=p.url{do{s.script=try String(contentsOf:u,encoding:.utf8)}catch{s.error=error.localizedDescription}}};Menu("Save script…"){Button("Save to this Mac…"){s.saveScriptLocal()};Button("Save to ARC…"){s.saveScriptARC()}.disabled(!s.connected || s.busy);Button("Save workspace draft"){s.save();s.status="Script draft saved in this workspace."}};Spacer();Button("Submit GPU job…"){confirm=true}.buttonStyle(MonochromeActionStyle()).disabled(!s.connected || s.busy)}
Text("Save script writes a reusable file on this Mac or ARC; it does not submit a job. GPU settings are configured separately above. Load the modules or environment your code needs in this script.").font(.caption).foregroundStyle(.secondary)
}
if !s.submission.isEmpty{Panel(title:"Submission receipt"){LogView(text:s.submission).frame(height:150);HStack{Button("View jobs"){s.tab="Jobs";s.refreshJobs()};Button("Read log"){s.readJobLog()}.disabled(s.busy)}}}
}.padding(.bottom,4)}.confirmationDialog("Submit this script to \(s.profile.name)?",isPresented:$confirm,titleVisibility:.visible){Button("Submit \(s.profile.gpus)-GPU job"){s.submit()}}message:{Text("Account: \(s.profile.account)\nPartition: \(s.profile.partition)\n\(s.profile.cpus) CPUs · \(s.profile.memory) GB · \(s.profile.walltime)\nThis consumes allocation service units while running.")}}
}
struct JobsView:View {
@EnvironmentObject var s:Workspace
@State var confirmCancel=false
var body:some View{VStack(alignment:.leading,spacing:14){
HStack{Button("Refresh"){s.refreshJobs()}.disabled(!s.connected || s.busy);Button("Job details"){if let id=s.selectedJob{s.jobDetails(id)}}.disabled(s.selectedJob==nil || s.busy);Button("Cancel selected job…",role:.destructive){confirmCancel=true}.disabled(s.selectedJob==nil || s.busy);Spacer();Text("Refreshes every 30 seconds while visible").font(.caption).foregroundStyle(.secondary)}
Table(s.jobs,selection:$s.selectedJob){TableColumn("Job",value:\.id).width(min:75,ideal:90);TableColumn("Name",value:\.name);TableColumn("Partition",value:\.partition);TableColumn("State",value:\.state);TableColumn("Elapsed",value:\.elapsed);TableColumn("Node / pending reason",value:\.reason)}.frame(minHeight:180)
HStack{Text("JOB DETAILS / LOG").font(.system(size:11,design:.monospaced));Spacer();TextField("Remote job.log path",text:$s.jobLogPath).textFieldStyle(.roundedBorder);Button("Read log"){s.readJobLog()}.disabled(!s.connected || s.busy || s.jobLogPath.isEmpty)}
LogView(text:s.jobDetail).frame(minHeight:150,maxHeight:250)
Text("TODAY’S ACCOUNTING").font(.system(size:11,design:.monospaced));LogView(text:s.history).frame(minHeight:100,maxHeight:200)
}.padding(.bottom,4).confirmationDialog("Cancel this Slurm job?",isPresented:$confirmCancel,titleVisibility:.visible){Button("Cancel job \(s.selectedJob ?? "")",role:.destructive){if let id=s.selectedJob{s.cancelJob(id)}}}message:{Text("The running computation will stop. Files already written remain on ARC.")}}
}
struct FilesView:View {
@EnvironmentObject var s:Workspace
var selected:RemoteFile?{s.files.first(where:{$0.id==s.selectedFile})}
var body:some View{VStack(spacing:14){
HStack{Button("Home"){s.browse("~")};Button("Up"){s.browse((s.remotePath as NSString).deletingLastPathComponent)};TextField("Remote directory",text:$s.remotePath).textFieldStyle(.roundedBorder).onSubmit{s.browse()};Button("Go"){s.browse()};Button("Upload…"){s.upload()}}.disabled(!s.connected || s.busy)
HSplitView{
Table(s.files,selection:$s.selectedFile){TableColumn("Name"){f in Label(f.name,systemImage:f.directory ? "folder":f.link ? "link":"doc")};TableColumn("Bytes"){f in Text(f.directory ? "—":String(f.size)).monospacedDigit()}.width(80);TableColumn("Modified",value:\.modified).width(150)}.frame(minWidth:350)
VStack(alignment:.leading){HStack{Button("Open / edit"){if let f=selected{s.viewFile(f)}};Button("Download…"){if let f=selected{s.downloadFile(f)}}.disabled(selected?.directory != false)}.disabled(selected==nil || s.busy);Divider()
Text(s.editorPath.isEmpty ? "Open a UTF-8 text file to edit.":s.editorPath).font(.system(size:11,design:.monospaced)).textSelection(.enabled)
HStack{Text(s.editorDirty ? "Modified · not saved to ARC":"No unsaved changes").font(.caption);Spacer();Button("Reload"){s.openRemoteFile(s.editorPath)}.disabled(s.busy || !s.connected || s.editorPath.isEmpty || s.editorConnection != s.fileConnection);Button("Save to ARC"){s.saveRemoteFile()}.disabled(!s.canSaveRemote)}
TextEditor(text:$s.editorText).font(.system(size:12,design:.monospaced)).disableAutocorrection(true).disabled(s.editorPath.isEmpty || s.busy).overlay(Rectangle().stroke(.gray.opacity(0.2)))
}.frame(minWidth:300)
}
HStack{Button("New text file…"){s.newRemoteFile()}.disabled(!s.connected || s.busy);TextField("New folder name",text:$s.folderName).textFieldStyle(.roundedBorder).frame(maxWidth:250);Button("Create folder"){s.mkdir()}.disabled(!s.connected || s.busy);Spacer();Button("Large transfers / file manager"){s.openWeb("https://ood.arc.vt.edu")}}
Text("Up to 5,000 directory entries. UTF-8 editor: 1 MiB; checks for remote changes before saving. Transfers here: 8 MB; use Open OnDemand or SFTP for larger files. Uploads never overwrite existing files.").font(.caption).foregroundStyle(.secondary)
}.padding(.bottom,4)}
}
struct SoftwareView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(spacing:20){
Panel(title:"Install or update source code"){
Picker("Source",selection:$s.repoProvider){Text("GitHub").tag("GitHub");Text("Hugging Face").tag("Hugging Face")}.pickerStyle(.segmented)
LabeledField(label:"Repository",value:$s.repo);LabeledField(label:"Revision (optional)",value:$s.revision);LabeledField(label:"Destination on ARC",value:$s.destination)
Text("Use owner/repository. Public repositories work directly; private repositories require credentials configured on ARC. Git updates require a clean checkout and fast-forward history. Hugging Face repositories require Git LFS. No repository setup script is run automatically.").font(.caption).foregroundStyle(.secondary)
Button("Prepare GPU installation job"){s.previewInstall()}.disabled(!s.connected || s.busy)
if !s.installPreview.isEmpty{LogView(text:s.installPreview).frame(height:220);HStack{Button("Review script + GPU resources"){s.useInstall()};Menu("Save script…"){Button("Save to this Mac…"){s.saveScriptLocal(s.installPreview)};Button("Save to ARC…"){s.saveScriptARC(s.installPreview)}.disabled(!s.connected || s.busy)}}}
}
Panel(title:"Software already installed by ARC"){
Button("List available modules"){s.modules()}.disabled(!s.connected || s.busy);LogView(text:s.moduleText).frame(height:250)
Text("Use a module’s documented load command in a job script. Desktop applications, IDEs and Jupyter are also available through Open OnDemand.").font(.caption).foregroundStyle(.secondary)
}
}.padding(.bottom,4)}}
}
struct HelpView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(spacing:20){
Panel(title:"ARC-hosted LLM API"){
LabeledField(label:"API base URL",value:$s.apiBase).onChange(of:s.apiBase){s.apiKey="";s.modelIDs=[];s.model=""}
HStack{Text("API key").frame(width:135,alignment:.leading).foregroundStyle(.secondary);SecureField("ARC personal API key",text:$s.apiKey).textFieldStyle(.roundedBorder);Button("Load Keychain"){s.loadKey()};Button("Save to Keychain"){s.saveKey()}}
HStack{Button("Get API key"){s.openWeb("https://llm.arc.vt.edu")};Button("Fetch available models"){s.loadModels()}.disabled(s.busy);Spacer()}
LabeledField(label:"Model ID",value:$s.model)
if !s.modelIDs.isEmpty{Picker("Hosted models",selection:$s.model){ForEach(s.modelIDs,id:\.self){Text($0).tag($0)}}}
Text("Model availability is loaded from the API, not a hardcoded list. Get your key at llm.arc.vt.edu → Settings → Account → API keys. The assistant sends only your question and a general ARC help instruction to the endpoint shown above; no files, job logs or SSH credentials are attached automatically.").font(.caption).foregroundStyle(.secondary)
}
Panel(title:"Ask for help"){
TextEditor(text:$s.question).font(.body).frame(height:120).overlay(RoundedRectangle(cornerRadius:5).stroke(.primary.opacity(0.15)))
HStack{Button("Ask ARC LLM"){s.ask()}.buttonStyle(MonochromeActionStyle()).disabled(s.busy);Button("Clear"){s.question="";s.answer=""};Spacer();Text("Advice only · commands are never executed").font(.caption).foregroundStyle(.secondary)}
if !s.answer.isEmpty{Text(s.answer).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)}
}
}.padding(.bottom,4)}}
}
struct CommandsView:View {
@EnvironmentObject var s:Workspace
var body:some View{ScrollView{VStack(spacing:18){
Text("A reference library for ARC. Copy commands into your own workflow or add them to a GPU job script. Live monitoring is available in Overview, Allocations and Jobs.").foregroundStyle(.secondary).frame(maxWidth:.infinity,alignment:.leading)
ForEach(commandLibrary){entry in Panel(title:entry.title){Text(entry.help).font(.callout).foregroundStyle(.secondary);Text(entry.command).font(.system(size:12,design:.monospaced)).textSelection(.enabled);HStack{Button("Copy"){NSPasteboard.general.clearContents();NSPasteboard.general.setString(entry.command,forType:.string)};Button("Use in GPU job"){s.script=entry.command;s.tab="Submit job"}}}}
}.padding(.bottom,4)}}
}
struct SessionsView:View {
@EnvironmentObject var s:Workspace
@State var showTools=false
@State var confirmShell=false
var body:some View{VStack(spacing:12){
HStack{Button{if s.web.canGoBack{s.web.goBack()}}label:{Image(systemName:"chevron.left")};Button{s.web.reload()}label:{Image(systemName:"arrow.clockwise")};TextField("Session URL",text:$s.webAddress).textFieldStyle(.roundedBorder).onSubmit{s.openWeb()};Button("Open"){s.openWeb()};Button("System browser"){if let url=URL(string:s.webAddress),["https","http"].contains(url.scheme ?? ""){NSWorkspace.shared.open(url)}}}
HStack{Menu("ARC services / bookmarks"){ForEach(s.bookmarks){b in Button(b.name){s.openWeb(b.url)}}};Button("Jupyter / interactive apps"){s.openWeb("https://ood.arc.vt.edu")};Button("Built-in terminal"){s.tab="Terminal"}.disabled(!s.connected || s.busy);Spacer();Toggle("Session tools",isOn:$showTools).toggleStyle(.button)}
if showTools{
VStack(alignment:.leading,spacing:10){
Text("Jupyter: sign in to Open OnDemand, choose Jupyter under Interactive Apps, review resources, and launch. Open a running session in this browser. If VT sign-in rejects an embedded browser, use System browser.").font(.caption).foregroundStyle(.secondary)
HStack{TextField("Bookmark name",text:$s.bookmarkName).textFieldStyle(.roundedBorder);Button("Bookmark URL"){s.bookmark()}}
Divider()
Text("CONNECT TO AN EXISTING COMPUTE-NODE WEB APP").font(.system(size:10,design:.monospaced))
HStack{TextField("Assigned compute node",text:$s.tunnelNode);TextField("Remote port",text:$s.tunnelRemotePort).frame(width:100);TextField("Local port",text:$s.tunnelLocalPort).frame(width:100);Button(s.tunnelSpec.isEmpty ? "Open tunnel":"Close tunnel"){if s.tunnelSpec.isEmpty{s.forward()}else{s.closeForward()}}.disabled(!s.connected || s.busy)}.textFieldStyle(.roundedBorder)
Text("For a web service already running in your Slurm job and reachable from the login node. The local listener is restricted to 127.0.0.1. Closing a tunnel does not cancel the GPU job.").font(.caption).foregroundStyle(.secondary)
}.padding(14).background(.primary.opacity(0.04),in:RoundedRectangle(cornerRadius:10))
}
if !s.webStatus.isEmpty{Text(s.webStatus).font(.caption).foregroundStyle(.secondary).frame(maxWidth:.infinity,alignment:.leading)}
EmbeddedBrowser(workspace:s).frame(maxWidth:.infinity,maxHeight:.infinity).clipShape(RoundedRectangle(cornerRadius:10))
}.padding(.bottom,4).onAppear{if s.web.url==nil{s.openWeb()}}.confirmationDialog("Start an interactive GPU session in Terminal?",isPresented:$confirmShell,titleVisibility:.visible){Button("Request GPU session"){s.interactive()};Button("Edit resource request"){s.tab="Submit job"}}message:{Text("\(s.profile.account) · \(s.profile.partition)\n\(s.profile.gpus) GPU(s), \(s.profile.cpus) CPUs, \(s.profile.memory) GB, \(s.profile.walltime).\nThe shell starts on a compute node after allocation. Exit it when finished to release resources.")}}
}
struct EmbeddedBrowser:NSViewRepresentable {
@ObservedObject var workspace:Workspace
func makeCoordinator()->Coordinator{Coordinator(workspace)}
func makeNSView(context:Context)->WKWebView{workspace.web.navigationDelegate=context.coordinator;workspace.web.uiDelegate=context.coordinator;return workspace.web}
func updateNSView(_ view:WKWebView,context:Context){}
class Coordinator:NSObject,WKNavigationDelegate,WKUIDelegate {
var s:Workspace
init(_ s:Workspace){self.s=s}
func webView(_ webView:WKWebView,didFinish navigation:WKNavigation!){s.webAddress=webView.url?.absoluteString ?? s.webAddress;s.webStatus=""}
func webView(_ webView:WKWebView,didFailProvisionalNavigation navigation:WKNavigation!,withError error:Error){s.webStatus=error.localizedDescription+" · Try System browser if site authentication requires it."}
func webView(_ webView:WKWebView,decidePolicyFor action:WKNavigationAction,decisionHandler:@escaping(WKNavigationActionPolicy)->Void){guard let url=action.request.url else{decisionHandler(.cancel);return};decisionHandler(["http","https","about"].contains(url.scheme ?? "") ? .allow:.cancel)}
func webView(_ webView:WKWebView,createWebViewWith configuration:WKWebViewConfiguration,for action:WKNavigationAction,windowFeatures:WKWindowFeatures)->WKWebView?{if action.targetFrame==nil,let url=action.request.url,["http","https"].contains(url.scheme ?? ""){webView.load(action.request)};return nil}
}
}
struct ConsoleView:View {
@EnvironmentObject var s:Workspace
@ObservedObject var console:ARCConsole
@State private var confirmStart=false
@State private var confirmStop=false
var body:some View {
VStack(alignment:.leading,spacing:12){
Panel(title:"Login Node Terminal"){
Text("Login node is not for compute intensive jobs.").foregroundStyle(Color.red).font(.callout)
Text(s.target).font(.system(size:11,design:.monospaced))
HStack{Button("Start login terminal…"){confirmStart=true}.disabled(!s.connected || s.busy || console.running);Button("Interrupt (Ctrl-C)"){console.write("\u{0003}")}.disabled(!console.running);Button("Exit shell"){console.write("exit\n")}.disabled(!console.running);Button("Close session…"){confirmStop=true}.disabled(!console.running);Spacer();Text(console.session).font(.caption)}
}
NativeTerminal(console:console,dark:s.appearance == "Dark").frame(maxWidth:.infinity,maxHeight:.infinity).frame(minHeight:400).overlay(Rectangle().stroke(.gray.opacity(0.2)))
Text("Click the terminal to type at the prompt. Use arrow keys for history, Tab for completion, and Ctrl-C to interrupt. Copy and paste with Command-C / Command-V. Compute-intensive work belongs in Jobs + software.").font(.caption).foregroundStyle(.secondary)
}.confirmationDialog("Open a terminal on the login node?",isPresented:$confirmStart,titleVisibility:.visible){Button("Start login terminal"){s.startTerminal()}}message:{Text(s.target)}
.confirmationDialog("Close the SSH terminal connection?",isPresented:$confirmStop,titleVisibility:.visible){Button("Close session",role:.destructive){console.stop()}}message:{Text("This closes the login-node shell connection.")}
}
}
|