feat: complete manager agnet callback timeline
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
type agnetCallbackEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
EventType string `json:"event_type"`
|
||||
DeploymentID string `json:"deployment_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
Source string `json:"source"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Artifact agnetArtifactPayload `json:"artifact"`
|
||||
}
|
||||
|
||||
type agnetArtifactPayload struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
URI string `json:"uri"`
|
||||
Checksum string `json:"checksum"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
func agnetCallbackTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Agnet-Service-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
auth := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
return strings.TrimSpace(auth[len("Bearer "):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateAgnetCallbackAuth(c *gin.Context) bool {
|
||||
if common.GetEnvOrDefaultBool("AGNET_CALLBACK_AUTH_DISABLED", false) {
|
||||
return true
|
||||
}
|
||||
expected := strings.TrimSpace(os.Getenv("AGNET_CALLBACK_TOKEN"))
|
||||
if expected == "" {
|
||||
agnetError(c, "CALLBACK_UNAUTHORIZED", "callback token is not configured")
|
||||
return false
|
||||
}
|
||||
if agnetCallbackTokenFromRequest(c) != expected {
|
||||
agnetError(c, "CALLBACK_UNAUTHORIZED", "invalid callback service token")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func agnetCallbackHasPlaintextSecret(payload agnetCallbackEnvelope) bool {
|
||||
if containsPlaintextSecret(payload.Metadata) || containsPlaintextSecret(payload.Artifact.Metadata) {
|
||||
return true
|
||||
}
|
||||
raw, _ := common.Marshal(payload)
|
||||
var asMap map[string]any
|
||||
if err := common.Unmarshal(raw, &asMap); err != nil {
|
||||
return false
|
||||
}
|
||||
return containsPlaintextSecret(asMap)
|
||||
}
|
||||
|
||||
func agnetCallbackDeploymentContext(deploymentID string) (agnetDeploymentRecord, bool) {
|
||||
if deploymentID == "" {
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
return findAgnetDeploymentRecord(deploymentID)
|
||||
}
|
||||
|
||||
func persistAgnetArtifactFromCallback(payload agnetCallbackEnvelope, record agnetDeploymentRecord) error {
|
||||
artifact := payload.Artifact
|
||||
if strings.TrimSpace(artifact.ArtifactID) == "" {
|
||||
return nil
|
||||
}
|
||||
metadataJSON := ""
|
||||
if artifact.Metadata != nil {
|
||||
if data, err := common.Marshal(artifact.Metadata); err == nil {
|
||||
metadataJSON = string(data)
|
||||
}
|
||||
}
|
||||
return model.UpsertAgnetArtifact(&model.AgnetArtifact{
|
||||
ArtifactID: strings.TrimSpace(artifact.ArtifactID),
|
||||
DeploymentID: strings.TrimSpace(payload.DeploymentID),
|
||||
TaskID: strings.TrimSpace(payload.TaskID),
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
CorrelationID: strings.TrimSpace(payload.CorrelationID),
|
||||
ArtifactType: strings.TrimSpace(artifact.ArtifactType),
|
||||
Title: strings.TrimSpace(artifact.Title),
|
||||
Summary: strings.TrimSpace(artifact.Summary),
|
||||
URI: strings.TrimSpace(artifact.URI),
|
||||
Checksum: strings.TrimSpace(artifact.Checksum),
|
||||
MetadataJSON: metadataJSON,
|
||||
CreatedAtMs: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
func AgnetReceiveSwarmEventCallback(c *gin.Context) {
|
||||
if !validateAgnetCallbackAuth(c) {
|
||||
return
|
||||
}
|
||||
var payload agnetCallbackEnvelope
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
agnetError(c, "CALLBACK_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
payload.EventID = strings.TrimSpace(payload.EventID)
|
||||
payload.EventType = strings.TrimSpace(payload.EventType)
|
||||
if payload.EventID == "" || payload.EventType == "" {
|
||||
agnetError(c, "CALLBACK_INVALID", "event_id and event_type are required")
|
||||
return
|
||||
}
|
||||
if payload.IdempotencyKey == "" {
|
||||
payload.IdempotencyKey = payload.EventID
|
||||
}
|
||||
if agnetCallbackHasPlaintextSecret(payload) {
|
||||
agnetError(c, "CALLBACK_SECRET_REJECTED", "callbacks must not contain plaintext credential fields")
|
||||
return
|
||||
}
|
||||
|
||||
record, _ := agnetCallbackDeploymentContext(strings.TrimSpace(payload.DeploymentID))
|
||||
if payload.CorrelationID == "" {
|
||||
payload.CorrelationID = record.Plan.Metadata.CorrelationID
|
||||
}
|
||||
payloadJSON, _ := common.Marshal(payload)
|
||||
inserted, err := model.InsertAgnetCallbackEvent(&model.AgnetCallbackEvent{
|
||||
EventID: payload.EventID,
|
||||
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
|
||||
CallbackType: "swarm-event",
|
||||
EventType: payload.EventType,
|
||||
DeploymentID: strings.TrimSpace(payload.DeploymentID),
|
||||
TaskID: strings.TrimSpace(payload.TaskID),
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
CorrelationID: strings.TrimSpace(payload.CorrelationID),
|
||||
Source: strings.TrimSpace(payload.Source),
|
||||
Result: "ok",
|
||||
PayloadJSON: string(payloadJSON),
|
||||
CreatedAtMs: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
common.SysLog("AgnetReceiveSwarmEventCallback: " + err.Error())
|
||||
agnetError(c, "CALLBACK_PERSIST_FAILED", "failed to persist callback")
|
||||
return
|
||||
}
|
||||
if inserted {
|
||||
if err := persistAgnetArtifactFromCallback(payload, record); err != nil {
|
||||
common.SysLog("persistAgnetArtifactFromCallback: " + err.Error())
|
||||
agnetError(c, "CALLBACK_PERSIST_FAILED", "failed to persist artifact")
|
||||
return
|
||||
}
|
||||
recordAgnetAuditEvent(agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "callback." + payload.EventType,
|
||||
SchemaVersion: 1,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
ChannelID: record.Plan.UserContext.ChannelID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
DeploymentID: strings.TrimSpace(payload.DeploymentID),
|
||||
CorrelationID: strings.TrimSpace(payload.CorrelationID),
|
||||
OccurredAt: agnetNow(),
|
||||
}, "agnet_callback", strings.TrimSpace(payload.DeploymentID), agnetRequestID(c), "ok")
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"event_id": payload.EventID,
|
||||
"inserted": inserted,
|
||||
"idempotent": !inserted,
|
||||
})
|
||||
}
|
||||
|
||||
func AgnetListUserDeploymentArtifacts(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgnetDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := model.ListAgnetArtifacts(model.ListAgnetArtifactsFilter{
|
||||
DeploymentID: record.DeploymentID,
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
common.SysLog("AgnetListUserDeploymentArtifacts: " + err.Error())
|
||||
agnetError(c, "ARTIFACT_QUERY_FAILED", "failed to query artifacts")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func AgnetGetUserDeploymentTimeline(c *gin.Context) {
|
||||
record, ok := requireAuthenticatedUserAgnetDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := model.ListAgnetAuditEventsByDeployment(record.DeploymentID)
|
||||
if err != nil {
|
||||
agnetError(c, "TIMELINE_QUERY_FAILED", "failed to query audit events")
|
||||
return
|
||||
}
|
||||
callbacks, err := model.ListAgnetCallbackEvents(model.ListAgnetCallbackEventsFilter{DeploymentID: record.DeploymentID, Limit: 500})
|
||||
if err != nil {
|
||||
agnetError(c, "TIMELINE_QUERY_FAILED", "failed to query callbacks")
|
||||
return
|
||||
}
|
||||
artifacts, err := model.ListAgnetArtifacts(model.ListAgnetArtifactsFilter{DeploymentID: record.DeploymentID, Limit: 500})
|
||||
if err != nil {
|
||||
agnetError(c, "TIMELINE_QUERY_FAILED", "failed to query artifacts")
|
||||
return
|
||||
}
|
||||
snapshots, err := model.ListAgnetSKSnapshots(record.DeploymentID)
|
||||
if err != nil {
|
||||
agnetError(c, "TIMELINE_QUERY_FAILED", "failed to query sk snapshots")
|
||||
return
|
||||
}
|
||||
timeline := make([]gin.H, 0, len(events)+len(callbacks)+len(artifacts)+len(snapshots))
|
||||
for _, event := range events {
|
||||
timeline = append(timeline, gin.H{"kind": "audit", "at": event.OccurredAt, "event": event.Event})
|
||||
}
|
||||
for _, callback := range callbacks {
|
||||
timeline = append(timeline, gin.H{"kind": "callback", "at": callback.CreatedAtMs, "event": callback.EventType})
|
||||
}
|
||||
for _, artifact := range artifacts {
|
||||
timeline = append(timeline, gin.H{"kind": "artifact", "at": artifact.CreatedAtMs, "event": artifact.ArtifactType, "artifact_id": artifact.ArtifactID})
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
timeline = append(timeline, gin.H{"kind": "sk_snapshot", "at": snapshot.ResolvedAtMs, "event": snapshot.SourceType, "snapshot_id": snapshot.SnapshotID})
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment": record,
|
||||
"events": events,
|
||||
"callbacks": callbacks,
|
||||
"artifacts": artifacts,
|
||||
"sk_snapshots": snapshots,
|
||||
"timeline": timeline,
|
||||
})
|
||||
}
|
||||
@@ -973,22 +973,22 @@ func requireAuthenticatedUserAgnetDeployment(c *gin.Context) (agnetDeploymentRec
|
||||
return record, true
|
||||
}
|
||||
|
||||
func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
|
||||
func createAgnetDeploymentRecord(c *gin.Context, enforceUserScope bool) (agnetDeploymentRecord, bool) {
|
||||
var req agnetDeploymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
agnetError(c, "POLICY_REJECTED", err.Error())
|
||||
return
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
plan := req.Plan
|
||||
if enforceUserScope {
|
||||
if !enforceAuthenticatedAgnetUserContext(c, &plan) {
|
||||
return
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
} else {
|
||||
applyAuthenticatedManagerUserContext(c, &plan)
|
||||
}
|
||||
if !validateOrchestrationPlan(c, plan) {
|
||||
return
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
plan.SubMode = normalizeAgnetSubMode(plan.SubMode)
|
||||
|
||||
@@ -1022,15 +1022,18 @@ func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
|
||||
if err := persistAgnetDeploymentRecord(record, agnetDeploymentRequest{Plan: plan}); err != nil {
|
||||
common.SysLog("AgnetCreateDeployment persist: " + err.Error())
|
||||
agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agnet deployment")
|
||||
return
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
agnetMu.Lock()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(event, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
return record, true
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
func writeAgnetDeploymentCreateSuccess(c *gin.Context, record agnetDeploymentRecord, extra gin.H) {
|
||||
data := gin.H{
|
||||
"deployment_id": record.DeploymentID,
|
||||
"sub_mode": record.SubMode,
|
||||
"status": record.Status,
|
||||
"phase": record.Phase,
|
||||
@@ -1038,6 +1041,30 @@ func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
|
||||
"failure_reason": record.FailureReason,
|
||||
"agent_instances": record.AgentInstances,
|
||||
"permission_manifest": record.ResourceGrantManifest,
|
||||
}
|
||||
for key, value := range extra {
|
||||
data[key] = value
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
|
||||
record, ok := createAgnetDeploymentRecord(c, enforceUserScope)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAgnetDeploymentCreateSuccess(c, record, nil)
|
||||
}
|
||||
|
||||
func AgnetCreateUserSwarm(c *gin.Context) {
|
||||
record, ok := createAgnetDeploymentRecord(c, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAgnetDeploymentCreateSuccess(c, record, gin.H{
|
||||
"swarm_id": record.DeploymentID,
|
||||
"adapter": "manager-local-control-plane",
|
||||
"source": "api_swarms_adapter",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1440,6 +1467,24 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
agnetMu.Lock()
|
||||
agnetSnapshots[deploymentID] = snapshots
|
||||
agnetMu.Unlock()
|
||||
rows := make([]model.AgnetSKSnapshot, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
rows = append(rows, model.AgnetSKSnapshot{
|
||||
SnapshotID: snapshot.SnapshotID,
|
||||
DeploymentID: snapshot.DeploymentID,
|
||||
UserID: snapshot.UserID,
|
||||
BindingScope: snapshot.BindingScope,
|
||||
SourceType: snapshot.SourceType,
|
||||
SourceRef: snapshot.SourceRef,
|
||||
ResolvedAt: snapshot.ResolvedAt,
|
||||
ResolvedAtMs: agnetTimestampMs(snapshot.ResolvedAt),
|
||||
})
|
||||
}
|
||||
if err := model.InsertAgnetSKSnapshots(rows); err != nil {
|
||||
common.SysLog("AgnetResolveSKSnapshots persist: " + err.Error())
|
||||
agnetError(c, "SK_SNAPSHOT_PERSIST_FAILED", "failed to persist sk snapshots")
|
||||
return
|
||||
}
|
||||
snapEvent := agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "sk_snapshot_refreshed",
|
||||
@@ -1469,6 +1514,26 @@ func AgnetListSKSnapshots(c *gin.Context) {
|
||||
agnetMu.RLock()
|
||||
items := agnetSnapshots[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
if len(items) == 0 {
|
||||
rows, err := model.ListAgnetSKSnapshots(deploymentID)
|
||||
if err != nil {
|
||||
common.SysLog("AgnetListSKSnapshots: " + err.Error())
|
||||
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "failed to query sk snapshots")
|
||||
return
|
||||
}
|
||||
items = make([]agnetSKSnapshot, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, agnetSKSnapshot{
|
||||
SnapshotID: row.SnapshotID,
|
||||
DeploymentID: row.DeploymentID,
|
||||
UserID: row.UserID,
|
||||
BindingScope: row.BindingScope,
|
||||
SourceType: row.SourceType,
|
||||
SourceRef: row.SourceRef,
|
||||
ResolvedAt: row.ResolvedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
"items": items,
|
||||
@@ -1476,6 +1541,13 @@ func AgnetListSKSnapshots(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func AgnetListUserSKSnapshots(c *gin.Context) {
|
||||
if _, ok := requireAuthenticatedUserAgnetDeployment(c); !ok {
|
||||
return
|
||||
}
|
||||
AgnetListSKSnapshots(c)
|
||||
}
|
||||
|
||||
func AgnetListAuditLogs(c *gin.Context) {
|
||||
// Persistent audit query — survives container restarts. Filters
|
||||
// are optional; the dashboard call usually narrows by user_id +
|
||||
|
||||
@@ -56,6 +56,9 @@ func setupAgnetControlPlaneTestDB(t *testing.T) *gorm.DB {
|
||||
&model.ResourceGrant{},
|
||||
&model.AgnetAuditEvent{},
|
||||
&model.AgnetDeployment{},
|
||||
&model.AgnetCallbackEvent{},
|
||||
&model.AgnetArtifact{},
|
||||
&model.AgnetSKSnapshot{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
if model.DB == db {
|
||||
@@ -554,6 +557,190 @@ func TestAgnetUserDeploymentSimulationRejectsOtherUsersDeployment(t *testing.T)
|
||||
require.Equal(t, "DEPLOYMENT_FORBIDDEN", envelope.Error.Code)
|
||||
}
|
||||
|
||||
func TestAgnetUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
plan := baseAgnetResourceGrantPlan()
|
||||
plan.UserContext.UserID = ""
|
||||
for idx := range plan.Agents[0].ResourceGrants {
|
||||
plan.Agents[0].ResourceGrants[idx].UserID = ""
|
||||
}
|
||||
body, err := common.Marshal(agnetDeploymentRequest{Plan: plan})
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Set("id", 7)
|
||||
ctx.Set("group", "development")
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/swarms", strings.NewReader(string(body)))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
AgnetCreateUserSwarm(ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"swarm_id"`)
|
||||
require.Contains(t, recorder.Body.String(), `"deployment_id"`)
|
||||
require.Contains(t, recorder.Body.String(), `"user_id":"7"`)
|
||||
}
|
||||
|
||||
func TestAgnetCallbackStoresEventArtifactAndIsIdempotent(t *testing.T) {
|
||||
db := setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
t.Setenv("AGNET_CALLBACK_TOKEN", "callback-token")
|
||||
|
||||
plan := baseAgnetResourceGrantPlan()
|
||||
plan.UserContext.UserID = "7"
|
||||
for idx := range plan.Agents[0].ResourceGrants {
|
||||
plan.Agents[0].ResourceGrants[idx].UserID = "7"
|
||||
}
|
||||
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
|
||||
require.True(t, createEnvelope.Success)
|
||||
var createBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
|
||||
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
|
||||
|
||||
body := fmt.Sprintf(`{
|
||||
"event_id":"evt-callback-1",
|
||||
"idempotency_key":"idem-callback-1",
|
||||
"event_type":"artifact.created",
|
||||
"deployment_id":%q,
|
||||
"task_id":"task-1",
|
||||
"correlation_id":"corr-p1-resource-grant",
|
||||
"artifact":{
|
||||
"artifact_id":"art-1",
|
||||
"artifact_type":"summary",
|
||||
"title":"Build summary",
|
||||
"summary":"Created backend scaffold",
|
||||
"uri":"artifact://task-1/summary"
|
||||
}
|
||||
}`, deploymentID)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(body))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
ctx.Request.Header.Set("X-Agnet-Service-Token", "callback-token")
|
||||
AgnetReceiveSwarmEventCallback(ctx)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"success":true`)
|
||||
}
|
||||
|
||||
var callbackCount int64
|
||||
require.NoError(t, db.Model(&model.AgnetCallbackEvent{}).Where("event_id = ?", "evt-callback-1").Count(&callbackCount).Error)
|
||||
require.Equal(t, int64(1), callbackCount)
|
||||
var artifactCount int64
|
||||
require.NoError(t, db.Model(&model.AgnetArtifact{}).Where("artifact_id = ?", "art-1").Count(&artifactCount).Error)
|
||||
require.Equal(t, int64(1), artifactCount)
|
||||
}
|
||||
|
||||
func TestAgnetCallbackRejectsPlaintextSecretsAndMissingToken(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
t.Setenv("AGNET_CALLBACK_TOKEN", "callback-token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(`{"event_id":"evt-no-token","event_type":"status.updated"}`))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
AgnetReceiveSwarmEventCallback(ctx)
|
||||
var missingToken agnetCreateTestEnvelope
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &missingToken))
|
||||
require.False(t, missingToken.Success)
|
||||
require.Equal(t, "CALLBACK_UNAUTHORIZED", missingToken.Error.Code)
|
||||
|
||||
secretRecorder := httptest.NewRecorder()
|
||||
secretCtx, _ := gin.CreateTestContext(secretRecorder)
|
||||
secretCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(`{
|
||||
"event_id":"evt-secret",
|
||||
"event_type":"status.updated",
|
||||
"metadata":{"access_token":"must-not-store"}
|
||||
}`))
|
||||
secretCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
secretCtx.Request.Header.Set("X-Agnet-Service-Token", "callback-token")
|
||||
AgnetReceiveSwarmEventCallback(secretCtx)
|
||||
var secretEnvelope agnetCreateTestEnvelope
|
||||
require.NoError(t, common.Unmarshal(secretRecorder.Body.Bytes(), &secretEnvelope))
|
||||
require.False(t, secretEnvelope.Success)
|
||||
require.Equal(t, "CALLBACK_SECRET_REJECTED", secretEnvelope.Error.Code)
|
||||
}
|
||||
|
||||
func TestAgnetArtifactsAndTimelineReturnPersistedRelatedRecords(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
t.Setenv("AGNET_CALLBACK_TOKEN", "callback-token")
|
||||
|
||||
plan := baseAgnetResourceGrantPlan()
|
||||
plan.UserContext.UserID = "7"
|
||||
plan.SubMode = "waterfall"
|
||||
for idx := range plan.Agents[0].ResourceGrants {
|
||||
plan.Agents[0].ResourceGrants[idx].UserID = "7"
|
||||
}
|
||||
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
|
||||
require.True(t, createEnvelope.Success)
|
||||
var createBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
|
||||
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
|
||||
|
||||
callbackBody := fmt.Sprintf(`{
|
||||
"event_id":"evt-artifact-timeline",
|
||||
"idempotency_key":"idem-artifact-timeline",
|
||||
"event_type":"artifact.created",
|
||||
"deployment_id":%q,
|
||||
"task_id":"task-timeline",
|
||||
"correlation_id":"corr-p1-resource-grant",
|
||||
"artifact":{"artifact_id":"art-timeline","artifact_type":"summary","title":"Timeline artifact","summary":"Done","uri":"artifact://task-timeline/summary"}
|
||||
}`, deploymentID)
|
||||
callbackRecorder := httptest.NewRecorder()
|
||||
callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
|
||||
callbackCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(callbackBody))
|
||||
callbackCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
callbackCtx.Request.Header.Set("X-Agnet-Service-Token", "callback-token")
|
||||
AgnetReceiveSwarmEventCallback(callbackCtx)
|
||||
require.Contains(t, callbackRecorder.Body.String(), `"success":true`)
|
||||
|
||||
snapshotRecorder := httptest.NewRecorder()
|
||||
snapshotCtx, _ := gin.CreateTestContext(snapshotRecorder)
|
||||
snapshotCtx.Set("id", 7)
|
||||
snapshotCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/sk-snapshots/resolve", strings.NewReader(fmt.Sprintf(`{"deployment_id":%q}`, deploymentID)))
|
||||
snapshotCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
AgnetResolveSKSnapshots(snapshotCtx)
|
||||
require.Equal(t, http.StatusOK, snapshotRecorder.Code)
|
||||
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
artifactRecorder := httptest.NewRecorder()
|
||||
artifactCtx, _ := gin.CreateTestContext(artifactRecorder)
|
||||
artifactCtx.Set("id", 7)
|
||||
artifactCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
artifactCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/artifacts", nil)
|
||||
AgnetListUserDeploymentArtifacts(artifactCtx)
|
||||
require.Equal(t, http.StatusOK, artifactRecorder.Code)
|
||||
require.Contains(t, artifactRecorder.Body.String(), `"art-timeline"`)
|
||||
|
||||
snapshotListRecorder := httptest.NewRecorder()
|
||||
snapshotListCtx, _ := gin.CreateTestContext(snapshotListRecorder)
|
||||
snapshotListCtx.Set("id", 7)
|
||||
snapshotListCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
snapshotListCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/sk-snapshots", nil)
|
||||
AgnetListUserSKSnapshots(snapshotListCtx)
|
||||
require.Equal(t, http.StatusOK, snapshotListRecorder.Code)
|
||||
require.Contains(t, snapshotListRecorder.Body.String(), `"deployment_id":"`+deploymentID+`"`)
|
||||
|
||||
timelineRecorder := httptest.NewRecorder()
|
||||
timelineCtx, _ := gin.CreateTestContext(timelineRecorder)
|
||||
timelineCtx.Set("id", 7)
|
||||
timelineCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
timelineCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/timeline", nil)
|
||||
AgnetGetUserDeploymentTimeline(timelineCtx)
|
||||
require.Equal(t, http.StatusOK, timelineRecorder.Code)
|
||||
require.Contains(t, timelineRecorder.Body.String(), `"callbacks"`)
|
||||
require.Contains(t, timelineRecorder.Body.String(), `"artifacts"`)
|
||||
require.Contains(t, timelineRecorder.Body.String(), `"sk_snapshots"`)
|
||||
require.Contains(t, timelineRecorder.Body.String(), `"timeline"`)
|
||||
}
|
||||
|
||||
func TestAgnetStopDeploymentPersistsState(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
type AgnetArtifact struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
ArtifactID string `gorm:"type:varchar(128);uniqueIndex" json:"artifact_id"`
|
||||
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
|
||||
TaskID string `gorm:"type:varchar(128);index" json:"task_id"`
|
||||
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
|
||||
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
|
||||
ArtifactType string `gorm:"type:varchar(64);index" json:"artifact_type"`
|
||||
Title string `gorm:"type:varchar(256)" json:"title"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
URI string `gorm:"type:varchar(1024)" json:"uri"`
|
||||
Checksum string `gorm:"type:varchar(128)" json:"checksum"`
|
||||
MetadataJSON string `gorm:"type:text" json:"metadata_json"`
|
||||
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"`
|
||||
}
|
||||
|
||||
func (AgnetArtifact) TableName() string {
|
||||
return "agnet_artifacts"
|
||||
}
|
||||
|
||||
type ListAgnetArtifactsFilter struct {
|
||||
DeploymentID string
|
||||
TaskID string
|
||||
CorrelationID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func UpsertAgnetArtifact(row *AgnetArtifact) error {
|
||||
if DB == nil || row == nil {
|
||||
return nil
|
||||
}
|
||||
var existing AgnetArtifact
|
||||
if row.ArtifactID != "" {
|
||||
if err := DB.Where("artifact_id = ?", row.ArtifactID).First(&existing).Error; err == nil {
|
||||
row.Id = existing.Id
|
||||
return DB.Model(&existing).Updates(row).Error
|
||||
}
|
||||
}
|
||||
return DB.Create(row).Error
|
||||
}
|
||||
|
||||
func ListAgnetArtifacts(f ListAgnetArtifactsFilter) ([]AgnetArtifact, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
q := DB.Model(&AgnetArtifact{})
|
||||
if f.DeploymentID != "" {
|
||||
q = q.Where("deployment_id = ?", f.DeploymentID)
|
||||
}
|
||||
if f.TaskID != "" {
|
||||
q = q.Where("task_id = ?", f.TaskID)
|
||||
}
|
||||
if f.CorrelationID != "" {
|
||||
q = q.Where("correlation_id = ?", f.CorrelationID)
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var items []AgnetArtifact
|
||||
err := q.Order("created_at_ms asc, id asc").Limit(limit).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
type AgnetCallbackEvent struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
EventID string `gorm:"type:varchar(128);uniqueIndex" json:"event_id"`
|
||||
IdempotencyKey string `gorm:"type:varchar(128);index" json:"idempotency_key"`
|
||||
CallbackType string `gorm:"type:varchar(64);index" json:"callback_type"`
|
||||
EventType string `gorm:"type:varchar(64);index" json:"event_type"`
|
||||
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
|
||||
TaskID string `gorm:"type:varchar(128);index" json:"task_id"`
|
||||
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
|
||||
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
|
||||
Source string `gorm:"type:varchar(64)" json:"source"`
|
||||
Result string `gorm:"type:varchar(32)" json:"result"`
|
||||
PayloadJSON string `gorm:"type:text" json:"payload_json"`
|
||||
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"`
|
||||
}
|
||||
|
||||
func (AgnetCallbackEvent) TableName() string {
|
||||
return "agnet_callback_events"
|
||||
}
|
||||
|
||||
type ListAgnetCallbackEventsFilter struct {
|
||||
DeploymentID string
|
||||
TaskID string
|
||||
CorrelationID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func InsertAgnetCallbackEvent(row *AgnetCallbackEvent) (bool, error) {
|
||||
if DB == nil || row == nil {
|
||||
return false, nil
|
||||
}
|
||||
if row.EventID == "" {
|
||||
return false, errors.New("event_id is required")
|
||||
}
|
||||
var existing AgnetCallbackEvent
|
||||
if err := DB.Where("event_id = ?", row.EventID).First(&existing).Error; err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if row.IdempotencyKey != "" {
|
||||
if err := DB.Where("idempotency_key = ?", row.IdempotencyKey).First(&existing).Error; err == nil {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := DB.Create(row).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ListAgnetCallbackEvents(f ListAgnetCallbackEventsFilter) ([]AgnetCallbackEvent, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
q := DB.Model(&AgnetCallbackEvent{})
|
||||
if f.DeploymentID != "" {
|
||||
q = q.Where("deployment_id = ?", f.DeploymentID)
|
||||
}
|
||||
if f.TaskID != "" {
|
||||
q = q.Where("task_id = ?", f.TaskID)
|
||||
}
|
||||
if f.CorrelationID != "" {
|
||||
q = q.Where("correlation_id = ?", f.CorrelationID)
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var items []AgnetCallbackEvent
|
||||
err := q.Order("created_at_ms asc, id asc").Limit(limit).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
type AgnetSKSnapshot struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
SnapshotID string `gorm:"type:varchar(128);uniqueIndex" json:"snapshot_id"`
|
||||
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
|
||||
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
|
||||
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
|
||||
SourceType string `gorm:"type:varchar(64)" json:"source_type"`
|
||||
SourceRef string `gorm:"type:varchar(1024)" json:"source_ref"`
|
||||
ResolvedAt string `gorm:"type:varchar(32)" json:"resolved_at"`
|
||||
ResolvedAtMs int64 `gorm:"bigint;index" json:"resolved_at_ms"`
|
||||
}
|
||||
|
||||
func (AgnetSKSnapshot) TableName() string {
|
||||
return "agnet_sk_snapshots"
|
||||
}
|
||||
|
||||
func InsertAgnetSKSnapshots(items []AgnetSKSnapshot) error {
|
||||
if DB == nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return DB.Create(&items).Error
|
||||
}
|
||||
|
||||
func ListAgnetSKSnapshots(deploymentID string) ([]AgnetSKSnapshot, error) {
|
||||
if DB == nil || deploymentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var items []AgnetSKSnapshot
|
||||
err := DB.Where("deployment_id = ?", deploymentID).
|
||||
Order("resolved_at_ms asc, id asc").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -286,6 +286,9 @@ func migrateDB() error {
|
||||
&AgnetApprovalRequest{},
|
||||
&AgnetCredentialLease{},
|
||||
&AgnetDeployment{},
|
||||
&AgnetCallbackEvent{},
|
||||
&AgnetArtifact{},
|
||||
&AgnetSKSnapshot{},
|
||||
// V2 device-binding: X25519 keypair the Manager uses for ECDH
|
||||
// body decryption. See model/server_key.go.
|
||||
&ServerKey{},
|
||||
@@ -355,6 +358,9 @@ func migrateDBFast() error {
|
||||
{&AgnetApprovalRequest{}, "AgnetApprovalRequest"},
|
||||
{&AgnetCredentialLease{}, "AgnetCredentialLease"},
|
||||
{&AgnetDeployment{}, "AgnetDeployment"},
|
||||
{&AgnetCallbackEvent{}, "AgnetCallbackEvent"},
|
||||
{&AgnetArtifact{}, "AgnetArtifact"},
|
||||
{&AgnetSKSnapshot{}, "AgnetSKSnapshot"},
|
||||
{&AgnetAuditEvent{}, "AgnetAuditEvent"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
|
||||
@@ -59,6 +59,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
|
||||
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
|
||||
apiRouter.POST("/agnet/callbacks/swarm-events", controller.AgnetReceiveSwarmEventCallback)
|
||||
apiRouter.POST("/swarms", middleware.UserAuth(), controller.AgnetCreateUserSwarm)
|
||||
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
|
||||
|
||||
// Universal secure verification routes
|
||||
@@ -508,6 +510,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
agnetApprovalRoute.GET("/user/deployments/:deployment_id/metrics", controller.AgnetGetUserDeploymentMetrics)
|
||||
agnetApprovalRoute.GET("/user/deployments/:deployment_id/events", controller.AgnetListUserDeploymentEvents)
|
||||
agnetApprovalRoute.POST("/user/deployments/:deployment_id/simulate-events", controller.AgnetSimulateUserDeploymentEvents)
|
||||
agnetApprovalRoute.GET("/user/deployments/:deployment_id/artifacts", controller.AgnetListUserDeploymentArtifacts)
|
||||
agnetApprovalRoute.GET("/user/deployments/:deployment_id/sk-snapshots", controller.AgnetListUserSKSnapshots)
|
||||
agnetApprovalRoute.GET("/user/deployments/:deployment_id/timeline", controller.AgnetGetUserDeploymentTimeline)
|
||||
agnetApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgnetCreateTaskDeploymentDraft)
|
||||
}
|
||||
|
||||
|
||||
@@ -324,6 +324,39 @@ export async function getAgnetDeploymentEvents(deploymentId: string) {
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function getAgnetDeploymentArtifacts(deploymentId: string) {
|
||||
const res = await api.get<
|
||||
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
|
||||
>(`/api/agnet/user/deployments/${deploymentId}/artifacts`)
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function getAgnetDeploymentSKSnapshots(deploymentId: string) {
|
||||
const res = await api.get<
|
||||
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
|
||||
>(`/api/agnet/user/deployments/${deploymentId}/sk-snapshots`)
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function getAgnetDeploymentTimeline(deploymentId: string) {
|
||||
const res = await api.get<
|
||||
ApiEnvelope<{
|
||||
callbacks?: Array<Record<string, unknown>>
|
||||
artifacts?: Array<Record<string, unknown>>
|
||||
sk_snapshots?: Array<Record<string, unknown>>
|
||||
timeline?: Array<Record<string, unknown>>
|
||||
}>
|
||||
>(`/api/agnet/user/deployments/${deploymentId}/timeline`)
|
||||
return (
|
||||
res.data?.data ?? {
|
||||
callbacks: [],
|
||||
artifacts: [],
|
||||
sk_snapshots: [],
|
||||
timeline: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function simulateAgnetDeploymentEvents(
|
||||
deploymentId: string,
|
||||
events?: string[]
|
||||
|
||||
@@ -61,6 +61,7 @@ import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
approveAgnetApproval,
|
||||
getAgnetDeploymentEvents,
|
||||
getAgnetDeploymentTimeline,
|
||||
listAgnetApprovals,
|
||||
listAgnetCredentialLeases,
|
||||
listAgnetDeployments,
|
||||
@@ -784,6 +785,9 @@ export function AgnetDeploymentsPage() {
|
||||
<div className='mt-4 space-y-4'>
|
||||
<RunDetailPanel dep={selectedRun} />
|
||||
<RunAuditTimeline deploymentId={selectedRun.deployment_id} />
|
||||
<RunRelatedRecordsPanel
|
||||
deploymentId={selectedRun.deployment_id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -891,6 +895,117 @@ function RunAuditTimeline({ deploymentId }: { deploymentId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agnet', 'deployment-timeline', deploymentId],
|
||||
queryFn: () => getAgnetDeploymentTimeline(deploymentId),
|
||||
enabled: Boolean(deploymentId),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const callbacks = data?.callbacks ?? []
|
||||
const artifacts = data?.artifacts ?? []
|
||||
const snapshots = data?.sk_snapshots ?? []
|
||||
const timeline = data?.timeline ?? []
|
||||
|
||||
return (
|
||||
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
|
||||
{t('Related records')}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Callbacks, artifacts, SK snapshots and merged timeline')}
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<CircleDashed className='text-muted-foreground h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid gap-2 sm:grid-cols-4'>
|
||||
<MetaPill
|
||||
icon={GitCommit}
|
||||
label={t('callbacks')}
|
||||
value={String(callbacks.length)}
|
||||
/>
|
||||
<MetaPill
|
||||
icon={FileSearch}
|
||||
label={t('artifacts')}
|
||||
value={String(artifacts.length)}
|
||||
/>
|
||||
<MetaPill
|
||||
icon={ShieldCheck}
|
||||
label='SK'
|
||||
value={String(snapshots.length)}
|
||||
/>
|
||||
<MetaPill
|
||||
icon={Activity}
|
||||
label={t('timeline')}
|
||||
value={String(timeline.length)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid gap-3 md:grid-cols-2'>
|
||||
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
|
||||
<p className='text-foreground text-xs font-semibold'>
|
||||
{t('Artifacts')}
|
||||
</p>
|
||||
{artifacts.length === 0 ? (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('No artifacts yet')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{artifacts.slice(0, 5).map((item, idx) => (
|
||||
<li
|
||||
key={String(item.artifact_id || idx)}
|
||||
className='bg-muted/25 rounded-lg p-2'
|
||||
>
|
||||
<p className='truncate text-xs font-medium'>
|
||||
{String(item.title || item.artifact_id || t('Artifact'))}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'>
|
||||
{String(item.summary || item.uri || '—')}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
|
||||
<p className='text-foreground text-xs font-semibold'>
|
||||
{t('SK snapshots')}
|
||||
</p>
|
||||
{snapshots.length === 0 ? (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('No SK snapshots yet')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className='mt-2 space-y-2'>
|
||||
{snapshots.slice(0, 5).map((item, idx) => (
|
||||
<li
|
||||
key={String(item.snapshot_id || idx)}
|
||||
className='bg-muted/25 rounded-lg p-2'
|
||||
>
|
||||
<p className='truncate font-mono text-[11px]'>
|
||||
{String(item.snapshot_id || 'snapshot')}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 truncate text-[11px]'>
|
||||
{String(item.source_type || 'source')} ·{' '}
|
||||
{String(item.source_ref || '—')}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events page
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user