forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
377 lines
15 KiB
Swift
377 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
struct SentinelView: View {
|
|
@State private var store = AppStore.shared
|
|
@State private var analytics: VelocitySentinelLiveAnalyticsDTO?
|
|
@State private var analyticsError: String?
|
|
@State private var isAnalyticsLoading = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
header
|
|
|
|
if let error = store.errorMessage {
|
|
errorBanner(error)
|
|
}
|
|
|
|
availabilityCard
|
|
analyticsCards
|
|
sentimentCard
|
|
journeyCard
|
|
postureCards
|
|
timelineCard
|
|
}
|
|
.padding(24)
|
|
}
|
|
.background(VelocityTheme.background)
|
|
.scrollContentBackground(.hidden)
|
|
.task {
|
|
await store.refresh()
|
|
await loadAnalytics()
|
|
}
|
|
.refreshable {
|
|
await store.refresh()
|
|
await loadAnalytics()
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(SentinelScope.productFamilyName.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.tracking(1.2)
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text(SentinelScope.navigationTitle)
|
|
.font(.system(size: 28, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
}
|
|
|
|
private var availabilityCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Production Scope")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
statusBadge(
|
|
label: SentinelScope.availabilityBadge,
|
|
color: VelocityTheme.success
|
|
)
|
|
}
|
|
|
|
Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
|
|
Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
|
|
if let analyticsError {
|
|
Text(analyticsError)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.danger)
|
|
} else if isAnalyticsLoading {
|
|
Text("Loading live perception analytics...")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
} else if let analytics {
|
|
Text("Stream: \(analytics.liveStreamPath)")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
private var analyticsCards: some View {
|
|
HStack(spacing: 14) {
|
|
SentinelCard(
|
|
title: "Active visitors",
|
|
value: "\(analytics?.activeSessions ?? 0)",
|
|
subtitle: "Open Sentinel perception sessions",
|
|
color: VelocityTheme.accent
|
|
)
|
|
SentinelCard(
|
|
title: "Visitors 24h",
|
|
value: "\(analytics?.visitorCount24h ?? 0)",
|
|
subtitle: "Sessions started in the last day",
|
|
color: VelocityTheme.success
|
|
)
|
|
SentinelCard(
|
|
title: "Avg QD",
|
|
value: String(format: "%.0f", analytics?.averageQdScore ?? 0),
|
|
subtitle: "Average finalized session score",
|
|
color: VelocityTheme.warning
|
|
)
|
|
}
|
|
}
|
|
|
|
private var sentimentCard: some View {
|
|
let distribution = analytics?.sentimentDistribution ?? [:]
|
|
return VStack(alignment: .leading, spacing: 14) {
|
|
Text("Sentiment Distribution")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
HStack(spacing: 12) {
|
|
sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success)
|
|
sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent)
|
|
sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
private var journeyCard: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
Text("Showroom Journey")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
Text("Live feed")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.success)
|
|
}
|
|
|
|
if analytics?.journey.isEmpty ?? true {
|
|
Text("No perception journey events have been persisted yet.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
} else {
|
|
ForEach(analytics?.journey.prefix(8) ?? []) { event in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
Text(String(format: "%.0f%%", event.engagementScore * 100))
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent)
|
|
}
|
|
Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text(event.happenedAt ?? "Timestamp pending")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
private var postureCards: some View {
|
|
HStack(spacing: 14) {
|
|
SentinelCard(
|
|
title: "Pending insights",
|
|
value: "\(store.metrics.pendingInsights)",
|
|
subtitle: "Recommendations waiting on operator review",
|
|
color: VelocityTheme.danger
|
|
)
|
|
SentinelCard(
|
|
title: "Transcript queue",
|
|
value: "\(store.metrics.pendingTranscriptions)",
|
|
subtitle: "Imported recordings still processing",
|
|
color: VelocityTheme.warning
|
|
)
|
|
SentinelCard(
|
|
title: "Upcoming 24h",
|
|
value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)",
|
|
subtitle: "Calendar events due soon",
|
|
color: VelocityTheme.success
|
|
)
|
|
}
|
|
}
|
|
|
|
private var timelineCard: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
Text("Recent Operator Timeline")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
if let lastRefresh = store.lastRefreshAt {
|
|
Text("Updated \(lastRefresh.relativeShort)")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
}
|
|
|
|
if store.timelineEvents.isEmpty {
|
|
Text("No live communication events have been loaded for the current high-priority leads yet.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
} else {
|
|
ForEach(store.timelineEvents.prefix(6)) { item in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(item.leadName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Spacer()
|
|
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(VelocityTheme.accent)
|
|
}
|
|
Text(item.event.summary ?? "No summary available for this event.")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.glassCard(cornerRadius: 18)
|
|
}
|
|
|
|
private func statusBadge(label: String, color: Color) -> some View {
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(color.opacity(0.12))
|
|
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
|
|
)
|
|
}
|
|
|
|
private func errorBanner(_ message: String) -> some View {
|
|
Text(message)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(VelocityTheme.danger)
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.danger.opacity(0.10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(label.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.tracking(1)
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text("\(value)")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(VelocityTheme.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(color.opacity(0.22), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func loadAnalytics(silent: Bool = false) async {
|
|
if !silent {
|
|
await MainActor.run {
|
|
isAnalyticsLoading = true
|
|
analyticsError = nil
|
|
}
|
|
}
|
|
do {
|
|
let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics()
|
|
await MainActor.run {
|
|
analytics = response
|
|
analyticsError = nil
|
|
isAnalyticsLoading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
|
|
analyticsError = "Sentinel analytics is not available on the configured backend yet."
|
|
} else {
|
|
analyticsError = error.localizedDescription
|
|
}
|
|
isAnalyticsLoading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SentinelCard: View {
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.tracking(1)
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
Text(value)
|
|
.font(.system(size: 26, weight: .bold))
|
|
.foregroundStyle(VelocityTheme.foreground)
|
|
Text(subtitle)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(VelocityTheme.mutedFg)
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(color)
|
|
.frame(width: 48, height: 4)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.glassCard(cornerRadius: 16)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SentinelView()
|
|
}
|
|
|
|
private extension String {
|
|
var trimmedNonEmpty: String? {
|
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|