Skip to content

Commit 3134747

Browse files
committed
feat: add proactive admin warnings, diagram search, migration timeline
Dashboard: - Alert banner when plugins have pending migrations or schema errors - Database card shows "X plugin(s) degraded" vs "All Healthy" dynamically Sidebar: - Red badge on "Database" nav link showing count of degraded plugins - Count injected via layout context (admin users only) Schema diagram: - Search bar filters tables/columns with match highlighting - Non-matching nodes dimmed to 15% opacity, match count shown Migration timeline: - Collapsible "Migration History" section with applied_at timestamps - Fetched from plugin_schema_versions table https://claude.ai/code/session_01QJLkgjQDu5qohzJKGV4hj9
1 parent df50bda commit 3134747

8 files changed

Lines changed: 220 additions & 11 deletions

File tree

internal/app/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,11 @@ func (a *App) RegisterRoutes() {
12951295
ctx = layouts.SetUserName(ctx, session.Name)
12961296
ctx = layouts.SetUserEmail(ctx, session.Email)
12971297
ctx = layouts.SetIsAdmin(ctx, session.IsAdmin)
1298+
1299+
// Inject degraded plugin count for admin sidebar badge.
1300+
if session.IsAdmin {
1301+
ctx = layouts.SetDegradedPluginCount(ctx, len(a.PluginHealth.DegradedPlugins()))
1302+
}
12981303
}
12991304

13001305
// Campaign info from campaign middleware.

internal/plugins/admin/dashboard.templ

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,33 @@ package admin
22

33
import (
44
"fmt"
5+
"strings"
56
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
67
)
78

89
// AdminDashboardPage renders the admin overview with high-level stats.
9-
templ AdminDashboardPage(userCount, campaignCount, mediaFileCount int, totalStorageBytes int64, smtpConfigured bool, addonCount int, securityStats *SecurityStats) {
10+
templ AdminDashboardPage(userCount, campaignCount, mediaFileCount int, totalStorageBytes int64, smtpConfigured bool, addonCount int, securityStats *SecurityStats, degradedPlugins []string) {
1011
@layouts.App("Admin Dashboard") {
1112
<div class="max-w-5xl mx-auto space-y-6">
1213
<h1 class="text-2xl font-bold text-fg">Admin Dashboard</h1>
1314

15+
<!-- Degraded Plugin Alert Banner -->
16+
if len(degradedPlugins) > 0 {
17+
<a href="/admin/database" class="flex items-start gap-3 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-950/50 transition-colors">
18+
<i class="fa-solid fa-triangle-exclamation text-red-500 mt-0.5 shrink-0"></i>
19+
<div>
20+
<p class="text-sm font-semibold text-red-700 dark:text-red-400">
21+
{ fmt.Sprintf("%d", len(degradedPlugins)) } plugin(s) need attention
22+
</p>
23+
<p class="text-xs text-red-600/80 dark:text-red-400/70 mt-0.5">
24+
{ strings.Join(degradedPlugins, ", ") } &mdash; pending migrations or schema errors.
25+
Click to view the Database Explorer.
26+
</p>
27+
</div>
28+
<i class="fa-solid fa-arrow-right text-red-400 mt-0.5 ml-auto shrink-0"></i>
29+
</a>
30+
}
31+
1432
<!-- Stats Cards -->
1533
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
1634
<!-- Users -->
@@ -116,8 +134,16 @@ templ AdminDashboardPage(userCount, campaignCount, mediaFileCount int, totalStor
116134
<i class="fa-solid fa-database text-slate-500"></i>
117135
</span>
118136
</div>
119-
<p class="text-lg font-semibold text-fg mt-2">Schema Explorer</p>
120-
<p class="text-xs text-fg-muted mt-1 group-hover:text-accent transition-colors">Tables &amp; migrations &rarr;</p>
137+
if len(degradedPlugins) > 0 {
138+
<p class="text-lg font-semibold text-amber-600 dark:text-amber-400 mt-2">
139+
{ fmt.Sprintf("%d", len(degradedPlugins)) } plugin(s) degraded
140+
</p>
141+
<p class="text-xs text-fg-muted mt-1">Pending migrations or errors</p>
142+
} else {
143+
<p class="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mt-2">All Healthy</p>
144+
<p class="text-xs text-fg-muted mt-1">All migrations applied</p>
145+
}
146+
<p class="text-xs text-fg-muted group-hover:text-accent transition-colors">Schema explorer &rarr;</p>
121147
</a>
122148
<!-- SMTP -->
123149
<a href="/admin/smtp" class="card hover:shadow-md transition-shadow group">

internal/plugins/admin/database.templ

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,39 @@ templ AdminDatabasePage(statuses []PluginMigrationStatus, tableCount int, csrfTo
4848
</div>
4949
}
5050

51+
<!-- Migration History Timeline -->
52+
if hasMigrationHistory(statuses) {
53+
<details class="group">
54+
<summary class="text-xs text-fg-muted cursor-pointer hover:text-fg-secondary">
55+
<i class="fa-solid fa-clock-rotate-left mr-1"></i>
56+
Migration History
57+
<i class="fa-solid fa-chevron-down text-[10px] ml-1 transition-transform group-open:rotate-180"></i>
58+
</summary>
59+
<div class="mt-3 card p-0 overflow-hidden">
60+
<table class="w-full text-xs">
61+
<thead>
62+
<tr class="border-b border-edge text-left text-fg-muted bg-surface-alt/50">
63+
<th class="px-4 py-2">Plugin</th>
64+
<th class="px-4 py-2">Version</th>
65+
<th class="px-4 py-2">Applied At</th>
66+
</tr>
67+
</thead>
68+
<tbody>
69+
for _, s := range statuses {
70+
for _, h := range s.History {
71+
<tr class="border-b border-edge last:border-b-0">
72+
<td class="px-4 py-1.5 font-medium text-fg capitalize">{ s.Slug }</td>
73+
<td class="px-4 py-1.5 text-fg-secondary">v{ fmt.Sprintf("%d", h.Version) }</td>
74+
<td class="px-4 py-1.5 text-fg-muted font-mono">{ h.AppliedAt }</td>
75+
</tr>
76+
}
77+
}
78+
</tbody>
79+
</table>
80+
</div>
81+
</details>
82+
}
83+
5184
<!-- Schema Diagram -->
5285
<div class="card p-0 overflow-hidden">
5386
<div class="px-5 py-3 border-b border-edge flex items-center justify-between">
@@ -106,3 +139,13 @@ func hasPendingMigrations(statuses []PluginMigrationStatus) bool {
106139
}
107140
return false
108141
}
142+
143+
// hasMigrationHistory returns true if any plugin has migration history entries.
144+
func hasMigrationHistory(statuses []PluginMigrationStatus) bool {
145+
for _, s := range statuses {
146+
if len(s.History) > 0 {
147+
return true
148+
}
149+
}
150+
return false
151+
}

internal/plugins/admin/database_service.go

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"log/slog"
1212
"strings"
13+
"time"
1314

1415
"github.com/keyxmakerx/chronicle/internal/database"
1516
)
@@ -62,12 +63,19 @@ type ForeignKeyInfo struct {
6263

6364
// PluginMigrationStatus describes the migration state of a single plugin.
6465
type PluginMigrationStatus struct {
65-
Slug string `json:"slug"`
66-
CurrentVersion int `json:"currentVersion"`
67-
LatestVersion int `json:"latestVersion"`
68-
Pending int `json:"pending"`
69-
Healthy bool `json:"healthy"`
70-
Error string `json:"error,omitempty"`
66+
Slug string `json:"slug"`
67+
CurrentVersion int `json:"currentVersion"`
68+
LatestVersion int `json:"latestVersion"`
69+
Pending int `json:"pending"`
70+
Healthy bool `json:"healthy"`
71+
Error string `json:"error,omitempty"`
72+
History []MigrationHistory `json:"history,omitempty"`
73+
}
74+
75+
// MigrationHistory records when a specific migration version was applied.
76+
type MigrationHistory struct {
77+
Version int `json:"version"`
78+
AppliedAt string `json:"appliedAt"` // RFC3339
7179
}
7280

7381
// databaseExplorer implements DatabaseExplorer with direct DB access.
@@ -242,12 +250,46 @@ func (e *databaseExplorer) GetMigrationStatus(ctx context.Context) ([]PluginMigr
242250
status.Pending = 0
243251
}
244252

253+
// Fetch applied migration timestamps.
254+
history, err := e.getMigrationHistory(ctx, p.Slug)
255+
if err != nil {
256+
slog.Warn("failed to read migration history",
257+
slog.String("plugin", p.Slug),
258+
slog.Any("error", err),
259+
)
260+
}
261+
status.History = history
262+
245263
statuses = append(statuses, status)
246264
}
247265

248266
return statuses, nil
249267
}
250268

269+
// getMigrationHistory returns the applied migration versions and timestamps for a plugin.
270+
func (e *databaseExplorer) getMigrationHistory(ctx context.Context, slug string) ([]MigrationHistory, error) {
271+
rows, err := e.db.QueryContext(ctx,
272+
`SELECT version, applied_at FROM plugin_schema_versions WHERE plugin_slug = ? ORDER BY version`,
273+
slug,
274+
)
275+
if err != nil {
276+
return nil, err
277+
}
278+
defer rows.Close()
279+
280+
var history []MigrationHistory
281+
for rows.Next() {
282+
var h MigrationHistory
283+
var appliedAt time.Time
284+
if err := rows.Scan(&h.Version, &appliedAt); err != nil {
285+
return nil, err
286+
}
287+
h.AppliedAt = appliedAt.Format(time.RFC3339)
288+
history = append(history, h)
289+
}
290+
return history, rows.Err()
291+
}
292+
251293
// ApplyPendingMigrations runs all pending plugin migrations and updates the
252294
// health registry. Returns the results for each plugin.
253295
func (e *databaseExplorer) ApplyPendingMigrations(ctx context.Context) ([]database.PluginMigrationResult, error) {

internal/plugins/admin/handler.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,18 @@ func (h *Handler) Dashboard(c echo.Context) error {
214214
securityStats, _ = h.securityService.GetStats(ctx)
215215
}
216216

217-
return middleware.Render(c, http.StatusOK, AdminDashboardPage(userCount, campaignCount, mediaFileCount, totalStorageBytes, smtpConfigured, addonCount, securityStats))
217+
// Check for degraded plugins to show alert banner.
218+
var degradedPlugins []string
219+
if h.databaseExplorer != nil {
220+
statuses, _ := h.databaseExplorer.GetMigrationStatus(ctx)
221+
for _, s := range statuses {
222+
if !s.Healthy || s.Pending > 0 {
223+
degradedPlugins = append(degradedPlugins, s.Slug)
224+
}
225+
}
226+
}
227+
228+
return middleware.Render(c, http.StatusOK, AdminDashboardPage(userCount, campaignCount, mediaFileCount, totalStorageBytes, smtpConfigured, addonCount, securityStats, degradedPlugins))
218229
}
219230

220231
// --- Users ---

internal/templates/layouts/app.templ

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,11 @@ templ AdminSidebarNav() {
524524
<i class="fa-solid fa-database text-xs"></i>
525525
</span>
526526
Database
527+
if GetDegradedPluginCount(ctx) > 0 {
528+
<span class="ml-auto inline-flex items-center justify-center w-5 h-5 text-[10px] font-bold text-white bg-red-500 rounded-full">
529+
{ fmt.Sprintf("%d", GetDegradedPluginCount(ctx)) }
530+
</span>
531+
}
527532
</a>
528533
<a
529534
href="/admin/smtp"

internal/templates/layouts/data.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ const (
3939
keyAccentColor ctxKey = "layout_accent_color"
4040
keyBrandName ctxKey = "layout_brand_name"
4141
keyBrandLogo ctxKey = "layout_brand_logo"
42-
keyTopbarStyle ctxKey = "layout_topbar_style"
42+
keyTopbarStyle ctxKey = "layout_topbar_style"
43+
keyDegradedPluginCount ctxKey = "layout_degraded_plugin_count"
4344
)
4445

4546
// SidebarEntityType holds the minimum entity type info needed for sidebar
@@ -471,6 +472,18 @@ func GetTopbarStyle(ctx context.Context) *TopbarStyleData {
471472
return style
472473
}
473474

475+
// SetDegradedPluginCount stores the number of unhealthy plugins in the context.
476+
// Used by the admin sidebar to show a warning badge on the Database link.
477+
func SetDegradedPluginCount(ctx context.Context, count int) context.Context {
478+
return context.WithValue(ctx, keyDegradedPluginCount, count)
479+
}
480+
481+
// GetDegradedPluginCount returns the number of unhealthy plugins, or 0.
482+
func GetDegradedPluginCount(ctx context.Context) int {
483+
count, _ := ctx.Value(keyDegradedPluginCount).(int)
484+
return count
485+
}
486+
474487
// EscapeJSONString escapes a string for safe embedding inside a JSON
475488
// double-quoted value. Only handles the characters that could break
476489
// the JSON structure (backslash and double-quote).

static/js/widgets/db_explorer.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@
8888
return;
8989
}
9090

91+
// Search bar.
92+
var searchBar = document.createElement('div');
93+
searchBar.className = 'flex items-center gap-2 px-4 py-2 border-b border-edge';
94+
searchBar.innerHTML = '<i class="fa-solid fa-search text-xs text-fg-muted"></i>' +
95+
'<input type="text" class="bg-transparent border-none outline-none text-sm text-fg flex-1" ' +
96+
'placeholder="Search tables or columns..." data-db-search />' +
97+
'<span class="text-xs text-fg-muted" data-db-search-count></span>';
98+
el.appendChild(searchBar);
99+
91100
var width = el.clientWidth || 900;
92101
var height = 600;
93102

@@ -305,6 +314,61 @@
305314
}
306315
});
307316

317+
// Table/column search — highlights matching nodes and dims others.
318+
var searchInput = el.querySelector('[data-db-search]');
319+
var searchCount = el.querySelector('[data-db-search-count]');
320+
var searchTimer = null;
321+
if (searchInput) {
322+
searchInput.addEventListener('input', function () {
323+
clearTimeout(searchTimer);
324+
searchTimer = setTimeout(function () {
325+
var q = searchInput.value.trim().toLowerCase();
326+
if (!q) {
327+
// Reset: show all nodes at full opacity.
328+
nodeGroups.attr('opacity', 1);
329+
links.attr('opacity', 1);
330+
linkLabels.attr('opacity', 1);
331+
if (searchCount) searchCount.textContent = '';
332+
return;
333+
}
334+
335+
var matchCount = 0;
336+
var matchSet = {};
337+
nodeGroups.each(function (d) {
338+
// Match table name or any column name.
339+
var match = d.name.toLowerCase().indexOf(q) >= 0;
340+
if (!match && d.columns) {
341+
for (var i = 0; i < d.columns.length; i++) {
342+
if (d.columns[i].name.toLowerCase().indexOf(q) >= 0) {
343+
match = true;
344+
break;
345+
}
346+
}
347+
}
348+
matchSet[d.id] = match;
349+
if (match) matchCount++;
350+
d3.select(this).attr('opacity', match ? 1 : 0.15);
351+
});
352+
353+
// Dim edges not connecting matched nodes.
354+
links.attr('opacity', function (d) {
355+
var src = typeof d.source === 'object' ? d.source.id : d.source;
356+
var tgt = typeof d.target === 'object' ? d.target.id : d.target;
357+
return (matchSet[src] || matchSet[tgt]) ? 0.6 : 0.05;
358+
});
359+
linkLabels.attr('opacity', function (d) {
360+
var src = typeof d.source === 'object' ? d.source.id : d.source;
361+
var tgt = typeof d.target === 'object' ? d.target.id : d.target;
362+
return (matchSet[src] || matchSet[tgt]) ? 0.8 : 0.05;
363+
});
364+
365+
if (searchCount) {
366+
searchCount.textContent = matchCount + ' / ' + nodes.length;
367+
}
368+
}, 150);
369+
});
370+
}
371+
308372
// Simulation tick.
309373
simulation.on('tick', function () {
310374
links

0 commit comments

Comments
 (0)