feat(ios): add cross-instance notification center

This commit is contained in:
chick
2026-09-09 01:13:45 +08:00
parent 84ec3678c0
commit cf187e42fa
9 changed files with 1114 additions and 3 deletions
@@ -0,0 +1,78 @@
import Foundation
public enum NotificationDeviceState: String, Equatable, Sendable, CaseIterable {
case ready
case unauthorized
case unavailable
case failed
}
public struct NotificationDevice: Equatable, Identifiable, Sendable {
public let id: UUID
public let name: String
public let state: NotificationDeviceState
}
public struct NotificationChannelTypeSummary: Equatable, Identifiable, Sendable {
public var id: String { type }
public let type: String
public let total: Int
public let enabled: Int
}
public struct NotificationConfigSummary: Equatable, Sendable {
public let channelCount: Int
public let channelEnabled: Int
public let ruleCount: Int
public let ruleEnabled: Int
public let channelTypes: [NotificationChannelTypeSummary]
}
public struct NotificationLogEntry: Equatable, Identifiable, Sendable {
public let id: String
public let eventType: String
public let status: String
public let ruleName: String?
public let channelName: String?
public let createdAt: String
}
public struct NotificationLogSummary: Equatable, Sendable {
public let total: Int
public let success: Int
public let failed: Int
public let quietHours: Int
public let unmatched: Int
public let noAvailableChannel: Int
public let other: Int
public let entries: [NotificationLogEntry]
}
public struct NotificationQueueEntry: Equatable, Identifiable, Sendable {
public let id: String
public let instanceID: UUID
public let instanceName: String
public let eventType: String
public let status: String
public let ruleName: String?
public let channelName: String?
public let createdAt: String
}
public struct NotificationQueueSummary: Equatable, Sendable {
public let total: Int
public let pending: Int
public let scheduled: Int
public let retrying: Int
public let sending: Int
public let failed: Int
public let items: [NotificationQueueEntry]
}
public struct NotificationOverview: Equatable, Sendable {
public let observedAt: Date
public let devices: [NotificationDevice]
public let config: NotificationConfigSummary
public let logs: NotificationLogSummary
public let queue: NotificationQueueSummary
}
@@ -0,0 +1,356 @@
import Foundation
public enum NotificationServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct NotificationService: Sendable {
private let client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.client = client
self.sessions = sessions
self.credentials = credentials
}
public func overview(_ instances: [Instance]) async throws -> NotificationOverview {
var devices: [NotificationDevice] = []
var configs: [NotificationConfigSummary] = []
var logs: [NotificationLogSummary] = []
var queues: [NotificationQueueSummary] = []
for instance in instances {
do {
let config = try Self.parseConfig(Self.data(of: try await request(.notificationConfig, instance)))
let logPage = try Self.parseLogs(Self.data(of: try await request(
.notificationLogs(limit: 50, offset: 0),
instance
)))
let queue = try Self.parseQueue(
Self.data(of: try await request(.notificationQueue(limit: 100), instance)),
instance
)
configs.append(config)
logs.append(logPage)
queues.append(queue)
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .ready))
} catch UpstreamError.notSupported {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unavailable))
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
} catch NotificationServiceError.unauthorized {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
} catch {
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .failed))
}
}
return NotificationOverview(
observedAt: Date(),
devices: devices,
config: Self.aggregateConfigs(configs),
logs: Self.aggregateLogs(logs),
queue: Self.aggregateQueues(queues)
)
}
public func retryAll(_ instances: [Instance]) async -> NotificationActionResult {
var result = NotificationActionResult()
for instance in instances {
do {
_ = try await request(.notificationRetryAllQueue, instance)
result.succeeded += 1
} catch UpstreamError.notSupported {
result.skipped += 1
} catch {
result.failed += 1
}
}
return result
}
public func retryQueue(_ instance: Instance, id: String) async throws {
try Self.validateQueueID(id)
_ = try await request(.notificationRetryQueue(id: id), instance)
}
public func deleteQueueItem(_ instance: Instance, id: String) async throws {
try Self.validateQueueID(id)
_ = try await request(.notificationDeleteQueue(id: id), instance)
}
static func validateQueueID(_ id: String) throws {
guard !id.isEmpty, id.count <= 128,
id.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." })
else {
throw NotificationServiceError.invalidInput
}
}
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
do {
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
cookie = try await login(instance)
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
}
}
private func login(_ instance: Instance) async throws -> String {
guard let credentials,
let password = try credentials.get(instanceID: instance.id),
!password.isEmpty
else {
throw NotificationServiceError.unauthorized
}
let sessionService = InstanceSessionService(store: sessions, client: client)
let result = try await sessionService.login(instance, password: password)
guard result.authenticated,
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
else {
throw NotificationServiceError.unauthorized
}
return cookie
}
static func data(of response: UpstreamResponse) throws -> [String: Any] {
guard let root = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
let status = root["status"] as? String,
["success", "ok", "OK"].contains(status),
let data = root["data"] as? [String: Any]
else {
throw NotificationServiceError.invalidResponse
}
return data
}
static func parseConfig(_ data: [String: Any]) throws -> NotificationConfigSummary {
var channelCount = 0
var channelEnabled = 0
var typeOrder: [String] = []
var typeTotals: [String: (total: Int, enabled: Int)] = [:]
func record(type: String, enabled: Bool) {
if !typeOrder.contains(type) {
typeOrder.append(type)
}
let current = typeTotals[type] ?? (0, 0)
typeTotals[type] = (current.total + 1, current.enabled + (enabled ? 1 : 0))
}
if let channels = data["channels"] as? [[String: Any]] {
for raw in channels {
guard let type = raw["type"] as? String, let enabled = raw["enabled"] as? Bool else { continue }
channelCount += 1
channelEnabled += enabled ? 1 : 0
record(type: type, enabled: enabled)
}
} else {
for type in ["webhook", "bark", "pushplus", "wecom_app", "wecom_robot", "dingtalk_robot", "dingtalk_app", "feishu_robot", "telegram", "email", "serverchan"] {
guard let value = data[type] as? [String: Any], let enabled = value["enabled"] as? Bool else { continue }
channelCount += 1
channelEnabled += enabled ? 1 : 0
record(type: type, enabled: enabled)
}
}
var ruleCount = 0
var ruleEnabled = 0
if let rules = data["rules"] as? [[String: Any]] {
for raw in rules {
guard let enabled = raw["enabled"] as? Bool else { continue }
ruleCount += 1
ruleEnabled += enabled ? 1 : 0
}
}
let types = typeOrder.map {
NotificationChannelTypeSummary(
type: $0,
total: typeTotals[$0]?.total ?? 0,
enabled: typeTotals[$0]?.enabled ?? 0
)
}
return NotificationConfigSummary(
channelCount: channelCount,
channelEnabled: channelEnabled,
ruleCount: ruleCount,
ruleEnabled: ruleEnabled,
channelTypes: types
)
}
static func parseLogs(_ data: [String: Any]) throws -> NotificationLogSummary {
guard let total = positive(data["total"]), let raw = data["logs"] as? [[String: Any]] else {
throw NotificationServiceError.invalidResponse
}
var entries: [NotificationLogEntry] = []
var counts = (
success: 0, failed: 0, quietHours: 0, unmatched: 0, noAvailableChannel: 0, other: 0
)
for item in raw {
guard let id = text(item["id"], 128),
let eventType = text(item["event_type"], 80),
let status = text(item["status"], 40),
let createdAt = text(item["created_at"], 64)
else {
throw NotificationServiceError.invalidResponse
}
entries.append(NotificationLogEntry(
id: id,
eventType: eventType,
status: status,
ruleName: text(item["rule_name"], 160),
channelName: text(item["channel_name"], 160),
createdAt: createdAt
))
switch status {
case "success": counts.success += 1
case "failed": counts.failed += 1
case "quiet_hours": counts.quietHours += 1
case "unmatched": counts.unmatched += 1
case "no_available_channel": counts.noAvailableChannel += 1
default: counts.other += 1
}
}
return NotificationLogSummary(
total: total,
success: counts.success,
failed: counts.failed,
quietHours: counts.quietHours,
unmatched: counts.unmatched,
noAvailableChannel: counts.noAvailableChannel,
other: counts.other,
entries: entries
)
}
static func parseQueue(_ data: [String: Any], _ instance: Instance) throws -> NotificationQueueSummary {
guard let total = positive(data["total"]), let raw = data["items"] as? [[String: Any]] else {
throw NotificationServiceError.invalidResponse
}
var items: [NotificationQueueEntry] = []
var counts = (pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0)
for value in raw {
guard let id = text(value["id"], 128),
let eventType = text(value["event_type"], 80),
let status = text(value["status"], 32),
let createdAt = text(value["created_at"], 64)
else {
throw NotificationServiceError.invalidResponse
}
items.append(NotificationQueueEntry(
id: id,
instanceID: instance.id,
instanceName: instance.name,
eventType: eventType,
status: status,
ruleName: text(value["rule_name"], 160),
channelName: text(value["channel_name"], 160),
createdAt: createdAt
))
switch status {
case "pending": counts.pending += 1
case "scheduled": counts.scheduled += 1
case "retrying": counts.retrying += 1
case "sending": counts.sending += 1
case "failed": counts.failed += 1
default: break
}
}
return NotificationQueueSummary(
total: total,
pending: counts.pending,
scheduled: counts.scheduled,
retrying: counts.retrying,
sending: counts.sending,
failed: counts.failed,
items: items
)
}
static func aggregateConfigs(_ values: [NotificationConfigSummary]) -> NotificationConfigSummary {
NotificationConfigSummary(
channelCount: values.reduce(0) { $0 + $1.channelCount },
channelEnabled: values.reduce(0) { $0 + $1.channelEnabled },
ruleCount: values.reduce(0) { $0 + $1.ruleCount },
ruleEnabled: values.reduce(0) { $0 + $1.ruleEnabled },
channelTypes: mergeChannelTypes(values.map(\.channelTypes))
)
}
static func aggregateLogs(_ values: [NotificationLogSummary]) -> NotificationLogSummary {
NotificationLogSummary(
total: values.reduce(0) { $0 + $1.total },
success: values.reduce(0) { $0 + $1.success },
failed: values.reduce(0) { $0 + $1.failed },
quietHours: values.reduce(0) { $0 + $1.quietHours },
unmatched: values.reduce(0) { $0 + $1.unmatched },
noAvailableChannel: values.reduce(0) { $0 + $1.noAvailableChannel },
other: values.reduce(0) { $0 + $1.other },
entries: Array(values.flatMap(\.entries).prefix(20))
)
}
static func aggregateQueues(_ values: [NotificationQueueSummary]) -> NotificationQueueSummary {
NotificationQueueSummary(
total: values.reduce(0) { $0 + $1.total },
pending: values.reduce(0) { $0 + $1.pending },
scheduled: values.reduce(0) { $0 + $1.scheduled },
retrying: values.reduce(0) { $0 + $1.retrying },
sending: values.reduce(0) { $0 + $1.sending },
failed: values.reduce(0) { $0 + $1.failed },
items: Array(values.flatMap(\.items).prefix(20))
)
}
private static func text(_ value: Any?, _ maximum: Int) -> String? {
guard let value, let text = value as? String,
!text.isEmpty, text.count <= maximum,
!text.unicodeScalars.contains(where: { $0.value < 9 || ($0.value > 13 && $0.value < 32) || $0.value == 127 })
else { return nil }
return text
}
private static func positive(_ value: Any?) -> Int? {
guard let number = value as? Int, number >= 0, number <= 10_000_000 else { return nil }
return number
}
private static func mergeChannelTypes(
_ groups: [[NotificationChannelTypeSummary]]
) -> [NotificationChannelTypeSummary] {
var merged: [NotificationChannelTypeSummary] = []
for group in groups {
for summary in group {
if let index = merged.firstIndex(where: { $0.type == summary.type }) {
let current = merged[index]
merged[index] = NotificationChannelTypeSummary(
type: current.type,
total: current.total + summary.total,
enabled: current.enabled + summary.enabled
)
} else {
merged.append(summary)
}
}
}
return merged
}
}
public struct NotificationActionResult: Equatable, Sendable {
public var succeeded = 0
public var failed = 0
public var skipped = 0
public init() {}
}
@@ -0,0 +1,316 @@
import SwiftUI
struct NotificationsView: View {
@StateObject private var model: NotificationsViewModel
init(model: NotificationsViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel())
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
if let actionMessage = model.actionMessage {
AICard {
Text(actionMessage)
.font(.subheadline)
.foregroundStyle(AITheme.success)
}
}
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
}
if let overview = model.overview {
stats(overview)
devices(overview)
channels(overview)
queue(overview)
logs(overview)
} else if model.isLoading {
AICard {
ProgressView("正在加载通知中心...")
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
}
} else {
empty
}
}
.padding(16)
}
}
.navigationTitle("通知中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
Task { await model.retryAll() }
} label: {
Label("立即投递", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
.disabled(model.isLoading)
}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
}
private var empty: some View {
AICard {
VStack(spacing: 10) {
Image(systemName: "bell")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无通知数据")
.font(.headline)
Text("下拉刷新后从已配置实例同步。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
private func stats(_ overview: NotificationOverview) -> some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
statCard("通道", "\(overview.config.channelEnabled)/\(overview.config.channelCount)", "已启用/总数", AITheme.primary)
statCard("规则", "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", "已启用/总数", AITheme.primary)
statCard("队列", "\(overview.queue.pending + overview.queue.retrying)", "等待与重试", AITheme.warning)
statCard("失败", "\(overview.queue.failed + overview.logs.failed)", "队列与投递", AITheme.danger)
}
}
private func statCard(_ title: String, _ value: String, _ detail: String, _ tint: Color) -> some View {
AICard {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(AITheme.muted)
Text(value)
.font(.title2.weight(.bold))
.foregroundStyle(tint)
Text(detail)
.font(.caption)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func devices(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("实例状态")
if overview.devices.isEmpty {
Text("尚未添加实例。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.devices) { device in
HStack(spacing: 8) {
Text(device.name)
.font(.subheadline)
Spacer()
AITag(deviceState(device.state), tint: deviceTint(device.state))
}
}
}
}
}
}
private func channels(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
title("通道类型")
if overview.config.channelTypes.isEmpty {
Text("实例未配置通知通道。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.config.channelTypes) { channel in
HStack(spacing: 8) {
Text(channelTypeLabel(channel.type))
.font(.subheadline)
Spacer()
AITag("\(channel.enabled)/\(channel.total)", tint: channel.enabled > 0 ? AITheme.success : AITheme.muted)
}
}
}
}
}
}
private func queue(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("最近队列")
Spacer()
Text("\(overview.queue.total) 项")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
if overview.queue.items.isEmpty {
Text("队列为空。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.queue.items) { item in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(eventTypeLabel(item.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(item.status), tint: statusTint(item.status))
}
Text("\(item.instanceName) · \(item.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
HStack(spacing: 8) {
Button {
Task { await model.retryQueue(item) }
} label: {
Label("重试", systemImage: "arrow.clockwise")
}
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
.disabled(model.busyQueueID == item.id)
Button {
Task { await model.deleteQueue(item) }
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
}
}
.padding(.bottom, 4)
}
}
}
}
}
private func logs(_ overview: NotificationOverview) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack {
title("投递日志")
Spacer()
Text("\(overview.logs.total) 条")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
if overview.logs.entries.isEmpty {
Text("暂无投递日志。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
} else {
ForEach(overview.logs.entries) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(eventTypeLabel(entry.eventType))
.font(.subheadline.weight(.semibold))
Spacer()
AITag(statusLabel(entry.status), tint: statusTint(entry.status))
}
Text("\(entry.channelName ?? "未指定通道") · \(entry.createdAt)")
.font(.caption)
.foregroundStyle(AITheme.muted)
}
.padding(.bottom, 4)
}
}
}
}
}
private func title(_ text: String) -> some View {
Text(text)
.font(.headline)
.foregroundStyle(AITheme.ink)
}
private func deviceState(_ state: NotificationDeviceState) -> String {
switch state {
case .ready: return "在线"
case .unauthorized: return "需登录"
case .unavailable: return "离线"
case .failed: return "异常"
}
}
private func deviceTint(_ state: NotificationDeviceState) -> Color {
switch state {
case .ready: return AITheme.success
case .unauthorized: return AITheme.warning
case .unavailable: return AITheme.muted
case .failed: return AITheme.danger
}
}
private func channelTypeLabel(_ type: String) -> String {
switch type {
case "webhook": return "Webhook"
case "bark": return "Bark"
case "pushplus": return "PushPlus"
case "wecom_app": return "企业微信应用"
case "wecom_robot": return "企业微信机器人"
case "dingtalk_robot": return "钉钉机器人"
case "dingtalk_app": return "钉钉应用"
case "feishu_robot": return "飞书机器人"
case "telegram": return "Telegram"
case "email": return "邮件"
case "serverchan": return "Server 酱"
default: return type
}
}
private func eventTypeLabel(_ value: String) -> String {
switch value {
case "sms": return "短信"
case "ddns": return "DDNS"
case "version": return "版本"
case "system": return "系统"
case "device": return "设备"
case "automation": return "自动化"
default: return value
}
}
private func statusLabel(_ value: String) -> String {
switch value {
case "success", "succeeded": return "成功"
case "failed": return "失败"
case "pending": return "等待"
case "sending": return "发送中"
case "retrying": return "重试中"
case "cancelled": return "已取消"
case "unmatched": return "未匹配"
case "no_available_channel": return "无可用通道"
case "quiet_hours": return "免打扰"
default: return value
}
}
private func statusTint(_ value: String) -> Color {
switch value {
case "success", "succeeded": return AITheme.success
case "pending", "sending", "retrying": return AITheme.warning
case "failed": return AITheme.danger
default: return AITheme.muted
}
}
}
@@ -0,0 +1,76 @@
import Foundation
@MainActor
final class NotificationsViewModel: ObservableObject {
@Published private(set) var overview: NotificationOverview?
@Published private(set) var instances: [Instance] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
@Published private(set) var actionMessage: String?
@Published private(set) var busyQueueID: String?
private let repository: any InstanceRepository
private let service: NotificationService
init(repository: any InstanceRepository, service: NotificationService) {
self.repository = repository
self.service = service
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
overview = try await service.overview(loaded)
} catch {
errorMessage = "无法加载通知中心。"
}
isLoading = false
}
func retryAll() async {
let result = await service.retryAll(instances)
if result.succeeded > 0 {
actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。"
} else if result.skipped > 0 {
actionMessage = "没有可重试的队列接口。"
} else {
actionMessage = "队列重试失败。"
}
await refresh()
}
func retryQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.retryQueue(instance, id: id)
}
}
func deleteQueue(_ item: NotificationQueueEntry) async {
await queueAction(item) { instance, id in
try await self.service.deleteQueueItem(instance, id: id)
}
await refresh()
}
private func queueAction(
_ item: NotificationQueueEntry,
action: (Instance, String) async throws -> Void
) async {
guard let instance = instances.first(where: { $0.id == item.instanceID }) else {
errorMessage = "找不到队列所属实例。"
return
}
busyQueueID = item.id
do {
try await action(instance, item.id)
actionMessage = "队列操作已完成。"
errorMessage = nil
} catch {
errorMessage = "队列操作失败。"
}
busyQueueID = nil
}
}
@@ -13,7 +13,7 @@ struct MultiSimAdminApp: App {
.tabItem {
Label("消息", systemImage: "message")
}
Text("通知")
NotificationsView()
.tabItem {
Label("通知", systemImage: "bell")
}
@@ -27,6 +27,18 @@ enum DefaultContainer {
)
}
@MainActor
static func makeNotificationsModel() -> NotificationsViewModel {
NotificationsViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
service: NotificationService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
)
)
}
@MainActor
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
DeviceViewModel(
@@ -12,16 +12,24 @@ public enum UpstreamRoute: Hashable, Sendable {
case smsList(limit: Int, offset: Int, direction: String?)
case smsSend(phoneNumber: String, content: String)
case smsBatchDelete(ids: [Int])
case notificationConfig
case notificationLogs(limit: Int, offset: Int)
case notificationQueue(limit: Int)
case notificationRetryAllQueue
case notificationRetryQueue(id: String)
case notificationDeleteQueue(id: String)
case authLogin(password: String)
case authLogout
public var method: String {
switch self {
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
.connectivity, .smsList:
.connectivity, .smsList, .notificationConfig, .notificationLogs, .notificationQueue:
return "GET"
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
case .smsSend, .smsBatchDelete, .notificationRetryAllQueue, .notificationRetryQueue, .authLogin, .authLogout:
return "POST"
case .notificationDeleteQueue:
return "DELETE"
}
}
@@ -43,6 +51,17 @@ public enum UpstreamRoute: Hashable, Sendable {
return "/api/sms/list?\(items.joined(separator: "&"))"
case .smsSend: return "/api/sms/send"
case .smsBatchDelete: return "/api/sms/batch-delete"
case .notificationConfig: return "/api/notifications/config"
case .notificationLogs(let limit, let offset):
return "/api/notifications/logs?limit=\(limit)&offset=\(offset)"
case .notificationQueue(let limit):
return "/api/notifications/queue?limit=\(limit)"
case .notificationRetryAllQueue:
return "/api/notifications/queue/retry-all"
case .notificationRetryQueue(let id):
return "/api/notifications/queue/\(id)/retry"
case .notificationDeleteQueue(let id):
return "/api/notifications/queue/\(id)"
case .authLogin: return "/api/auth/login"
case .authLogout: return "/api/auth/logout"
}
@@ -0,0 +1,163 @@
import XCTest
@testable import MultiSimAdmin
final class NotificationServiceTests: XCTestCase {
func testAggregatesInstanceConfigLogsAndQueue() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(client.routes, [
.notificationConfig,
.notificationLogs(limit: 50, offset: 0),
.notificationQueue(limit: 100)
])
XCTAssertEqual(overview.config.channelCount, 2)
XCTAssertEqual(overview.config.channelEnabled, 1)
XCTAssertEqual(overview.config.ruleCount, 1)
XCTAssertEqual(overview.config.ruleEnabled, 1)
XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook")
XCTAssertEqual(overview.logs.total, 3)
XCTAssertEqual(overview.logs.success, 2)
XCTAssertEqual(overview.logs.failed, 1)
XCTAssertEqual(overview.queue.total, 2)
XCTAssertEqual(overview.queue.pending, 1)
XCTAssertEqual(overview.queue.failed, 1)
XCTAssertEqual(overview.queue.items.first?.id, "q-1")
XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id)
XCTAssertEqual(overview.devices.first?.state, .ready)
}
func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
try await service.retryQueue(instance, id: "q_1-a.2")
try await service.deleteQueueItem(instance, id: "q_1-a.2")
XCTAssertEqual(client.routes.suffix(2), [
.notificationRetryQueue(id: "q_1-a.2"),
.notificationDeleteQueue(id: "q_1-a.2")
])
do {
try await service.retryQueue(instance, id: "bad id")
XCTFail("Invalid queue ID should fail.")
} catch {
XCTAssertEqual(error as? NotificationServiceError, .invalidInput)
}
}
func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws {
let service = NotificationService(
client: UnauthorizedNotificationClient(),
sessions: InstanceSessionStore()
)
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(overview.devices, [NotificationDevice(
id: instance.id,
name: instance.name,
state: .unauthorized
)])
XCTAssertEqual(overview.config.channelCount, 0)
XCTAssertEqual(overview.logs.total, 0)
XCTAssertEqual(overview.queue.total, 0)
}
func testAggregatesChannelTypeSummariesInFirstSeenOrder() {
let first = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 1,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0)
]
)
let second = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 2,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1)
]
)
let result = NotificationService.aggregateConfigs([first, second])
XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"])
let webhook = result.channelTypes.first { $0.type == "webhook" }
XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2))
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private struct UnauthorizedNotificationClient: SimAdminClientProtocol {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
throw UpstreamError.unauthorized
}
}
private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
let json = """
{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true},
{"type":"bark","enabled":false}],"rules":[{"enabled":true}]}}
"""
return Self.response(json)
case .notificationLogs(let limit, let offset):
XCTAssertEqual(limit, 50)
XCTAssertEqual(offset, 0)
let json = """
{"status":"ok","data":{"total":3,"logs":[
{"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"},
{"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"},
{"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"}
]}}
"""
return Self.response(json)
case .notificationQueue(let limit):
XCTAssertEqual(limit, 100)
let json = """
{"status":"ok","data":{"total":2,"items":[
{"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"},
{"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"}
]}}
"""
return Self.response(json)
case .notificationRetryQueue, .notificationDeleteQueue:
return Self.response(#"{"status":"ok"}"#)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -0,0 +1,91 @@
import XCTest
@testable import MultiSimAdmin
@MainActor
final class NotificationsViewModelTests: XCTestCase {
func testRefreshLoadsInstancesAndAggregatedOverview() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.instances.count, 1)
XCTAssertEqual(model.overview?.devices.first?.state, .ready)
XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1")
XCTAssertNil(model.errorMessage)
}
func testRetryQueueTargetsOwningInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
let item = try XCTUnwrap(model.overview?.queue.items.first)
await model.retryQueue(item)
XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1"))
}
func testRetryAllTargetsEveryInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.21:3000")!,
tags: [],
password: nil
))
await model.refresh()
await model.retryAll()
XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2)
}
}
private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#)
case .notificationLogs:
return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#)
case .notificationQueue:
return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#)
default:
return Self.response(#"{"status":"ok"}"#)
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}