feat: add optional server groups

This commit is contained in:
2026-04-21 00:15:08 +02:00
parent 08db74f397
commit 44cc620d3d
6 changed files with 519 additions and 84 deletions

View File

@@ -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())