feat: add optional server groups
This commit is contained in:
@@ -8,16 +8,23 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct MainView: View {
|
||||
|
||||
private static let serverOrderKeyStatic = "serverOrder"
|
||||
private static let storedServersKeyStatic = "storedServers"
|
||||
private static let storedGroupsKeyStatic = "storedGroups"
|
||||
|
||||
@State var showAddServerSheet: Bool = false
|
||||
@State private var showAddGroupSheet: Bool = false
|
||||
@State private var serverBeingEdited: Server?
|
||||
@State private var groupBeingEdited: ServerGroup?
|
||||
@State private var serverToDelete: Server?
|
||||
@State private var groupToDelete: ServerGroup?
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showDeleteGroupConfirmation = false
|
||||
@State private var isFetchingInfo: Bool = false
|
||||
@AppStorage("pingInterval") private var pingInterval: Int = 10
|
||||
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
|
||||
@@ -27,12 +34,15 @@ struct MainView: View {
|
||||
@State private var refreshSubscription: AnyCancellable?
|
||||
@State private var pingTimer: Timer?
|
||||
@State private var restartingServerID: UUID?
|
||||
@State private var draggedGroupID: UUID?
|
||||
@State private var groupDropIndicator: GroupDropIndicator?
|
||||
@State private var lastRefreshInterval: Int?
|
||||
@State private var previousServiceStates: [String: String] = [:]
|
||||
private let serverOrderKey = MainView.serverOrderKeyStatic
|
||||
private let storedServersKey = MainView.storedServersKeyStatic
|
||||
private let storedGroupsKey = MainView.storedGroupsKeyStatic
|
||||
|
||||
@State private var servers: [Server] = MainView.loadStoredServers()
|
||||
@State private var groups: [ServerGroup] = MainView.loadStoredGroups()
|
||||
|
||||
// @State private var selectedServer: Server?
|
||||
@State private var selectedServerID: UUID?
|
||||
@@ -40,81 +50,106 @@ struct MainView: View {
|
||||
var body: some View {
|
||||
var mainContent: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedServerID) {
|
||||
ForEach(servers) { server in
|
||||
HStack {
|
||||
Image(systemName: "dot.circle.fill")
|
||||
.foregroundColor(server.pingable ? .green : .red)
|
||||
Text(server.hostname)
|
||||
ZStack {
|
||||
SidebarMaterialView()
|
||||
|
||||
List(selection: $selectedServerID) {
|
||||
sidebarContent
|
||||
}
|
||||
.tag(server)
|
||||
.contextMenu {
|
||||
Button("Edit") {
|
||||
print("Editing:", server.hostname)
|
||||
serverBeingEdited = server
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
serverToDelete = server
|
||||
showDeleteConfirmation = true
|
||||
.listStyle(.sidebar)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 0, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.frame(width: 1)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button("Add Host") {
|
||||
showAddServerSheet = true
|
||||
}
|
||||
Button("Add Group") {
|
||||
showAddGroupSheet = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.help("Add Host or Group")
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveServer)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: { showAddServerSheet = true }) {
|
||||
Image(systemName: "plus")
|
||||
.navigationTitle("Servers")
|
||||
.onChange(of: selectedServerID) {
|
||||
if let selectedServerID {
|
||||
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
|
||||
fetchServerInfo(for: selectedServerID)
|
||||
}
|
||||
.help("Add Host")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Servers")
|
||||
.onChange(of: selectedServerID) {
|
||||
if let selectedServerID {
|
||||
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
|
||||
fetchServerInfo(for: selectedServerID)
|
||||
} detail: {
|
||||
if let selectedServerID,
|
||||
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
|
||||
let serverID = servers[index].id
|
||||
ServerDetailView(
|
||||
server: $servers[index],
|
||||
isFetching: isFetchingInfo,
|
||||
canRestart: servers[index].info?.supportsRestartCommand == true,
|
||||
isRestarting: restartingServerID == serverID
|
||||
) {
|
||||
await restartServer(for: serverID)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let selectedServerID,
|
||||
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
|
||||
let serverID = servers[index].id
|
||||
ServerDetailView(
|
||||
server: $servers[index],
|
||||
isFetching: isFetchingInfo,
|
||||
canRestart: servers[index].info?.supportsRestartCommand == true,
|
||||
isRestarting: restartingServerID == serverID
|
||||
) {
|
||||
await restartServer(for: serverID)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
|
||||
}
|
||||
}
|
||||
}
|
||||
return mainContent
|
||||
.sheet(isPresented: $showAddServerSheet) {
|
||||
ServerFormView(
|
||||
mode: .add,
|
||||
servers: $servers,
|
||||
groups: $groups,
|
||||
dismiss: { showAddServerSheet = false }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showAddGroupSheet) {
|
||||
GroupFormView(mode: .add, groups: $groups) {
|
||||
saveGroups()
|
||||
}
|
||||
}
|
||||
.sheet(item: $serverBeingEdited) { server in
|
||||
ServerFormView(
|
||||
mode: .edit(server),
|
||||
servers: $servers,
|
||||
groups: $groups,
|
||||
dismiss: { serverBeingEdited = nil }
|
||||
)
|
||||
}
|
||||
.sheet(item: $groupBeingEdited) { group in
|
||||
GroupFormView(mode: .edit(group), groups: $groups) {
|
||||
saveGroups()
|
||||
}
|
||||
}
|
||||
.alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in
|
||||
Button("Delete", role: .destructive) {
|
||||
ServerFormView.delete(server: server, from: &servers)
|
||||
saveServers()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert("Are you sure you want to delete this group?", isPresented: $showDeleteGroupConfirmation, presenting: groupToDelete) { group in
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteGroup(group)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { group in
|
||||
Text("Servers in \(group.name) will remain available and become ungrouped.")
|
||||
}
|
||||
.onAppear {
|
||||
requestNotificationPermissions()
|
||||
|
||||
@@ -149,8 +184,145 @@ struct MainView: View {
|
||||
setupRefreshTimer()
|
||||
}
|
||||
}
|
||||
.onChange(of: groups) { _, _ in
|
||||
saveGroups()
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 450)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebarContent: some View {
|
||||
if groups.isEmpty {
|
||||
ForEach(servers) { server in
|
||||
sidebarRow(for: server)
|
||||
}
|
||||
.onMove(perform: moveServer)
|
||||
} else {
|
||||
ForEach(groups) { group in
|
||||
Section {
|
||||
ForEach(servers(in: group)) { server in
|
||||
sidebarRow(for: server)
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveServers(in: group.id, from: source, to: destination)
|
||||
}
|
||||
} header: {
|
||||
groupHeader(for: group)
|
||||
}
|
||||
}
|
||||
|
||||
if !ungroupedServers.isEmpty {
|
||||
Section {
|
||||
ForEach(ungroupedServers) { server in
|
||||
sidebarRow(for: server)
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveServers(in: nil, from: source, to: destination)
|
||||
}
|
||||
} header: {
|
||||
sidebarSectionHeader("Ungrouped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarSectionHeader(_ title: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: NSFont.systemFontSize + 1, weight: .bold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func sidebarRow(for server: Server) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "dot.circle.fill")
|
||||
.foregroundColor(server.pingable ? .green : .red)
|
||||
Text(server.hostname)
|
||||
}
|
||||
.tag(server.id)
|
||||
.contextMenu {
|
||||
Button("Edit") {
|
||||
print("Editing:", server.hostname)
|
||||
serverBeingEdited = server
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
serverToDelete = server
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupHeader(for group: ServerGroup) -> some View {
|
||||
let activePlacement = groupDropIndicator?.groupID == group.id ? groupDropIndicator?.placement : nil
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
if activePlacement == .before {
|
||||
dropIndicator
|
||||
}
|
||||
|
||||
sidebarSectionHeader(group.name)
|
||||
.contentShape(Rectangle())
|
||||
.background {
|
||||
if activePlacement != nil {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
}
|
||||
}
|
||||
|
||||
if activePlacement == .after {
|
||||
dropIndicator
|
||||
}
|
||||
}
|
||||
.onDrag {
|
||||
draggedGroupID = group.id
|
||||
return NSItemProvider(object: group.id.uuidString as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [UTType.text],
|
||||
delegate: GroupDropDelegate(
|
||||
targetGroup: group,
|
||||
groups: $groups,
|
||||
draggedGroupID: $draggedGroupID,
|
||||
indicator: $groupDropIndicator
|
||||
)
|
||||
)
|
||||
.contextMenu {
|
||||
Button("Edit Group") {
|
||||
groupBeingEdited = group
|
||||
}
|
||||
Button("Delete Group", role: .destructive) {
|
||||
groupToDelete = group
|
||||
showDeleteGroupConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dropIndicator: some View {
|
||||
VStack(spacing: 4) {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(height: 3)
|
||||
.shadow(color: Color.accentColor.opacity(0.25), radius: 1, y: 0)
|
||||
Color.clear
|
||||
.frame(height: 4)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var ungroupedServers: [Server] {
|
||||
servers.filter { server in
|
||||
guard let groupID = server.groupID else { return true }
|
||||
return groups.contains(where: { $0.id == groupID }) == false
|
||||
}
|
||||
}
|
||||
|
||||
private func servers(in group: ServerGroup) -> [Server] {
|
||||
servers.filter { $0.groupID == group.id }
|
||||
}
|
||||
|
||||
private func fetchServerInfo(for id: UUID) {
|
||||
guard let server = servers.first(where: { $0.id == id }) else {
|
||||
@@ -223,6 +395,41 @@ struct MainView: View {
|
||||
|
||||
private func moveServer(from source: IndexSet, to destination: Int) {
|
||||
servers.move(fromOffsets: source, toOffset: destination)
|
||||
saveServers()
|
||||
saveServerOrder()
|
||||
}
|
||||
|
||||
private func moveServers(in groupID: UUID?, from source: IndexSet, to destination: Int) {
|
||||
let matchingServers = servers.filter { server in
|
||||
if let groupID {
|
||||
return server.groupID == groupID
|
||||
}
|
||||
return server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
|
||||
}
|
||||
|
||||
var reorderedServers = matchingServers
|
||||
reorderedServers.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
let replacements = Dictionary(uniqueKeysWithValues: reorderedServers.map { ($0.id, $0) })
|
||||
var reorderedIDs = reorderedServers.map(\.id)
|
||||
|
||||
servers = servers.map { server in
|
||||
let belongsInSection: Bool
|
||||
if let groupID {
|
||||
belongsInSection = server.groupID == groupID
|
||||
} else {
|
||||
belongsInSection = server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
|
||||
}
|
||||
|
||||
guard belongsInSection, let nextID = reorderedIDs.first else {
|
||||
return server
|
||||
}
|
||||
|
||||
reorderedIDs.removeFirst()
|
||||
return replacements[nextID] ?? server
|
||||
}
|
||||
|
||||
saveServers()
|
||||
saveServerOrder()
|
||||
}
|
||||
|
||||
@@ -231,17 +438,45 @@ struct MainView: View {
|
||||
UserDefaults.standard.set(ids, forKey: serverOrderKey)
|
||||
print("💾 [MainView] Saved server order with \(ids.count) entries")
|
||||
}
|
||||
|
||||
private func saveServers() {
|
||||
if let data = try? JSONEncoder().encode(servers) {
|
||||
UserDefaults.standard.set(data, forKey: MainView.storedServersKeyStatic)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGroups() {
|
||||
if let data = try? JSONEncoder().encode(groups) {
|
||||
UserDefaults.standard.set(data, forKey: storedGroupsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteGroup(_ group: ServerGroup) {
|
||||
groups.removeAll { $0.id == group.id }
|
||||
for index in servers.indices {
|
||||
if servers[index].groupID == group.id {
|
||||
servers[index].groupID = nil
|
||||
}
|
||||
}
|
||||
saveGroups()
|
||||
saveServers()
|
||||
}
|
||||
|
||||
private struct PingResponse: Codable {
|
||||
let response: String
|
||||
}
|
||||
|
||||
func pingAllServers() {
|
||||
for (index, server) in servers.enumerated() {
|
||||
let pingTargets = servers.map { ($0.id, $0.hostname) }
|
||||
|
||||
for (serverID, hostname) in pingTargets {
|
||||
Task {
|
||||
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
|
||||
let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let pingable = await PingService.ping(hostname: hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
|
||||
await MainActor.run {
|
||||
guard let index = servers.firstIndex(where: { $0.id == serverID }) else {
|
||||
return
|
||||
}
|
||||
servers[index].pingable = pingable
|
||||
}
|
||||
}
|
||||
@@ -299,6 +534,20 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadStoredGroups() -> [ServerGroup] {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let data = defaults.data(forKey: storedGroupsKeyStatic) else {
|
||||
return []
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode([ServerGroup].self, from: data)
|
||||
} catch {
|
||||
print("❌ [MainView] Failed to decode stored groups: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func requestNotificationPermissions() {
|
||||
Task {
|
||||
do {
|
||||
@@ -421,6 +670,90 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarMaterialView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.blendingMode = .behindWindow
|
||||
view.material = .sidebar
|
||||
view.state = .active
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||||
nsView.state = .active
|
||||
}
|
||||
}
|
||||
|
||||
private struct GroupDropDelegate: DropDelegate {
|
||||
enum Placement {
|
||||
case before
|
||||
case after
|
||||
}
|
||||
|
||||
let targetGroup: ServerGroup
|
||||
@Binding var groups: [ServerGroup]
|
||||
@Binding var draggedGroupID: UUID?
|
||||
@Binding var indicator: GroupDropIndicator?
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
updateIndicator(with: info)
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
updateIndicator(with: info)
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
indicator = nil
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
defer {
|
||||
draggedGroupID = nil
|
||||
indicator = nil
|
||||
}
|
||||
|
||||
guard
|
||||
let draggedGroupID,
|
||||
draggedGroupID != targetGroup.id,
|
||||
let fromIndex = groups.firstIndex(where: { $0.id == draggedGroupID }),
|
||||
let toIndex = groups.firstIndex(where: { $0.id == targetGroup.id })
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let placement = placement(for: info)
|
||||
let proposedIndex = placement == .after ? toIndex + 1 : toIndex
|
||||
groups.move(
|
||||
fromOffsets: IndexSet(integer: fromIndex),
|
||||
toOffset: proposedIndex > fromIndex ? proposedIndex + 1 : proposedIndex
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateIndicator(with info: DropInfo) {
|
||||
guard let draggedGroupID, draggedGroupID != targetGroup.id else {
|
||||
indicator = nil
|
||||
return
|
||||
}
|
||||
|
||||
indicator = GroupDropIndicator(
|
||||
groupID: targetGroup.id,
|
||||
placement: placement(for: info)
|
||||
)
|
||||
}
|
||||
|
||||
private func placement(for info: DropInfo) -> Placement {
|
||||
info.location.y > 12 ? .after : .before
|
||||
}
|
||||
}
|
||||
|
||||
private struct GroupDropIndicator: Equatable {
|
||||
let groupID: UUID
|
||||
let placement: GroupDropDelegate.Placement
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainView()
|
||||
.environmentObject(SparkleUpdater())
|
||||
|
||||
Reference in New Issue
Block a user