Initial Commit
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Destination: String, CaseIterable, Identifiable {
|
||||
case overview = "Overview"
|
||||
case hosts = "Proxy Hosts"
|
||||
case certificates = "Certificates"
|
||||
case activity = "Activity"
|
||||
case dns = "UniFi DNS"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .overview: "server.rack"
|
||||
case .hosts: "network"
|
||||
case .certificates: "checkmark.shield"
|
||||
case .activity: "clock"
|
||||
case .dns: "globe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var store = ProxyStore()
|
||||
@State private var unifi = UniFiStore()
|
||||
@State private var destination: Destination? = .hosts
|
||||
@State private var search = ""
|
||||
@State private var dnsSearch = ""
|
||||
@State private var filter = "All Hosts"
|
||||
@State private var selectedID: Int?
|
||||
@State private var sortOrder = [KeyPathComparator(\ProxyHost.domain)]
|
||||
@State private var editor: ProxyHost?
|
||||
@State private var creating = false
|
||||
@State private var showConnection = false
|
||||
@State private var showInspector = true
|
||||
@State private var deleting: ProxyHost?
|
||||
|
||||
private var selectedHost: ProxyHost? { store.hosts.first { $0.id == selectedID } }
|
||||
private var filteredHosts: [ProxyHost] {
|
||||
store.hosts.filter {
|
||||
(search.isEmpty || $0.domain.localizedCaseInsensitiveContains(search) || $0.destination.contains(search))
|
||||
&& (filter == "All Hosts" || (filter == "Enabled" && $0.enabled) || (filter == "Disabled" && !$0.enabled))
|
||||
}.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $destination) {
|
||||
Section(store.isDemo ? "Demo Server" : store.serverName) {
|
||||
ForEach([Destination.overview, .hosts, .certificates, .activity]) { item in
|
||||
Label(item.rawValue, systemImage: item.icon)
|
||||
.badge(item == .hosts ? store.hosts.count : 0)
|
||||
.tag(item)
|
||||
}
|
||||
}
|
||||
Section("UniFi Network") {
|
||||
Label("DNS Records", systemImage: "globe").tag(Destination.dns)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 280)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button { showConnection = true } label: {
|
||||
Label("Connect to Server…", systemImage: "server.rack")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.padding()
|
||||
}
|
||||
} detail: {
|
||||
detail
|
||||
.navigationTitle(destination?.rawValue ?? "Proxy Studio")
|
||||
.navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName))
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
if destination != .dns {
|
||||
Button { Task { await store.refresh() } } label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh")
|
||||
.keyboardShortcut("r")
|
||||
.disabled(store.isBusy)
|
||||
}
|
||||
|
||||
if destination == .hosts {
|
||||
Button(action: newHost) {
|
||||
Label("Add Proxy Host", systemImage: "plus")
|
||||
}
|
||||
.help("Add Proxy Host")
|
||||
.keyboardShortcut("n")
|
||||
|
||||
Button { if let host = selectedHost { edit(host) } } label: {
|
||||
Label("Edit Host", systemImage: "square.and.pencil")
|
||||
}
|
||||
.help("Edit Selected Host")
|
||||
.disabled(selectedHost == nil)
|
||||
}
|
||||
}
|
||||
ToolbarItem {
|
||||
Button { showInspector.toggle() } label: {
|
||||
Label("Inspector", systemImage: "sidebar.right")
|
||||
}
|
||||
.help("Show or Hide Inspector")
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
.disabled(destination != .hosts)
|
||||
}
|
||||
}
|
||||
.inspector(isPresented: Binding(
|
||||
get: { showInspector && destination == .hosts },
|
||||
set: { showInspector = $0 }
|
||||
)) {
|
||||
if let host = selectedHost {
|
||||
HostInspector(host: host, edit: { edit(host) }, toggle: {
|
||||
Task { await store.toggle(host) }
|
||||
}, delete: { deleting = host })
|
||||
.inspectorColumnWidth(min: 260, ideal: 290, max: 380)
|
||||
} else {
|
||||
ContentUnavailableView("No Selection", systemImage: "network", description: Text("Select a proxy host to view its details."))
|
||||
.inspectorColumnWidth(min: 260, ideal: 290, max: 380)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A window must own only one search toolbar item. Keeping this outside the
|
||||
// changing detail views avoids duplicate AppKit search identifiers.
|
||||
.searchable(
|
||||
text: Binding(
|
||||
get: { destination == .dns ? dnsSearch : search },
|
||||
set: { if destination == .dns { dnsSearch = $0 } else { search = $0 } }
|
||||
),
|
||||
prompt: destination == .dns ? "Search DNS Records" : "Search Hosts"
|
||||
)
|
||||
.frame(minWidth: 900, minHeight: 550)
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
|
||||
await store.restoreConnection()
|
||||
}
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
|
||||
await unifi.restoreConnection()
|
||||
}
|
||||
.sheet(item: $editor) { host in
|
||||
HostEditor(store: store, host: host, isNew: creating)
|
||||
}
|
||||
.sheet(isPresented: $showConnection) { ConnectionSheet(store: store) }
|
||||
.alert("Unable to Complete Request", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
|
||||
Button("OK") { store.error = nil }
|
||||
} message: { Text(store.error ?? "") }
|
||||
.confirmationDialog("Delete \(deleting?.domain ?? "host")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
|
||||
Button("Delete Proxy Host", role: .destructive) {
|
||||
if let host = deleting { Task { await store.delete(host) } }
|
||||
deleting = nil
|
||||
}
|
||||
} message: { Text("This removes the proxy configuration. Your upstream service is not affected.") }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
switch destination ?? .hosts {
|
||||
case .hosts:
|
||||
hostTable
|
||||
case .overview:
|
||||
ServerOverview(store: store, connect: { showConnection = true })
|
||||
case .certificates:
|
||||
CertificateTable(certificates: store.certificates, isDemo: store.isDemo)
|
||||
case .activity:
|
||||
ActivityTable(entries: (store.activity + unifi.activity).sorted { $0.date > $1.date })
|
||||
case .dns:
|
||||
UniFiDNSView(store: unifi, search: $dnsSearch)
|
||||
}
|
||||
}
|
||||
|
||||
private var hostTable: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Picker("Show", selection: $filter) {
|
||||
Text("All Hosts").tag("All Hosts")
|
||||
Text("Enabled").tag("Enabled")
|
||||
Text("Disabled").tag("Disabled")
|
||||
}
|
||||
.frame(width: 175)
|
||||
Spacer()
|
||||
if store.isBusy { ProgressView().controlSize(.small) }
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
Divider()
|
||||
Table(filteredHosts, selection: $selectedID, sortOrder: $sortOrder) {
|
||||
TableColumn("Domain", value: \.domain) { host in
|
||||
Label(host.domain, systemImage: "globe")
|
||||
}
|
||||
.width(min: 140, ideal: 200)
|
||||
TableColumn("Destination", value: \.destination)
|
||||
.width(min: 130, ideal: 180)
|
||||
TableColumn("SSL") { host in
|
||||
Text(host.secured ? "HTTPS" : "HTTP")
|
||||
}
|
||||
.width(65)
|
||||
TableColumn("Status") { host in
|
||||
Label(host.enabled ? "Enabled" : "Disabled", systemImage: host.enabled ? "checkmark.circle.fill" : "minus.circle")
|
||||
.foregroundStyle(host.enabled ? Color.primary : Color.secondary)
|
||||
}
|
||||
.width(95)
|
||||
}
|
||||
.contextMenu(forSelectionType: Int.self) { ids in
|
||||
if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) {
|
||||
Button("Edit Host…") { edit(host) }
|
||||
Button(host.enabled ? "Disable Host" : "Enable Host") {
|
||||
Task { await store.toggle(host) }
|
||||
}
|
||||
Divider()
|
||||
Button("Delete Host…", role: .destructive) { deleting = host }
|
||||
}
|
||||
} primaryAction: { ids in
|
||||
if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) { edit(host) }
|
||||
}
|
||||
.overlay {
|
||||
if filteredHosts.isEmpty {
|
||||
ContentUnavailableView("No Hosts", systemImage: "network", description: Text(search.isEmpty ? "Add a proxy host or change the status filter." : "No hosts match your search."))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Text("\(filteredHosts.count) hosts")
|
||||
Spacer()
|
||||
if store.isDemo {
|
||||
Text("Demo data — stored on this Mac")
|
||||
} else if let date = store.lastSynced {
|
||||
Text("Updated \(date.formatted(date: .omitted, time: .shortened))")
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func edit(_ host: ProxyHost) {
|
||||
creating = false
|
||||
editor = host
|
||||
}
|
||||
|
||||
private func newHost() {
|
||||
creating = true
|
||||
editor = ProxyHost.sample(0, "", "", 80, secure: false)
|
||||
}
|
||||
}
|
||||
|
||||
struct HostInspector: View {
|
||||
let host: ProxyHost
|
||||
let edit: () -> Void
|
||||
let toggle: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Domain", value: host.domain)
|
||||
LabeledContent("Status", value: host.enabled ? "Enabled" : "Disabled")
|
||||
LabeledContent("ID", value: String(host.id))
|
||||
} header: {
|
||||
Text("Proxy Host")
|
||||
}
|
||||
Section("Forwarding") {
|
||||
LabeledContent("Host", value: host.forwardHost)
|
||||
LabeledContent("Port", value: String(host.forwardPort))
|
||||
LabeledContent("Scheme", value: host.forwardScheme.uppercased())
|
||||
}
|
||||
Section("Security") {
|
||||
LabeledContent("Certificate", value: host.secured ? "#\(host.certificateId)" : "None")
|
||||
LabeledContent("Force HTTPS", value: host.sslForced ? "On" : "Off")
|
||||
LabeledContent("Block Exploits", value: host.blockExploits ? "On" : "Off")
|
||||
LabeledContent("Access", value: host.accessListId == 0 ? "Public" : "Restricted")
|
||||
}
|
||||
Section("Options") {
|
||||
LabeledContent("HTTP/2", value: host.http2Support ? "On" : "Off")
|
||||
LabeledContent("WebSockets", value: host.allowWebsocketUpgrade ? "On" : "Off")
|
||||
}
|
||||
Section {
|
||||
Button("Edit Host…", action: edit)
|
||||
Button(host.enabled ? "Disable Host" : "Enable Host", action: toggle)
|
||||
Button("Delete Host…", role: .destructive, action: delete)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerOverview: View {
|
||||
let store: ProxyStore
|
||||
let connect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Server") {
|
||||
LabeledContent("Name", value: store.serverName)
|
||||
LabeledContent("Connection", value: store.isDemo ? "Demo" : "Connected")
|
||||
if !store.isDemo { LabeledContent("URL", value: store.baseURL) }
|
||||
Button("Connect to Server…", action: connect)
|
||||
}
|
||||
Section("Configuration") {
|
||||
LabeledContent("Proxy Hosts", value: String(store.hosts.count))
|
||||
LabeledContent("Enabled Hosts", value: String(store.hosts.filter(\.enabled).count))
|
||||
LabeledContent("Hosts with SSL", value: String(store.hosts.filter(\.secured).count))
|
||||
LabeledContent("Certificates", value: String(store.certificates.count))
|
||||
}
|
||||
if store.isDemo {
|
||||
Section {
|
||||
Text("This workspace contains sample hosts. Changes are stored locally. Connect to a Nginx Proxy Manager server to manage its configuration.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
struct CertificateTable: View {
|
||||
let certificates: [ProxyCertificate]
|
||||
let isDemo: Bool
|
||||
|
||||
var body: some View {
|
||||
Table(certificates) {
|
||||
TableColumn("Name", value: \.niceName)
|
||||
TableColumn("Domains") { certificate in
|
||||
Text(certificate.domainNames.joined(separator: ", "))
|
||||
}
|
||||
TableColumn("Provider") { certificate in
|
||||
Text(certificate.provider == "letsencrypt" ? "Let’s Encrypt" : "Custom")
|
||||
}
|
||||
TableColumn("Expiration") { certificate in
|
||||
Text(certificate.expiresOn ?? (isDemo ? "Demo certificate" : "Not provided"))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if certificates.isEmpty {
|
||||
ContentUnavailableView("No Certificates", systemImage: "checkmark.shield", description: Text("Certificates from your server appear here."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityTable: View {
|
||||
let entries: [ActivityEntry]
|
||||
|
||||
var body: some View {
|
||||
Table(entries) {
|
||||
TableColumn("Time") { entry in
|
||||
Text(entry.date, style: .time)
|
||||
}
|
||||
.width(100)
|
||||
TableColumn("Action", value: \.title)
|
||||
TableColumn("Host", value: \.detail)
|
||||
}
|
||||
.overlay {
|
||||
if entries.isEmpty {
|
||||
ContentUnavailableView("No Activity", systemImage: "clock", description: Text("Changes made during this session appear here."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.frame(width: 1180, height: 720)
|
||||
}
|
||||
Reference in New Issue
Block a user