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
1100 lines
41 KiB
Markdown
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)
|