feat(@projects/@cocottetech): ✨ add cockpit and content asset endpoints
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5b324d3b0c
commit
432670469d
6 changed files with 177 additions and 2 deletions
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Read-only cockpit surfaces backed by platform.api:
|
||||||
|
/// - `/specialists` — fleet roster derived from agent_actions
|
||||||
|
/// - `/surface-metrics` — per-surface metric rollup (empty until adapters write rows)
|
||||||
|
extension Endpoint {
|
||||||
|
|
||||||
|
public static func specialists() -> Endpoint {
|
||||||
|
.crudList("specialists")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func surfaceMetrics() -> Endpoint {
|
||||||
|
.crudList("surface-metrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,23 @@ import CocottePlatformModels
|
||||||
/// query filters — add one here only when the backend supports it.)
|
/// query filters — add one here only when the backend supports it.)
|
||||||
extension Endpoint {
|
extension Endpoint {
|
||||||
|
|
||||||
public static func contentAssets() -> Endpoint {
|
/// List assets, newest first. `limit` caps to the most-recent N server-side —
|
||||||
.crudList("content-assets")
|
/// the cockpit library live-polls, so it asks for a bounded slice rather than
|
||||||
|
/// the whole (eventually tens-of-thousands-row) library every few seconds.
|
||||||
|
public static func contentAssets(limit: Int? = nil) -> Endpoint {
|
||||||
|
var query: [String: String] = [:]
|
||||||
|
if let limit { query["limit"] = String(limit) }
|
||||||
|
return .crudList("content-assets", queryParams: query)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func contentAsset(id: UUID) -> Endpoint {
|
public static func contentAsset(id: UUID) -> Endpoint {
|
||||||
.crudGet("content-assets", id: id)
|
.crudGet("content-assets", id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authenticated image proxy — streams the asset's original bytes from MinIO.
|
||||||
|
/// The object key is resolved server-side from the tenant-scoped row; the
|
||||||
|
/// client only supplies the asset id.
|
||||||
|
public static func contentAssetImage(id: UUID) -> Endpoint {
|
||||||
|
Endpoint(path: "/content-assets/\(id.uuidString)/image", method: .get)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,74 @@ extension Endpoint {
|
||||||
public static func contentDrop(id: UUID) -> Endpoint {
|
public static func contentDrop(id: UUID) -> Endpoint {
|
||||||
.crudGet("content-drops", id: id)
|
.crudGet("content-drops", id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a drop. (platform-api `POST content-drops`, via `CrudControllerBase`.)
|
||||||
|
/// Legs are not part of the create payload — they live in `content_drop_legs`
|
||||||
|
/// and are fanned out separately; assets link via `addDropAsset`.
|
||||||
|
public static func createContentDrop(_ body: CreateContentDropBody) -> Endpoint {
|
||||||
|
.crudCreate("content-drops", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link an existing content asset to a drop.
|
||||||
|
/// (platform-api `POST content-drops/:id/assets`.)
|
||||||
|
public static func addDropAsset(dropId: UUID, _ body: AddDropAssetBody) -> Endpoint {
|
||||||
|
Endpoint(path: "/content-drops/\(dropId.uuidString)/assets", method: .post, body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CreateContentDropBody: Encodable, Sendable {
|
||||||
|
public let userId: UUID
|
||||||
|
public let orgId: UUID?
|
||||||
|
public let title: String
|
||||||
|
public let arc: String
|
||||||
|
public let state: ContentDropState
|
||||||
|
public let clusterSource: String
|
||||||
|
public let dropAt: Date
|
||||||
|
public let createdBySpecialist: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case orgId = "org_id"
|
||||||
|
case title
|
||||||
|
case arc
|
||||||
|
case state
|
||||||
|
case clusterSource = "cluster_source"
|
||||||
|
case dropAt = "drop_at"
|
||||||
|
case createdBySpecialist = "created_by_specialist"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
userId: UUID,
|
||||||
|
orgId: UUID?,
|
||||||
|
title: String,
|
||||||
|
arc: String,
|
||||||
|
state: ContentDropState,
|
||||||
|
clusterSource: String,
|
||||||
|
dropAt: Date,
|
||||||
|
createdBySpecialist: String
|
||||||
|
) {
|
||||||
|
self.userId = userId
|
||||||
|
self.orgId = orgId
|
||||||
|
self.title = title
|
||||||
|
self.arc = arc
|
||||||
|
self.state = state
|
||||||
|
self.clusterSource = clusterSource
|
||||||
|
self.dropAt = dropAt
|
||||||
|
self.createdBySpecialist = createdBySpecialist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AddDropAssetBody: Encodable, Sendable {
|
||||||
|
public let assetId: UUID
|
||||||
|
public let sortOrder: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case assetId = "asset_id"
|
||||||
|
case sortOrder = "sort_order"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(assetId: UUID, sortOrder: Int) {
|
||||||
|
self.assetId = assetId
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,14 @@ public final class PlatformAPIClient: Sendable {
|
||||||
_ = try await perform(endpoint, scope: scope)
|
_ = try await perform(endpoint, scope: scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a raw (non-JSON) response body with full auth + tenancy headers —
|
||||||
|
/// e.g. the content-assets image proxy, which streams image bytes, not JSON.
|
||||||
|
/// `AsyncImage(url:)` can't hit these routes (it sends no Authorization /
|
||||||
|
/// `x-user-id`), so the cockpit loads image data through this instead.
|
||||||
|
public func sendData(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
|
||||||
|
try await perform(endpoint, scope: scope)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - transport
|
// MARK: - transport
|
||||||
|
|
||||||
private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
|
private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Fleet-roster row, DERIVED from `agent_actions` (there is no specialist registry
|
||||||
|
/// table). `trust` is mean action confidence (nil if the specialist has no actions);
|
||||||
|
/// `mode` is derived from `auto_executed` over the last week. Operational states with
|
||||||
|
/// no signal (degraded/retired) are never reported.
|
||||||
|
public struct SpecialistSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String { specialistId }
|
||||||
|
public let specialistId: String
|
||||||
|
public let countToday: Int
|
||||||
|
public let trust: Double?
|
||||||
|
public let mode: SpecialistMode
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case specialistId = "specialist_id"
|
||||||
|
case countToday = "count_today"
|
||||||
|
case trust
|
||||||
|
case mode
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(specialistId: String, countToday: Int, trust: Double?, mode: SpecialistMode) {
|
||||||
|
self.specialistId = specialistId
|
||||||
|
self.countToday = countToday
|
||||||
|
self.trust = trust
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derived operating mode. `auto` = acted autonomously this week; `draftOnly` = acted
|
||||||
|
/// but only via approval; `idle` = no recent actions. Decodes leniently (unknown →
|
||||||
|
/// `.idle`) so a new server-side value never breaks the cockpit.
|
||||||
|
public enum SpecialistMode: String, Codable, Sendable {
|
||||||
|
case auto
|
||||||
|
case draftOnly = "draft_only"
|
||||||
|
case idle
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
self = SpecialistMode(rawValue: raw) ?? .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Per-surface metric rollup for the Insights tab. Sourced from `surface_metrics`
|
||||||
|
/// (empty until surface adapters write rows). The kind→field mapping server-side is
|
||||||
|
/// provisional; `posts` has no metric source yet and arrives as 0.
|
||||||
|
public struct SurfaceMetricSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String { surface }
|
||||||
|
public let surface: String
|
||||||
|
public let posts: Int
|
||||||
|
public let impressions: Int
|
||||||
|
public let engagementPct: Double
|
||||||
|
public let conversions: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case surface
|
||||||
|
case posts
|
||||||
|
case impressions
|
||||||
|
case engagementPct = "engagement_pct"
|
||||||
|
case conversions
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(surface: String, posts: Int, impressions: Int, engagementPct: Double, conversions: Int) {
|
||||||
|
self.surface = surface
|
||||||
|
self.posts = posts
|
||||||
|
self.impressions = impressions
|
||||||
|
self.engagementPct = engagementPct
|
||||||
|
self.conversions = conversions
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue