feat(ios): add cross-instance notification center
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user