6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
553 lines
16 KiB
Swift
553 lines
16 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - User
|
|
|
|
enum UserRole: String, Codable, CaseIterable {
|
|
case admin
|
|
case `operator`
|
|
case viewer
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .admin: return "Admin"
|
|
case .operator: return "Operatör"
|
|
case .viewer: return "Visare"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .admin: return Color(.systemRed)
|
|
case .operator: return Color(.systemOrange)
|
|
case .viewer: return Color(.systemBlue)
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .admin: return "shield.fill"
|
|
case .operator: return "wrench.and.screwdriver.fill"
|
|
case .viewer: return "eye.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct User: Codable, Identifiable {
|
|
let id: String
|
|
let email: String
|
|
let name: String
|
|
let role: UserRole
|
|
let createdAt: Date
|
|
let lastLogin: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, email, name, role
|
|
case createdAt = "created_at"
|
|
case lastLogin = "last_login"
|
|
}
|
|
}
|
|
|
|
// MARK: - Module
|
|
|
|
enum ModuleStatus: String, Codable {
|
|
case healthy
|
|
case degraded
|
|
case offline
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .healthy: return "Aktiv"
|
|
case .degraded: return "Degraderad"
|
|
case .offline: return "Offline"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .healthy: return Color(.systemGreen)
|
|
case .degraded: return Color(.systemOrange)
|
|
case .offline: return Color(.systemRed)
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .healthy: return "checkmark.circle.fill"
|
|
case .degraded: return "exclamationmark.triangle.fill"
|
|
case .offline: return "xmark.circle.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Module: Codable, Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let enabled: Bool
|
|
let description: String
|
|
let icon: String
|
|
let status: ModuleStatus
|
|
}
|
|
|
|
// MARK: - AuditEvent
|
|
|
|
struct AuditEvent: Codable, Identifiable {
|
|
let id: String
|
|
let userId: String
|
|
let action: String
|
|
let resource: String
|
|
let ip: String
|
|
let timestamp: Date
|
|
let details: [String: String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, action, resource, ip, timestamp, details
|
|
case userId = "user_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - SystemMetrics
|
|
|
|
struct SystemMetrics: Codable {
|
|
let cpu: Double
|
|
let ram: Double
|
|
let disk: Double
|
|
let servicesHealthy: Int
|
|
let servicesTotal: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case cpu, ram, disk
|
|
case servicesHealthy = "services_healthy"
|
|
case servicesTotal = "services_total"
|
|
}
|
|
|
|
var servicesRatio: Double {
|
|
guard servicesTotal > 0 else { return 0 }
|
|
return Double(servicesHealthy) / Double(servicesTotal)
|
|
}
|
|
|
|
func statusColor(for value: Double) -> Color {
|
|
switch value {
|
|
case ..<0.6: return Color(.systemGreen)
|
|
case ..<0.8: return Color(.systemOrange)
|
|
default: return Color(.systemRed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - DashboardData
|
|
|
|
struct DashboardData: Codable {
|
|
let usersCount: Int
|
|
let modulesHealthy: Int
|
|
let modulesTotal: Int
|
|
let recentAudit: [AuditEvent]
|
|
let metrics: SystemMetrics
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case metrics
|
|
case usersCount = "users_count"
|
|
case modulesHealthy = "modules_healthy"
|
|
case modulesTotal = "modules_total"
|
|
case recentAudit = "recent_audit"
|
|
}
|
|
|
|
var modulesRatio: Double {
|
|
guard modulesTotal > 0 else { return 0 }
|
|
return Double(modulesHealthy) / Double(modulesTotal)
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON Decoder
|
|
|
|
extension JSONDecoder {
|
|
static let aamos: JSONDecoder = {
|
|
let d = JSONDecoder()
|
|
d.dateDecodingStrategy = .iso8601
|
|
return d
|
|
}()
|
|
}
|
|
|
|
// MARK: - UserRowView
|
|
|
|
struct UserRowView: View {
|
|
let user: User
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(user.role.color.opacity(0.15))
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: user.role.icon)
|
|
.foregroundStyle(user.role.color)
|
|
.font(.system(size: 18, weight: .medium))
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(user.name)
|
|
.font(.system(.body, design: .default, weight: .semibold))
|
|
.foregroundStyle(Color(.label))
|
|
Text(user.email)
|
|
.font(.caption)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
}
|
|
Spacer()
|
|
Text(user.role.label)
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(user.role.color.opacity(0.15))
|
|
.foregroundStyle(user.role.color)
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - ModuleCardView
|
|
|
|
struct ModuleCardView: View {
|
|
let module: Module
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(module.status.color.opacity(0.15))
|
|
.frame(width: 44, height: 44)
|
|
Image(systemName: module.icon)
|
|
.foregroundStyle(module.status.color)
|
|
.font(.system(size: 20, weight: .medium))
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(module.name)
|
|
.font(.system(.body, design: .default, weight: .semibold))
|
|
.foregroundStyle(Color(.label))
|
|
Spacer()
|
|
HStack(spacing: 4) {
|
|
Image(systemName: module.status.icon)
|
|
Text(module.status.label)
|
|
}
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(module.status.color)
|
|
}
|
|
Text(module.description)
|
|
.font(.caption)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.opacity(module.enabled ? 1 : 0.5)
|
|
}
|
|
}
|
|
|
|
// MARK: - AuditEventRowView
|
|
|
|
struct AuditEventRowView: View {
|
|
let event: AuditEvent
|
|
|
|
private var relativeTime: String {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
return f.localizedString(for: event.timestamp, relativeTo: .now)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.foregroundStyle(Color(.systemBlue))
|
|
.font(.system(size: 16, weight: .medium))
|
|
.frame(width: 24)
|
|
.padding(.top, 2)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(event.action)
|
|
.font(.system(.subheadline, design: .monospaced, weight: .semibold))
|
|
.foregroundStyle(Color(.label))
|
|
Spacer()
|
|
Text(relativeTime)
|
|
.font(.caption2)
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
Text(event.resource)
|
|
.font(.caption)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "network")
|
|
.font(.caption2)
|
|
Text(event.ip)
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - MetricGaugeView
|
|
|
|
struct MetricGaugeView: View {
|
|
let label: String
|
|
let value: Double
|
|
let icon: String
|
|
let metrics: SystemMetrics
|
|
|
|
var body: some View {
|
|
let color = metrics.statusColor(for: value)
|
|
VStack(spacing: 8) {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color(.systemFill), lineWidth: 6)
|
|
Circle()
|
|
.trim(from: 0, to: value)
|
|
.stroke(color, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.easeOut(duration: 0.6), value: value)
|
|
VStack(spacing: 1) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(color)
|
|
Text("\(Int(value * 100))%")
|
|
.font(.system(.caption, design: .rounded, weight: .bold))
|
|
.foregroundStyle(Color(.label))
|
|
}
|
|
}
|
|
.frame(width: 64, height: 64)
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - SystemMetricsView
|
|
|
|
struct SystemMetricsView: View {
|
|
let metrics: SystemMetrics
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Label("Systemstatus", systemImage: "gauge.with.dots.needle.67percent")
|
|
.font(.headline)
|
|
.foregroundStyle(Color(.label))
|
|
Spacer()
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(metrics.servicesRatio >= 0.9
|
|
? Color(.systemGreen)
|
|
: Color(.systemOrange))
|
|
.frame(width: 8, height: 8)
|
|
Text("\(metrics.servicesHealthy)/\(metrics.servicesTotal) tjänster")
|
|
.font(.caption)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
}
|
|
}
|
|
HStack(spacing: 8) {
|
|
MetricGaugeView(label: "CPU", value: metrics.cpu, icon: "cpu", metrics: metrics)
|
|
MetricGaugeView(label: "RAM", value: metrics.ram, icon: "memorychip", metrics: metrics)
|
|
MetricGaugeView(label: "Disk", value: metrics.disk, icon: "internaldrive", metrics: metrics)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - StatTileView
|
|
|
|
struct StatTileView: View {
|
|
let icon: String
|
|
let value: String
|
|
let label: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(color)
|
|
Spacer()
|
|
Text(value)
|
|
.font(.system(.title2, design: .rounded, weight: .bold))
|
|
.foregroundStyle(Color(.label))
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - DashboardSummaryView
|
|
|
|
struct DashboardSummaryView: View {
|
|
let data: DashboardData
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
StatTileView(
|
|
icon: "person.2.fill",
|
|
value: "\(data.usersCount)",
|
|
label: "Användare",
|
|
color: Color(.systemBlue)
|
|
)
|
|
StatTileView(
|
|
icon: "puzzlepiece.fill",
|
|
value: "\(data.modulesHealthy)/\(data.modulesTotal)",
|
|
label: "Moduler",
|
|
color: data.modulesRatio == 1 ? Color(.systemGreen) : Color(.systemOrange)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Data
|
|
|
|
#if DEBUG
|
|
extension User {
|
|
static let preview = User(
|
|
id: "u1",
|
|
email: "admin@aamos.io",
|
|
name: "Bernt Lind",
|
|
role: .admin,
|
|
createdAt: Date().addingTimeInterval(-86400 * 90),
|
|
lastLogin: Date().addingTimeInterval(-3600)
|
|
)
|
|
|
|
static let previewList: [User] = [
|
|
preview,
|
|
User(id: "u2", email: "ops@aamos.io", name: "Sara Nilsson",
|
|
role: .operator,
|
|
createdAt: Date().addingTimeInterval(-86400 * 30),
|
|
lastLogin: Date().addingTimeInterval(-7200)),
|
|
User(id: "u3", email: "view@aamos.io", name: "Erik Svensson",
|
|
role: .viewer,
|
|
createdAt: Date().addingTimeInterval(-86400 * 7),
|
|
lastLogin: nil)
|
|
]
|
|
}
|
|
|
|
extension Module {
|
|
static let preview = Module(
|
|
id: "m1", name: "AAMOS Core", enabled: true,
|
|
description: "Kärntjänst för agentkörning och orchestration",
|
|
icon: "cpu.fill", status: .healthy
|
|
)
|
|
|
|
static let previewList: [Module] = [
|
|
preview,
|
|
Module(id: "m2", name: "Watchman", enabled: true,
|
|
description: "Realtidsövervakning av byggen och deployments",
|
|
icon: "eye.circle.fill", status: .healthy),
|
|
Module(id: "m3", name: "Mail Relay", enabled: true,
|
|
description: "SMTP-proxy med krypterad vidarebefordran",
|
|
icon: "envelope.fill", status: .degraded),
|
|
Module(id: "m4", name: "Vault", enabled: false,
|
|
description: "Hemlig lagring och nyckelrotation",
|
|
icon: "lock.shield.fill", status: .offline)
|
|
]
|
|
}
|
|
|
|
extension AuditEvent {
|
|
static let previewList: [AuditEvent] = [
|
|
AuditEvent(id: "e1", userId: "u1", action: "USER_LOGIN",
|
|
resource: "/auth/login", ip: "192.168.1.10",
|
|
timestamp: Date().addingTimeInterval(-600), details: nil),
|
|
AuditEvent(id: "e2", userId: "u1", action: "MODULE_TOGGLE",
|
|
resource: "/modules/vault", ip: "192.168.1.10",
|
|
timestamp: Date().addingTimeInterval(-1800),
|
|
details: ["module": "vault", "enabled": "false"]),
|
|
AuditEvent(id: "e3", userId: "u2", action: "CONFIG_UPDATE",
|
|
resource: "/system/config", ip: "10.0.0.5",
|
|
timestamp: Date().addingTimeInterval(-3600), details: nil)
|
|
]
|
|
}
|
|
|
|
extension SystemMetrics {
|
|
static let preview = SystemMetrics(
|
|
cpu: 0.42, ram: 0.67, disk: 0.31,
|
|
servicesHealthy: 17, servicesTotal: 20
|
|
)
|
|
}
|
|
|
|
extension DashboardData {
|
|
static let preview = DashboardData(
|
|
usersCount: 8,
|
|
modulesHealthy: 17,
|
|
modulesTotal: 20,
|
|
recentAudit: AuditEvent.previewList,
|
|
metrics: .preview
|
|
)
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("UserRowView") {
|
|
VStack(spacing: 8) {
|
|
ForEach(User.previewList) { user in
|
|
UserRowView(user: user)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
|
|
#Preview("ModuleCardView") {
|
|
NavigationStack {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(Module.previewList) { module in
|
|
ModuleCardView(module: module)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.navigationTitle("Moduler")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
}
|
|
}
|
|
|
|
#Preview("AuditEventRowView") {
|
|
VStack(spacing: 8) {
|
|
ForEach(AuditEvent.previewList) { event in
|
|
AuditEventRowView(event: event)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
|
|
#Preview("SystemMetricsView") {
|
|
SystemMetricsView(metrics: .preview)
|
|
.padding(16)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
|
|
#Preview("DashboardSummaryView") {
|
|
DashboardSummaryView(data: .preview)
|
|
.padding(16)
|
|
.background(Color(.systemBackground))
|
|
}
|
|
#endif
|