Files
Jakub Zych e0acf7d93a docs(08): research admin dashboard domain
Phase 8: Admin Dashboard
- Standard stack: Gridstack.js for layout, ZIO HTTP SSE for real-time
- Architecture patterns: Widget registry, per-user preferences, SSE streaming
- Settings system: Centralized manager, YAML-driven forms
- Pitfalls: SSE connection limits, layout save races, cache invalidation
2026-02-05 15:07:51 +01:00

1100 lines
41 KiB
Markdown

# Phase 8: Admin Dashboard - Research
**Researched:** 2026-02-05
**Domain:** Admin dashboard with customizable widgets, real-time updates, and plugin settings pages
**Confidence:** HIGH (based on WinterCMS reference + official library documentation + CONTEXT.md decisions)
## Summary
This research covers the implementation of a customizable admin dashboard for SummerCMS. The dashboard provides a container for plugin-registered widgets (report widgets) with per-user layout customization, real-time updates via SSE/WebSocket, and a centralized settings system for plugin configuration.
The WinterCMS reference implementation provides a comprehensive blueprint:
1. **ReportContainer** widget hosts multiple ReportWidgetBase instances with YAML-configured defaults
2. **Per-user preferences** stored in a dedicated table with JSON-serialized layouts
3. **WidgetManager** singleton collects widgets from plugin registrations
4. **SettingsManager** centralizes plugin settings with categories and permissions
5. **SettingsModel** behavior provides YAML-driven forms for plugin configuration
The CONTEXT.md decisions specify:
- Masonry/fluid layout with drag-and-drop customization
- Real-time updates via WebSocket/SSE for live data
- Widget configuration (title, sizing, custom parameters)
- WinterCMS-style centralized Settings area grouped by category
**Primary recommendation:** Use Gridstack.js for dashboard grid layout (masonry, drag-and-drop, resize), ZIO HTTP SSE for real-time widget updates, HTMX SSE extension for frontend integration, and the Phase 7 YAML-driven form system for widget configuration and settings pages.
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Gridstack.js | 10.x | Dashboard grid layout | Native drag-drop, resize, save/restore, TypeScript, no deps |
| ZIO HTTP | 3.4.x | SSE/WebSocket endpoints | Already in stack, native streaming support |
| HTMX SSE Extension | 2.2.x | Frontend SSE integration | Declarative, pairs with existing HTMX usage |
| circe-yaml | 1.x | Widget/settings config | Already in stack from Phase 7 |
| ScalaTags | 0.13.x | HTML generation | Already in stack from Phase 7 |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| Chart.js | 4.x | Widget charts/graphs | Stats widgets with visualizations |
| Flatpickr | 4.6.x | Date range picker | Widget date configuration |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Gridstack.js | Muuri | Muuri more flexible but Gridstack specialized for dashboards |
| Gridstack.js | React Grid Layout | React Grid requires React; we use HTMX/ScalaTags |
| SSE | Full WebSocket | SSE simpler, unidirectional is sufficient for dashboard updates |
| Gridstack.js | Packery/Isotope | Packery older, Gridstack actively maintained with TypeScript |
**Mill dependencies:**
```scala
// Phase 7 deps already included (circe-yaml, scalatags, etc.)
// ZIO HTTP already in stack
```
**Frontend (included via CDN or bundled):**
```html
<link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4"></script>
```
## Architecture Patterns
### Recommended Project Structure
```
summercms/
├── admin/
│ ├── dashboard/
│ │ ├── Dashboard.scala # Dashboard container
│ │ ├── DashboardWidget.scala # Widget base trait/class
│ │ ├── WidgetRegistry.scala # Collects registered widgets
│ │ ├── UserDashboardPrefs.scala # Per-user layout storage
│ │ ├── widgets/ # Core widgets
│ │ │ ├── WelcomeWidget.scala
│ │ │ ├── SystemStatusWidget.scala
│ │ │ └── QuickActionsWidget.scala
│ │ └── sse/
│ │ └── WidgetEventStream.scala # SSE endpoint for real-time updates
│ ├── settings/
│ │ ├── SettingsManager.scala # Collects registered settings
│ │ ├── SettingsController.scala # Generic settings CRUD
│ │ ├── SettingsModel.scala # Base trait for settings models
│ │ └── categories.scala # Standard category constants
│ └── controllers/
│ └── IndexController.scala # Dashboard route handler
```
### Pattern 1: Dashboard Widget Base Trait
**What:** Define a base trait for all dashboard (report) widgets
**When to use:** All plugins extending dashboard with widgets
**Example:**
```scala
// Source: WinterCMS ReportWidgetBase pattern + CONTEXT.md
import scalatags.Text.all._
import zio._
trait DashboardWidget:
/** Unique widget identifier */
def widgetId: String
/** Default alias for this widget */
def defaultAlias: String = widgetId
/** Widget display name (translation key) */
def name: String
/** Widget description (translation key) */
def description: String
/** Define configurable properties */
def defineProperties: Map[String, PropertyDef] = Map(
"title" -> PropertyDef(
title = "backend::lang.dashboard.widget_title_label",
`type` = PropertyType.String,
default = Some(name)
),
"ocWidgetWidth" -> PropertyDef(
title = "backend::lang.dashboard.widget_columns_label",
`type` = PropertyType.Dropdown,
options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
default = Some("6")
)
)
/** Render widget content (can be ZIO effect for async data loading) */
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag]
/** Optional: Stream of updates for real-time widgets */
def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = None
case class PropertyDef(
title: String,
`type`: PropertyType,
description: Option[String] = None,
default: Option[String] = None,
options: Map[String, String] = Map.empty,
required: Boolean = false
)
enum PropertyType:
case String, Number, Checkbox, Dropdown, DateRange
case class WidgetConfig(
alias: String,
properties: Map[String, Any],
position: WidgetPosition
)
case class WidgetPosition(
x: Int, y: Int, // Grid position
w: Int, h: Int // Width and height in grid units
)
case class WidgetUpdate(
widgetAlias: String,
content: Frag
)
```
### Pattern 2: Widget Registry with Plugin Registration
**What:** Singleton registry collecting widgets from plugins during boot
**When to use:** Plugin system collects all available dashboard widgets
**Example:**
```scala
// Source: WinterCMS WidgetManager pattern
import zio._
trait WidgetRegistry:
def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit]
def listWidgets: UIO[Map[String, WidgetInfo]]
def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]]
def resolveWidget(className: String): UIO[Option[DashboardWidget]]
case class WidgetInfo(
name: String,
description: String,
context: List[String] = List("dashboard"), // Where widget can appear
permissions: List[String] = Nil // Required permissions
)
object WidgetRegistry:
val live: ZLayer[Any, Nothing, WidgetRegistry] =
ZLayer.fromZIO {
Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
new WidgetRegistry:
def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] =
for
instance <- ZIO.succeed(widgetClass.getDeclaredConstructor().newInstance())
_ <- widgetsRef.update(_ + (widgetClass.getName -> (info, instance)))
yield ()
def listWidgets: UIO[Map[String, WidgetInfo]] =
widgetsRef.get.map(_.view.mapValues(_._1).toMap)
def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] =
listWidgets.map(_.filter(_._2.context.contains(context)))
def resolveWidget(className: String): UIO[Option[DashboardWidget]] =
widgetsRef.get.map(_.get(className).map(_._2))
}
}
// Plugin registration in boot
def registerReportWidgets: Map[Class[? <: DashboardWidget], WidgetInfo] = Map(
classOf[SystemStatusWidget] -> WidgetInfo(
name = "system::lang.dashboard.status.name",
description = "system::lang.dashboard.status.description",
permissions = List("system.access_dashboard")
),
classOf[WelcomeWidget] -> WidgetInfo(
name = "backend::lang.dashboard.welcome.name",
description = "backend::lang.dashboard.welcome.description"
)
)
```
### Pattern 3: Per-User Dashboard Preferences
**What:** Store and retrieve per-user widget layouts using database
**When to use:** Dashboard layout customization per admin user
**Example:**
```scala
// Source: WinterCMS UserPreference + ReportContainer patterns
import io.getquill._
import zio._
case class DashboardPreference(
userId: Long,
context: String, // "dashboard", "analytics", etc.
widgets: Json // JSON array of widget configurations
)
case class StoredWidgetConfig(
className: String,
alias: String,
sortOrder: Int,
configuration: Map[String, Any], // Including position: {x, y, w, h}
)
trait DashboardPreferenceService:
def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]]
def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit]
def resetToDefault(userId: Long, context: String): Task[Unit]
def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]]
object DashboardPreferenceService:
def live(quill: Quill.Postgres[SnakeCase]): ZLayer[Any, Nothing, DashboardPreferenceService] =
ZLayer.succeed {
import quill._
new DashboardPreferenceService:
def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
// First try user preferences, fall back to system defaults
run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
).flatMap {
case Nil => getDefaultLayout(context)
case prefs => ZIO.succeed(parseWidgets(prefs.head.widgets))
}
def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
.updateValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
).unit.catchAll { _ =>
run(
query[DashboardPreference]
.insertValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
).unit
}
def resetToDefault(userId: Long, context: String): Task[Unit] =
run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
.delete
).unit
def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]] =
// Load from YAML config or system parameter
ZIO.attempt {
val config = loadYamlConfig(s"config_dashboard_$context.yaml")
parseDefaultWidgets(config)
}
}
```
### Pattern 4: Dashboard Container with Gridstack Integration
**What:** Render dashboard with Gridstack.js grid layout
**When to use:** Main dashboard view rendering
**Example:**
```scala
// Source: WinterCMS ReportContainer + Gridstack.js patterns
import scalatags.Text.all._
def renderDashboard(
widgets: List[(String, DashboardWidget, WidgetConfig)],
canAddAndDelete: Boolean
)(using ctx: DashboardContext): Frag = {
div(cls := "dashboard-container",
// Toolbar
div(cls := "dashboard-toolbar",
canAddAndDelete.option(
button(cls := "btn btn-primary",
attr("hx-get") := "/admin/dashboard/add-widget-popup",
attr("hx-target") := "#modal-container",
"Add Widget"
)
),
button(cls := "btn btn-secondary",
attr("hx-post") := "/admin/dashboard/reset",
attr("hx-confirm") := "Reset dashboard to default layout?",
"Reset to Default"
)
),
// Grid container - Gridstack.js will initialize this
div(cls := "grid-stack",
id := "dashboard-grid",
widgets.map { case (alias, widget, config) =>
div(cls := "grid-stack-item",
attr("gs-id") := alias,
attr("gs-x") := config.position.x.toString,
attr("gs-y") := config.position.y.toString,
attr("gs-w") := config.position.w.toString,
attr("gs-h") := config.position.h.toString,
attr("gs-min-w") := "2",
attr("gs-min-h") := "2",
div(cls := "grid-stack-item-content widget-container",
// SSE connection for real-time updates
widget.updates(config).map { _ =>
Seq(
attr("hx-ext") := "sse",
attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
attr("sse-swap") := "content"
)
}.getOrElse(Nil),
// Widget header
div(cls := "widget-header",
span(cls := "widget-title", config.properties.getOrElse("title", widget.name).toString),
div(cls := "widget-controls",
button(cls := "btn-widget-config",
attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
attr("hx-target") := "#modal-container",
i(cls := "icon-cog")
),
canAddAndDelete.option(
button(cls := "btn-widget-remove",
attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
attr("hx-confirm") := "Remove this widget?",
i(cls := "icon-trash")
)
)
)
),
// Widget body (rendered content)
div(cls := "widget-body", id := s"widget-content-$alias",
// Initial render - will be swapped via SSE for real-time widgets
renderWidgetContent(widget, config, ctx)
)
)
)
}
),
// Gridstack initialization script
script(raw("""
document.addEventListener('DOMContentLoaded', function() {
var grid = GridStack.init({
cellHeight: 80,
margin: 10,
float: true,
removable: false
});
// Save layout on change
grid.on('change', function(event, items) {
var layout = items.map(function(item) {
return {
id: item.id,
x: item.x, y: item.y,
w: item.w, h: item.h
};
});
htmx.ajax('POST', '/admin/dashboard/save-layout', {
values: { layout: JSON.stringify(layout) }
});
});
});
"""))
)
}
```
### Pattern 5: SSE Endpoint for Real-Time Widget Updates
**What:** ZIO HTTP SSE endpoint streaming widget updates
**When to use:** Widgets requiring live data (stats, activity feeds)
**Example:**
```scala
// Source: ZIO HTTP SSE documentation + HTMX SSE extension
import zio._
import zio.http._
import zio.stream._
object WidgetStreamEndpoint:
def routes: Routes[WidgetRegistry & DashboardPreferenceService, Nothing] =
Routes(
Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") ->
handler { (alias: String, req: Request) =>
for
registry <- ZIO.service[WidgetRegistry]
prefs <- ZIO.service[DashboardPreferenceService]
userId <- extractUserId(req)
configs <- prefs.getPreferences(userId, "dashboard")
config <- ZIO.fromOption(configs.find(_.alias == alias))
.orElseFail(Response.notFound)
widget <- registry.resolveWidget(config.className)
.someOrFail(Response.notFound)
stream = widget.updates(toWidgetConfig(config)) match
case Some(updateStream) =>
updateStream.map { update =>
ServerSentEvent(
data = Some(update.content.render),
eventType = Some("content"),
id = Some(java.util.UUID.randomUUID().toString)
)
}
case None =>
// No updates - just keep connection alive with heartbeat
ZStream.tick(30.seconds).map { _ =>
ServerSentEvent(
data = Some(":heartbeat"),
eventType = Some("heartbeat")
)
}
yield Response(
status = Status.Ok,
headers = Headers(
Header.ContentType(MediaType.text.`event-stream`),
Header.CacheControl.NoCache
),
body = Body.fromCharSequenceStreamChunked(
stream.map(sse => sse.encode)
)
)
}
)
// Example widget with real-time updates
class ActiveUsersWidget extends DashboardWidget:
def widgetId = "active-users"
def name = "backend::lang.dashboard.active_users"
def description = "Shows currently active users"
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
getActiveUserCount.map { count =>
div(cls := "stat-widget",
span(cls := "stat-value", count.toString),
span(cls := "stat-label", "Active Users")
)
}
override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
Some(
ZStream.repeatZIOWithSchedule(
getActiveUserCount.map { count =>
WidgetUpdate(
config.alias,
div(cls := "stat-widget",
span(cls := "stat-value", count.toString),
span(cls := "stat-label", "Active Users")
)
)
}.orDie,
Schedule.fixed(5.seconds)
)
)
```
### Pattern 6: Settings Manager for Plugin Settings
**What:** Centralized registry of plugin settings pages with categories
**When to use:** Plugins registering configuration pages
**Example:**
```scala
// Source: WinterCMS SettingsManager pattern
import zio._
// Standard categories (translation keys)
object SettingsCategory:
val System = "system::lang.system.categories.system"
val CMS = "system::lang.system.categories.cms"
val Users = "system::lang.system.categories.users"
val Mail = "system::lang.system.categories.mail"
val Misc = "system::lang.system.categories.misc"
case class SettingsItem(
code: String,
owner: String, // Plugin ID: "golem15.blog"
label: String, // Translation key
description: Option[String],
category: String, // SettingsCategory constant
icon: Option[String],
url: Option[String], // Custom URL, or auto-generated
settingsClass: Option[String], // Settings model class name
permissions: List[String],
order: Int = 500,
keywords: Option[String] = None // For search
)
trait SettingsManager:
def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit]
def listItems(context: Option[String] = None): UIO[Map[String, List[SettingsItem]]]
def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
def setContext(owner: String, code: String): UIO[Unit]
def getContext: UIO[(String, String)]
object SettingsManager:
val live: ZLayer[Any, Nothing, SettingsManager] =
ZLayer.fromZIO {
for
itemsRef <- Ref.make(Map.empty[String, SettingsItem])
contextRef <- Ref.make(("", ""))
yield new SettingsManager:
def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] =
itemsRef.update { current =>
items.foldLeft(current) { case (acc, (code, item)) =>
val key = s"${owner.toUpperCase}.$${code.toUpperCase}"
val itemWithOwner = item.copy(
code = code,
owner = owner,
url = item.url.orElse(Some(generateSettingsUrl(owner, code)))
)
acc + (key -> itemWithOwner)
}
}
def listItems(context: Option[String]): UIO[Map[String, List[SettingsItem]]] =
itemsRef.get.map { items =>
items.values
.groupBy(_.category)
.view.mapValues(_.toList.sortBy(_.order))
.toMap
}
def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))
def setContext(owner: String, code: String): UIO[Unit] =
contextRef.set((owner.toLowerCase, code.toLowerCase))
def getContext: UIO[(String, String)] =
contextRef.get
private def generateSettingsUrl(owner: String, code: String): String =
val parts = owner.split('.')
s"/admin/system/settings/update/${parts.mkString("/")}/$code"
}
// Plugin registration
def registerSettings: Map[String, SettingsItem] = Map(
"blog" -> SettingsItem(
code = "blog",
owner = "golem15.blog",
label = "golem15.blog::lang.settings.label",
description = Some("golem15.blog::lang.settings.description"),
category = SettingsCategory.CMS,
icon = Some("icon-pencil"),
settingsClass = Some("golem15.blog.models.Settings"),
permissions = List("golem15.blog.manage_settings"),
order = 500,
keywords = Some("blog post category")
)
)
```
### Pattern 7: Settings Model Behavior
**What:** Model behavior for YAML-driven settings storage
**When to use:** Plugin settings models stored in system_settings table
**Example:**
```scala
// Source: WinterCMS SettingsModel behavior
import zio._
import io.circe.Json
trait SettingsModel:
def settingsCode: String
def settingsFields: String // Path to fields.yaml
// Mixin implementation
private var fieldValues: Map[String, Any] = Map.empty
private var instance: Option[SettingsModel] = None
/** Get singleton instance (loads from DB or creates default) */
def getInstance: Task[SettingsModel] =
instance match
case Some(inst) => ZIO.succeed(inst)
case None =>
loadFromDatabase.flatMap {
case Some(record) =>
ZIO.succeed {
fieldValues = record.values
instance = Some(this)
this
}
case None =>
ZIO.succeed {
initSettingsData()
instance = Some(this)
this
}
}
/** Override to set default values */
def initSettingsData(): Unit = ()
/** Get a setting value */
def get[T](key: String, default: T): T =
fieldValues.get(key).map(_.asInstanceOf[T]).getOrElse(default)
/** Set setting values */
def set(values: Map[String, Any]): Task[Unit] =
ZIO.attempt {
fieldValues = fieldValues ++ values
} *> saveToDatabase
/** Reset to defaults */
def resetDefault: Task[Unit] =
deleteFromDatabase *> ZIO.succeed {
fieldValues = Map.empty
instance = None
}
// Database operations (using system_settings table)
private def loadFromDatabase: Task[Option[SettingsRecord]] = ???
private def saveToDatabase: Task[Unit] = ???
private def deleteFromDatabase: Task[Unit] = ???
// Example settings model
class BlogSettings extends SettingsModel:
val settingsCode = "golem15_blog_settings"
val settingsFields = "fields.yaml"
override def initSettingsData(): Unit =
fieldValues = Map(
"posts_per_page" -> 10,
"show_author" -> true,
"excerpt_length" -> 200
)
// Usage
val postsPerPage = BlogSettings.getInstance.flatMap { settings =>
ZIO.succeed(settings.get("posts_per_page", 10))
}
```
### Anti-Patterns to Avoid
- **Polling for updates:** Use SSE streams instead of periodic AJAX polling
- **Global layout storage:** Each user must have their own layout preferences
- **Inline widget configuration:** Use YAML-driven property definitions for consistency
- **Blocking widget renders:** Widget render should be ZIO Task, not blocking
- **Shared mutable state:** Use ZIO Ref for widget registry and settings state
- **Direct database queries in widgets:** Use service layer for data access
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Dashboard grid layout | Custom CSS Grid + JS | Gridstack.js | Drag-drop, resize, save/restore, mobile support |
| Real-time updates | Custom WebSocket protocol | ZIO HTTP SSE + HTMX ext | Standard protocol, automatic reconnection |
| User preferences storage | Session/cookie storage | Database per-user table | Persists across sessions, multi-device |
| Widget property forms | Custom form generation | Phase 7 form system | Consistency, YAML-driven, validation |
| Settings form generation | Custom forms per plugin | SettingsModel behavior | Standardized, YAML-driven, cached |
**Key insight:** Dashboard widgets are deceptively complex. Each widget needs configuration UI, persistence, permissions, and potentially real-time updates. Reuse WinterCMS patterns for the container/widget relationship rather than building ad-hoc.
## Common Pitfalls
### Pitfall 1: SSE Connection Limits
**What goes wrong:** Browser limits concurrent SSE connections (typically 6 per domain)
**Why it happens:** Each widget opens its own SSE connection
**How to avoid:**
- Single SSE connection per dashboard page with multiplexed events
- Use event names to route updates to correct widgets
- Send widget alias in event type: `event: widget-$alias`
**Warning signs:** Later widgets don't receive updates; "ERR_CONNECTION_LIMIT" in browser console
### Pitfall 2: Layout Save Race Conditions
**What goes wrong:** Rapid drag/resize operations cause lost updates
**Why it happens:** Multiple save requests overlap, last one wins
**How to avoid:**
- Debounce save operations (300-500ms after last change)
- Include full layout state in each save (not incremental updates)
- Server acknowledges save, client can retry on failure
**Warning signs:** Layout reverts unexpectedly; saved layout doesn't match visual
### Pitfall 3: Widget Configuration State Loss
**What goes wrong:** Widget config changes lost on page reload
**Why it happens:** Config saved to layout but not persisted to database
**How to avoid:**
- Widget config stored in preferences alongside position
- Config changes trigger preferences save immediately
- Default config comes from widget definition, overrides from preferences
**Warning signs:** "Reset" doesn't restore expected defaults; config resets on browser refresh
### Pitfall 4: Memory Leaks in Real-Time Widgets
**What goes wrong:** Dashboard becomes slow/unresponsive over time
**Why it happens:** SSE event handlers accumulate; old widget content not garbage collected
**How to avoid:**
- HTMX's SSE extension manages this automatically via sse-swap
- Avoid manual JavaScript event listeners without cleanup
- Use `sse-close` attribute for explicit cleanup on navigation
**Warning signs:** Growing memory usage; event handlers stack on widget reconfigure
### Pitfall 5: Settings Cache Invalidation
**What goes wrong:** Changed settings not reflected immediately
**Why it happens:** Settings cached at application level for performance
**How to avoid:**
- Clear cache on save (WinterCMS calls queue:restart)
- Use short TTL for frequently-changed settings
- Provide "clear cache" admin action
**Warning signs:** Changes require restart; inconsistent behavior across requests
### Pitfall 6: Category Translation and Ordering
**What goes wrong:** Settings categories display incorrectly or in wrong order
**Why it happens:** Categories are translation keys, not display strings; no explicit ordering
**How to avoid:**
- Define standard categories with explicit sort order
- Translate category keys at render time, not storage
- Allow plugins to register new categories with order hints
**Warning signs:** Untranslated category keys visible; random category order
## Code Examples
### Complete Dashboard Controller
```scala
// Source: WinterCMS backend/controllers/Index.php + Gridstack patterns
import zio._
import zio.http._
import scalatags.Text.all._
class DashboardController(
widgetRegistry: WidgetRegistry,
prefsService: DashboardPreferenceService,
auth: AuthService
):
def index: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
widgets <- loadUserDashboard(user.id, "dashboard")
html <- renderDashboard(widgets, canAddAndDelete = true)
yield Response.html(html)
}
def saveLayout: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
layout <- req.body.asString.flatMap(parseLayoutJson)
_ <- prefsService.setPreferences(user.id, "dashboard", layout)
yield Response.ok
}
def addWidgetPopup: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
widgets <- widgetRegistry.listWidgetsForContext("dashboard")
// Filter by user permissions
available = widgets.filter { case (_, info) =>
info.permissions.isEmpty || user.hasAnyAccess(info.permissions)
}
yield Response.html(renderAddWidgetPopup(available))
}
def addWidget: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
form <- req.body.asURLEncodedForm
className <- ZIO.fromOption(form.get("className"))
size <- ZIO.fromOption(form.get("size").flatMap(_.toIntOption))
widget <- widgetRegistry.resolveWidget(className)
.someOrFail(Response.badRequest("Invalid widget class"))
// Get current layout to determine new widget position
current <- prefsService.getPreferences(user.id, "dashboard")
nextOrder = current.map(_.sortOrder).maxOption.getOrElse(0) + 1
alias = s"widget_${user.id}_$nextOrder"
newWidget = StoredWidgetConfig(
className = className,
alias = alias,
sortOrder = nextOrder,
configuration = Map(
"ocWidgetWidth" -> size,
"x" -> 0, "y" -> current.size, // Add at bottom
"w" -> size, "h" -> 2
)
)
_ <- prefsService.setPreferences(user.id, "dashboard", current :+ newWidget)
// Return partial HTML for the new widget
config = toWidgetConfig(newWidget)
html <- widget.render(config, DashboardContext.forUser(user))
yield Response.html(renderGridstackItem(alias, widget, config, html))
}
def removeWidget: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
alias <- req.url.queryParams.get("alias").flatMap(_.headOption)
.fold(ZIO.fail(Response.badRequest("Missing alias")))(ZIO.succeed)
current <- prefsService.getPreferences(user.id, "dashboard")
updated = current.filterNot(_.alias == alias)
_ <- prefsService.setPreferences(user.id, "dashboard", updated)
yield Response.ok
}
def resetDashboard: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
_ <- prefsService.resetToDefault(user.id, "dashboard")
yield Response.redirect("/admin")
}
```
### Settings Controller with Generic CRUD
```scala
// Source: WinterCMS system/controllers/Settings.php
import zio._
import zio.http._
class SettingsController(
settingsManager: SettingsManager,
formRenderer: FormRenderer,
auth: AuthService
):
def index: Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
for
user <- auth.getUser(req)
items <- settingsManager.listItems()
// Filter by permissions
filtered = items.view.mapValues(_.filter { item =>
item.permissions.isEmpty || user.hasAnyAccess(item.permissions)
}).filterNot(_._2.isEmpty).toMap
yield Response.html(renderSettingsIndex(filtered))
}
def update(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
val owner = s"$author.$plugin"
for
user <- auth.getUser(req)
item <- settingsManager.findItem(owner, code)
.someOrFail(Response.notFound)
_ <- ZIO.when(item.permissions.nonEmpty && !user.hasAnyAccess(item.permissions)) {
ZIO.fail(Response.forbidden)
}
_ <- settingsManager.setContext(owner, code)
// Load settings model
model <- loadSettingsModel(item.settingsClass.get)
instance <- model.getInstance
// Parse YAML fields definition
fieldsConfig <- parseFieldsYaml(model.settingsFields)
// Render form
form <- formRenderer.render(fieldsConfig, instance.getValues)
yield Response.html(renderSettingsForm(item, form))
}
def save(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
handler { (req: Request) =>
val owner = s"$author.$plugin"
for
user <- auth.getUser(req)
item <- settingsManager.findItem(owner, code)
.someOrFail(Response.notFound)
model <- loadSettingsModel(item.settingsClass.get)
instance <- model.getInstance
// Parse form data
formData <- req.body.asURLEncodedForm
values = formData.view.mapValues(_.head).toMap
// Validate (optional - use model validation rules)
_ <- validateSettings(model, values)
// Save
_ <- instance.set(values)
// Flash success message
yield Response.redirect(s"/admin/system/settings/update/$author/$plugin/$code")
.addHeader(Header.Custom("X-Flash", "Settings saved successfully"))
}
```
### Default Dashboard Configuration YAML
```yaml
# config_dashboard.yaml
# Source: WinterCMS backend/controllers/index/config_dashboard.yaml
defaultWidgets:
welcome:
class: summercms.admin.widgets.WelcomeWidget
sortOrder: 50
configuration:
ocWidgetWidth: 7
x: 0
y: 0
w: 7
h: 3
systemStatus:
class: summercms.admin.widgets.SystemStatusWidget
sortOrder: 60
configuration:
ocWidgetWidth: 5
x: 7
y: 0
w: 5
h: 3
recentActivity:
class: summercms.admin.widgets.RecentActivityWidget
sortOrder: 70
configuration:
ocWidgetWidth: 12
x: 0
y: 3
w: 12
h: 4
itemCount: 10
```
### Widget Property Configuration Popup
```scala
// Source: WinterCMS ReportContainer property config patterns
def renderWidgetConfigPopup(
alias: String,
widget: DashboardWidget,
currentConfig: WidgetConfig
): Frag = {
val properties = widget.defineProperties
div(cls := "modal-dialog",
div(cls := "modal-content",
div(cls := "modal-header",
h5(cls := "modal-title", "Widget Configuration"),
button(`type` := "button", cls := "btn-close",
attr("data-bs-dismiss") := "modal")
),
form(
attr("hx-post") := s"/admin/dashboard/widget-config/$alias",
attr("hx-target") := s"#widget-content-$alias",
attr("hx-swap") := "innerHTML",
div(cls := "modal-body",
properties.map { case (propName, propDef) =>
div(cls := "form-group",
label(cls := "form-label", propDef.title),
propDef.description.map(d => p(cls := "help-block", d)),
renderPropertyInput(propName, propDef, currentConfig.properties.get(propName))
)
}
),
div(cls := "modal-footer",
button(`type` := "button", cls := "btn btn-secondary",
attr("data-bs-dismiss") := "modal", "Cancel"),
button(`type` := "submit", cls := "btn btn-primary", "Save")
)
)
)
)
}
def renderPropertyInput(name: String, propDef: PropertyDef, value: Option[Any]): Frag = {
val currentValue = value.orElse(propDef.default).map(_.toString).getOrElse("")
propDef.`type` match
case PropertyType.String =>
input(cls := "form-control", `type` := "text",
attr("name") := name, attr("value") := currentValue)
case PropertyType.Number =>
input(cls := "form-control", `type` := "number",
attr("name") := name, attr("value") := currentValue)
case PropertyType.Checkbox =>
div(cls := "form-check",
input(cls := "form-check-input", `type` := "checkbox",
attr("name") := name,
(currentValue == "true").option(attr("checked") := "checked"))
)
case PropertyType.Dropdown =>
select(cls := "form-select", attr("name") := name,
propDef.options.map { case (optValue, optLabel) =>
option(attr("value") := optValue,
(currentValue == optValue).option(attr("selected") := "selected"),
optLabel)
}
)
case PropertyType.DateRange =>
input(cls := "form-control flatpickr",
`type` := "text", attr("name") := name, attr("value") := currentValue,
attr("data-mode") := "range")
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| jQuery UI sortable | Gridstack.js | 2020+ | Better mobile support, TypeScript, maintained |
| Polling for updates | SSE/WebSocket | 2018+ | Real-time, lower overhead |
| Session-based prefs | Database per-user | Standard | Persists, multi-device |
| Manual AJAX widget refresh | HTMX SSE extension | 2023+ | Declarative, automatic reconnection |
| PHP serialized layout | JSON layout storage | Standard | Interoperable, queryable |
**Deprecated/outdated:**
- jQuery UI Sortable: Use Gridstack.js for dashboard grids
- Isotope/Packery: Gridstack specifically designed for dashboards
- Long-polling: Use SSE for server-push updates
- Cookie-based preferences: Use database storage for persistence
## Open Questions
1. **Multiplexed SSE vs Multiple Connections**
- What we know: Browser limits concurrent connections (6 per domain)
- What's unclear: Best pattern for many real-time widgets
- Recommendation: Single SSE connection with event routing by widget alias
2. **Widget Caching Strategy**
- What we know: Some widgets expensive to render (database queries)
- What's unclear: How to cache widget HTML while maintaining real-time updates
- Recommendation: Cache initial render, SSE updates bypass cache
3. **Admin-Set Default Layout**
- What we know: WinterCMS allows admins to "make layout default"
- What's unclear: Should super-admin override affect existing user layouts?
- Recommendation: System default separate from user prefs; "reset" restores system default
4. **Widget Asset Loading**
- What we know: Some widgets need additional JS/CSS (Chart.js)
- What's unclear: How to lazy-load widget assets only when widget is used
- Recommendation: Define asset dependencies in widget class, inject on dashboard load
## Sources
### Primary (HIGH confidence)
- WinterCMS reference implementation:
- `modules/backend/widgets/ReportContainer.php` - Dashboard container pattern
- `modules/backend/classes/ReportWidgetBase.php` - Widget base class
- `modules/backend/classes/WidgetManager.php` - Widget registration
- `modules/system/classes/SettingsManager.php` - Settings registration
- `modules/system/behaviors/SettingsModel.php` - Settings persistence
- `modules/backend/models/UserPreference.php` - Per-user storage
- [HTMX SSE Extension](https://htmx.org/extensions/sse/) - SSE frontend integration
- [Gridstack.js](https://gridstackjs.com/) - Dashboard grid layout library
- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - SSE endpoint implementation
### Secondary (MEDIUM confidence)
- [Designing Scalable Database for User Settings](https://medium.com/@jorgemudry/designing-a-scalable-database-for-user-settings-8e189a678a85) - Preferences schema patterns
- [Building Real-Time Dashboards with FastAPI and HTMX](https://medium.com/codex/building-real-time-dashboards-with-fastapi-and-htmx-01ea458673cb) - SSE dashboard patterns
### Tertiary (LOW confidence)
- WebSearch results on dashboard widget best practices 2025-2026
- Community discussions on SSE vs WebSocket tradeoffs
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - Gridstack.js docs + ZIO HTTP SSE verified
- Architecture: HIGH - Based on thorough WinterCMS reference analysis
- Pitfalls: MEDIUM - Mix of WinterCMS patterns and web development experience
- Real-time patterns: MEDIUM - ZIO SSE documented, HTMX integration well-established
**Research date:** 2026-02-05
**Valid until:** 2026-03-05 (30 days - stable domain)