Skip to content

Commit 1b17534

Browse files
committed
Sprint A-2: Chronicle Sync Dashboard — Owner + Admin expansion
Owner API Keys page: - HTMX lazy-loaded sync overview card showing total mappings, per-type breakdown (entities, maps, events, etc.), and last sync activity - Searchable, filterable, paginated sync mappings table with name joins (LEFT JOIN entities/maps for display names), type filter, sort options - Direction badges (both/push/pull) and version tracking per mapping Admin API Monitor: - Campaign Sync Overview table showing per-campaign sync health: active keys, total mappings, last activity, and 24h error counts - Aggregated from sync_mappings, sync_api_keys, and api_request_logs Backend: - Extended SyncMappingRepository interface with CountByType, LastSyncActivity, ListMappingsWithNames, ListCampaignSyncStats - Added GetSyncSummary and ListMappingsWithNames service methods - New handler endpoints for HTMX sync fragments - Injected SyncMappingService into management Handler via setter - Consolidated sync mapping repo creation in routes.go wiring https://claude.ai/code/session_01WJEjfBqjZaGatHiXXXDupo
1 parent 9f086f5 commit 1b17534

9 files changed

Lines changed: 720 additions & 28 deletions

File tree

internal/app/routes.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,10 @@ func (a *App) RegisterRoutes() {
926926
syncRepo := syncapi.NewSyncAPIRepository(a.DB)
927927
syncService := syncapi.NewSyncAPIService(syncRepo)
928928
syncHandler := syncapi.NewHandler(syncService)
929+
// Inject sync mapping service early so the owner dashboard can show sync status.
930+
syncMappingRepoEarly := syncapi.NewSyncMappingRepository(a.DB)
931+
syncMappingSvcEarly := syncapi.NewSyncMappingService(syncMappingRepoEarly)
932+
syncHandler.SetSyncMappingService(syncMappingSvcEarly)
929933
if a.PluginHealth.IsHealthy("syncapi") {
930934
syncapi.RegisterAdminRoutes(adminGroup, syncHandler)
931935
syncapi.RegisterCampaignRoutes(e, syncHandler, campaignService, authService)
@@ -1012,11 +1016,9 @@ func (a *App) RegisterRoutes() {
10121016
mediaAPIHandler.SetURLSigner(urlSigner)
10131017
}
10141018

1015-
// Sync mapping service and handler for Foundry VTT bidirectional sync.
1016-
syncMappingRepo := syncapi.NewSyncMappingRepository(a.DB)
1017-
syncMappingSvc := syncapi.NewSyncMappingService(syncMappingRepo)
1018-
syncMappingHandler := syncapi.NewSyncHandler(syncMappingSvc)
1019-
_ = syncMappingSvc // Service will also be used by map/entity handlers.
1019+
// Sync mapping handler for Foundry VTT bidirectional sync.
1020+
// Reuses the sync mapping service created earlier for the owner dashboard.
1021+
syncMappingHandler := syncapi.NewSyncHandler(syncMappingSvcEarly)
10201022
mapAPIHandler := syncapi.NewMapAPIHandler(syncService, mapsService, drawingService, campaignService)
10211023

10221024
if a.PluginHealth.IsHealthy("syncapi") {

internal/plugins/syncapi/admin_dashboard.templ

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ templ AdminAPIDashboardTempl(data AdminDashboardData) {
9494
@topTable("Top Keys", "fa-key", data.TopKeys)
9595
</div>
9696

97+
// Campaign Sync Overview.
98+
if len(data.CampaignSyncStats) > 0 {
99+
@AdminCampaignSyncTableTempl(data.CampaignSyncStats)
100+
}
101+
97102
// Security events.
98103
<div class="card p-6">
99104
<div class="flex items-center justify-between mb-4">

internal/plugins/syncapi/handler.go

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,20 @@ import (
1717
// Handler handles sync API HTTP requests for both the management UI
1818
// (key management, dashboards) and the actual sync API endpoints.
1919
type Handler struct {
20-
service SyncAPIService
20+
service SyncAPIService
21+
syncMapSvc SyncMappingService
2122
}
2223

2324
// NewHandler creates a new sync API handler.
2425
func NewHandler(service SyncAPIService) *Handler {
2526
return &Handler{service: service}
2627
}
2728

29+
// SetSyncMappingService injects the sync mapping service for owner dashboard sync status.
30+
func (h *Handler) SetSyncMappingService(svc SyncMappingService) {
31+
h.syncMapSvc = svc
32+
}
33+
2834
// --- Campaign Owner: API Key Management ---
2935

3036
// KeysPage renders the API keys management page for a campaign (GET /campaigns/:id/api-keys).
@@ -209,6 +215,59 @@ func (h *Handler) SyncStatusEmbed(c echo.Context) error {
209215
return middleware.Render(c, http.StatusOK, SyncStatusFragment(cc.Campaign.ID, keys, stats))
210216
}
211217

218+
// SyncOverviewFragment returns an HTMX fragment showing the sync summary card.
219+
// GET /campaigns/:id/api-keys/sync-overview
220+
func (h *Handler) SyncOverviewFragment(c echo.Context) error {
221+
cc := campaigns.GetCampaignContext(c)
222+
if cc == nil {
223+
return apperror.NewForbidden("campaign context required")
224+
}
225+
226+
if h.syncMapSvc == nil {
227+
return middleware.Render(c, http.StatusOK, SyncOverviewEmptyTempl())
228+
}
229+
230+
summary, err := h.syncMapSvc.GetSyncSummary(c.Request().Context(), cc.Campaign.ID)
231+
if err != nil {
232+
summary = &SyncSummary{}
233+
}
234+
235+
return middleware.Render(c, http.StatusOK, SyncOverviewCardTempl(summary))
236+
}
237+
238+
// SyncMappingsFragment returns an HTMX fragment with the paginated sync mappings table.
239+
// GET /campaigns/:id/api-keys/sync-mappings?search=&type=&sort=&offset=0
240+
func (h *Handler) SyncMappingsFragment(c echo.Context) error {
241+
cc := campaigns.GetCampaignContext(c)
242+
if cc == nil {
243+
return apperror.NewForbidden("campaign context required")
244+
}
245+
246+
if h.syncMapSvc == nil {
247+
return middleware.Render(c, http.StatusOK, SyncMappingsEmptyTempl())
248+
}
249+
250+
offset, _ := strconv.Atoi(c.QueryParam("offset"))
251+
if offset < 0 {
252+
offset = 0
253+
}
254+
255+
opts := SyncMappingListOptions{
256+
Search: strings.TrimSpace(c.QueryParam("search")),
257+
Type: strings.TrimSpace(c.QueryParam("type")),
258+
Sort: strings.TrimSpace(c.QueryParam("sort")),
259+
Limit: 25,
260+
Offset: offset,
261+
}
262+
263+
rows, total, err := h.syncMapSvc.ListMappingsWithNames(c.Request().Context(), cc.Campaign.ID, opts)
264+
if err != nil {
265+
rows = []SyncMappingRow{}
266+
}
267+
268+
return middleware.Render(c, http.StatusOK, SyncMappingsTableTempl(cc.Campaign.ID, rows, total, opts))
269+
}
270+
212271
// --- Admin: API Monitoring Dashboard ---
213272

214273
// AdminDashboard renders the admin API monitoring page (GET /admin/api).
@@ -230,20 +289,27 @@ func (h *Handler) AdminDashboard(c echo.Context) error {
230289

231290
keys, totalKeys, _ := h.service.ListAllKeys(ctx, 20, 0)
232291

292+
// Fetch per-campaign sync stats if sync mapping service is available.
293+
var campaignSyncStats []CampaignSyncStats
294+
if h.syncMapSvc != nil {
295+
campaignSyncStats, _ = h.syncMapSvc.ListCampaignSyncStats(ctx)
296+
}
297+
233298
csrfToken := middleware.GetCSRFToken(c)
234299

235300
data := AdminDashboardData{
236-
Stats: stats,
237-
RequestSeries: requestSeries,
238-
SecuritySeries: securitySeries,
239-
TopIPs: topIPs,
240-
TopPaths: topPaths,
241-
TopKeys: topKeys,
242-
SecurityEvents: secEvents,
243-
IPBlocks: ipBlocks,
244-
APIKeys: keys,
245-
TotalKeys: totalKeys,
246-
CSRFToken: csrfToken,
301+
Stats: stats,
302+
RequestSeries: requestSeries,
303+
SecuritySeries: securitySeries,
304+
TopIPs: topIPs,
305+
TopPaths: topPaths,
306+
TopKeys: topKeys,
307+
SecurityEvents: secEvents,
308+
IPBlocks: ipBlocks,
309+
APIKeys: keys,
310+
TotalKeys: totalKeys,
311+
CampaignSyncStats: campaignSyncStats,
312+
CSRFToken: csrfToken,
247313
}
248314

249315
return middleware.Render(c, http.StatusOK, AdminAPIDashboardTempl(data))
@@ -423,15 +489,16 @@ func (h *Handler) AdminRevokeKey(c echo.Context) error {
423489

424490
// AdminDashboardData holds all data for the admin API monitoring dashboard.
425491
type AdminDashboardData struct {
426-
Stats *APIStats
427-
RequestSeries []TimeSeriesPoint
428-
SecuritySeries []TimeSeriesPoint
429-
TopIPs []TopEntry
430-
TopPaths []TopEntry
431-
TopKeys []TopEntry
432-
SecurityEvents []SecurityEvent
433-
IPBlocks []IPBlock
434-
APIKeys []APIKey
435-
TotalKeys int
436-
CSRFToken string
492+
Stats *APIStats
493+
RequestSeries []TimeSeriesPoint
494+
SecuritySeries []TimeSeriesPoint
495+
TopIPs []TopEntry
496+
TopPaths []TopEntry
497+
TopKeys []TopEntry
498+
SecurityEvents []SecurityEvent
499+
IPBlocks []IPBlock
500+
APIKeys []APIKey
501+
TotalKeys int
502+
CampaignSyncStats []CampaignSyncStats
503+
CSRFToken string
437504
}

internal/plugins/syncapi/model.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,44 @@ type SyncPullResponse struct {
229229
Mappings []SyncMapping `json:"mappings"`
230230
HasMore bool `json:"has_more"`
231231
}
232+
233+
// SyncSummary provides an overview of sync state for a campaign.
234+
// Used by the owner's API Keys page to show what's syncing.
235+
type SyncSummary struct {
236+
TotalMappings int `json:"total_mappings"`
237+
ByType map[string]int `json:"by_type"`
238+
LastSyncActivity *time.Time `json:"last_sync_activity,omitempty"`
239+
RecentErrors int `json:"recent_errors"`
240+
}
241+
242+
// SyncMappingRow is a display-friendly sync mapping with the linked
243+
// Chronicle entity name for the owner's mappings table.
244+
type SyncMappingRow struct {
245+
ID string `json:"id"`
246+
ChronicleType string `json:"chronicle_type"`
247+
ChronicleID string `json:"chronicle_id"`
248+
ChronicleName string `json:"chronicle_name"`
249+
ExternalID string `json:"external_id"`
250+
SyncDirection string `json:"sync_direction"`
251+
SyncVersion int `json:"sync_version"`
252+
LastSyncedAt time.Time `json:"last_synced_at"`
253+
}
254+
255+
// SyncMappingListOptions configures the sync mappings list query.
256+
type SyncMappingListOptions struct {
257+
Search string
258+
Type string
259+
Sort string
260+
Limit int
261+
Offset int
262+
}
263+
264+
// CampaignSyncStats provides per-campaign sync statistics for the admin dashboard.
265+
type CampaignSyncStats struct {
266+
CampaignID string `json:"campaign_id"`
267+
CampaignName string `json:"campaign_name"`
268+
ActiveKeys int `json:"active_keys"`
269+
TotalMappings int `json:"total_mappings"`
270+
LastActivity *time.Time `json:"last_activity,omitempty"`
271+
RecentErrors int `json:"recent_errors"`
272+
}

internal/plugins/syncapi/owner_keys.templ

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ templ OwnerKeysPageTempl(campaignID string, keys []APIKey, stats *APIStats, csrf
141141
</form>
142142
</div>
143143

144+
// Sync Status Overview (HTMX lazy-loaded).
145+
<div
146+
hx-get={ fmt.Sprintf("/campaigns/%s/api-keys/sync-overview", campaignID) }
147+
hx-trigger="load"
148+
hx-swap="innerHTML"
149+
>
150+
<div class="card p-6 text-center">
151+
<i class="fa-solid fa-spinner fa-spin text-fg-muted"></i>
152+
<p class="text-sm text-fg-muted mt-2">Loading sync status...</p>
153+
</div>
154+
</div>
155+
156+
// Sync Mappings Table (HTMX lazy-loaded).
157+
<div id="sync-mappings-container"
158+
hx-get={ fmt.Sprintf("/campaigns/%s/api-keys/sync-mappings", campaignID) }
159+
hx-trigger="load"
160+
hx-swap="innerHTML"
161+
>
162+
<div class="card p-6 text-center">
163+
<i class="fa-solid fa-spinner fa-spin text-fg-muted"></i>
164+
<p class="text-sm text-fg-muted mt-2">Loading sync mappings...</p>
165+
</div>
166+
</div>
167+
144168
// Security info.
145169
<div class="card p-6">
146170
<div class="flex items-start gap-4">

internal/plugins/syncapi/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ func RegisterCampaignRoutes(e *echo.Echo, h *Handler, campaignSvc campaigns.Camp
4444

4545
// Sync status embed (owner only — used by dashboard sync status block).
4646
cg.GET("/sync-status", h.SyncStatusEmbed, campaigns.RequireRole(campaigns.RoleOwner))
47+
48+
// Sync dashboard fragments (owner only — HTMX-loaded on API keys page).
49+
cg.GET("/api-keys/sync-overview", h.SyncOverviewFragment, campaigns.RequireRole(campaigns.RoleOwner))
50+
cg.GET("/api-keys/sync-mappings", h.SyncMappingsFragment, campaigns.RequireRole(campaigns.RoleOwner))
4751
}
4852

4953
// RegisterAPIRoutes adds the public REST API endpoints under /api/v1/.

0 commit comments

Comments
 (0)