2025-12-24 19:36:10 +01:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
extension CronSettings {
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
|
self.header
|
|
|
|
|
|
self.schedulerBanner
|
|
|
|
|
|
self.content
|
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
|
}
|
2026-01-20 11:49:31 +00:00
|
|
|
|
.onAppear {
|
|
|
|
|
|
self.store.start()
|
|
|
|
|
|
self.channelsStore.start()
|
|
|
|
|
|
}
|
|
|
|
|
|
.onDisappear {
|
|
|
|
|
|
self.store.stop()
|
|
|
|
|
|
self.channelsStore.stop()
|
|
|
|
|
|
}
|
2025-12-24 19:36:10 +01:00
|
|
|
|
.sheet(isPresented: self.$showEditor) {
|
|
|
|
|
|
CronJobEditor(
|
|
|
|
|
|
job: self.editingJob,
|
|
|
|
|
|
isSaving: self.$isSaving,
|
|
|
|
|
|
error: self.$editorError,
|
2026-01-20 11:49:31 +00:00
|
|
|
|
channelsStore: self.channelsStore,
|
2025-12-24 19:36:10 +01:00
|
|
|
|
onCancel: {
|
|
|
|
|
|
self.showEditor = false
|
|
|
|
|
|
self.editingJob = nil
|
|
|
|
|
|
},
|
|
|
|
|
|
onSave: { payload in
|
|
|
|
|
|
Task {
|
|
|
|
|
|
await self.save(payload: payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.alert("Delete cron job?", isPresented: Binding(
|
2026-01-15 14:40:57 +00:00
|
|
|
|
get: { self.confirmDelete != nil },
|
|
|
|
|
|
set: { if !$0 { self.confirmDelete = nil } }))
|
2025-12-24 19:36:10 +01:00
|
|
|
|
{
|
|
|
|
|
|
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
|
|
|
|
|
Button("Delete", role: .destructive) {
|
|
|
|
|
|
if let job = self.confirmDelete {
|
|
|
|
|
|
Task { await self.store.removeJob(id: job.id) }
|
|
|
|
|
|
}
|
|
|
|
|
|
self.confirmDelete = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
} message: {
|
|
|
|
|
|
if let job = self.confirmDelete {
|
|
|
|
|
|
Text(job.displayName)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.onChange(of: self.store.selectedJobId) { _, newValue in
|
2026-01-15 14:40:57 +00:00
|
|
|
|
guard let newValue else { return }
|
|
|
|
|
|
Task { await self.store.refreshRuns(jobId: newValue) }
|
|
|
|
|
|
}
|
2025-12-24 19:36:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var schedulerBanner: some View {
|
|
|
|
|
|
Group {
|
|
|
|
|
|
if self.store.schedulerEnabled == false {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
|
|
|
|
.foregroundStyle(.orange)
|
|
|
|
|
|
Text("Cron scheduler is disabled")
|
|
|
|
|
|
.font(.headline)
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
}
|
|
|
|
|
|
Text(
|
|
|
|
|
|
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
|
|
|
|
|
|
"and the Gateway restarts.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
|
|
|
|
|
Text(storePath)
|
|
|
|
|
|
.font(.caption.monospaced())
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.truncationMode(.middle)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
.padding(10)
|
|
|
|
|
|
.background(Color.orange.opacity(0.10))
|
|
|
|
|
|
.cornerRadius(8)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var header: some View {
|
|
|
|
|
|
HStack(alignment: .top) {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
|
Text("Cron")
|
|
|
|
|
|
.font(.headline)
|
|
|
|
|
|
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
|
}
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
Button {
|
|
|
|
|
|
Task { await self.store.refreshJobs() }
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.bordered)
|
|
|
|
|
|
.disabled(self.store.isLoadingJobs)
|
|
|
|
|
|
|
|
|
|
|
|
Button {
|
|
|
|
|
|
self.editorError = nil
|
|
|
|
|
|
self.editingJob = nil
|
|
|
|
|
|
self.showEditor = true
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
Label("New Job", systemImage: "plus")
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var content: some View {
|
|
|
|
|
|
HStack(spacing: 12) {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
|
if let err = self.store.lastError {
|
|
|
|
|
|
Text("Error: \(err)")
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.red)
|
|
|
|
|
|
} else if let msg = self.store.statusMessage {
|
|
|
|
|
|
Text(msg)
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
List(selection: self.$store.selectedJobId) {
|
|
|
|
|
|
ForEach(self.store.jobs) { job in
|
|
|
|
|
|
self.jobRow(job)
|
|
|
|
|
|
.tag(job.id)
|
|
|
|
|
|
.contextMenu { self.jobContextMenu(job) }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.listStyle(.inset)
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(width: 250)
|
|
|
|
|
|
|
|
|
|
|
|
Divider()
|
|
|
|
|
|
|
|
|
|
|
|
self.detail
|
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
|
var detail: some View {
|
|
|
|
|
|
if let selected = self.selectedJob {
|
|
|
|
|
|
ScrollView(.vertical) {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
|
self.detailHeader(selected)
|
|
|
|
|
|
self.detailCard(selected)
|
|
|
|
|
|
self.runHistoryCard(selected)
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
.padding(.top, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
|
Text("Select a job to inspect details and run history.")
|
|
|
|
|
|
.font(.callout)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
|
|
|
|
|
|
.font(.caption)
|
|
|
|
|
|
.foregroundStyle(.tertiary)
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
|
.padding(.top, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|