docs(08): create phase plan

Phase 08: Admin Dashboard
- 4 plan(s) in 2 wave(s)
- 2 parallel (08-01, 08-02), 2 sequential (08-03, 08-04)
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 15:16:30 +01:00
parent e0acf7d93a
commit 5b0928f292
5 changed files with 2313 additions and 4 deletions

View File

@@ -0,0 +1,862 @@
---
phase: 08-admin-dashboard
plan: 03
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
- summercms/admin/src/dashboard/Dashboard.scala
- summercms/admin/src/dashboard/DashboardController.scala
- summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
- summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
- summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
- summercms/admin/src/dashboard/sse/WidgetEventStream.scala
autonomous: true
must_haves:
truths:
- "Admin sees dashboard with Gridstack.js grid after login"
- "Widgets can be dragged, resized, and layout is saved"
- "Real-time widgets receive updates via SSE"
- "Default widgets display on fresh install"
artifacts:
- path: "summercms/admin/src/dashboard/Dashboard.scala"
provides: "Dashboard container with Gridstack.js integration"
contains: "def renderDashboard"
- path: "summercms/admin/src/dashboard/DashboardController.scala"
provides: "HTTP routes for dashboard operations"
exports: ["DashboardController"]
- path: "summercms/admin/src/dashboard/sse/WidgetEventStream.scala"
provides: "SSE endpoint for widget updates"
contains: "ServerSentEvent"
- path: "summercms/admin/src/dashboard/widgets/WelcomeWidget.scala"
provides: "Welcome widget for new users"
contains: "class WelcomeWidget"
- path: "summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala"
provides: "System status display widget"
contains: "class SystemStatusWidget"
key_links:
- from: "DashboardController"
to: "DashboardPreferenceService"
via: "loads/saves user preferences"
pattern: "prefsService\\.(get|set)Preferences"
- from: "Dashboard.scala"
to: "WidgetEventStream"
via: "sse-connect attribute in Gridstack items"
pattern: "sse-connect.*widget-stream"
- from: "Dashboard.scala"
to: "Gridstack.js"
via: "grid-stack classes and initialization"
pattern: "grid-stack"
---
<objective>
Build the dashboard container with Gridstack.js grid layout, core widgets (Welcome, SystemStatus, QuickActions), and SSE endpoint for real-time widget updates.
Purpose: Deliver the customizable dashboard admins see after login
Output: Dashboard container, DashboardController, SSE endpoint, 3 core widgets
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/08-admin-dashboard/08-RESEARCH.md
# Depends on 08-01 output
@summercms/admin/src/dashboard/DashboardWidget.scala
@summercms/admin/src/dashboard/WidgetRegistry.scala
@summercms/admin/src/dashboard/DashboardPreferenceService.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Dashboard Container and Controller</name>
<files>
summercms/admin/src/dashboard/Dashboard.scala
summercms/admin/src/dashboard/DashboardController.scala
</files>
<action>
Create the dashboard container and HTTP routes:
**Dashboard.scala** - Render the dashboard with Gridstack.js:
```scala
package summercms.admin.dashboard
import scalatags.Text.all._
import scalatags.Text.tags2.title as pageTitle
import zio._
object Dashboard:
/** Render full dashboard page */
def renderDashboard(
widgets: List[(String, DashboardWidget, WidgetConfig)],
canAddAndDelete: Boolean,
ctx: DashboardContext
): Task[Frag] =
for
renderedWidgets <- ZIO.foreach(widgets) { case (alias, widget, config) =>
widget.render(config, ctx).map(content => (alias, widget, config, content))
}
yield frag(
// Toolbar
div(cls := "dashboard-toolbar mb-3 d-flex gap-2",
if canAddAndDelete then
button(cls := "btn btn-primary",
attr("hx-get") := "/admin/dashboard/add-widget-popup",
attr("hx-target") := "#modal-container",
i(cls := "icon-plus me-1"), "Add Widget"
)
else frag(),
button(cls := "btn btn-secondary",
attr("hx-post") := "/admin/dashboard/reset",
attr("hx-confirm") := "Reset dashboard to default layout?",
i(cls := "icon-refresh me-1"), "Reset to Default"
)
),
// Grid container
div(cls := "grid-stack", id := "dashboard-grid",
renderedWidgets.map { case (alias, widget, config, content) =>
renderGridItem(alias, widget, config, content, canAddAndDelete)
}
),
// Gridstack initialization
script(raw(s"""
document.addEventListener('DOMContentLoaded', function() {
var grid = GridStack.init({
cellHeight: 80,
margin: 10,
float: true,
removable: false,
animate: true
});
// Debounce save (500ms after last change)
var saveTimeout = null;
grid.on('change', function(event, items) {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(function() {
var layout = items.map(function(item) {
return {
alias: 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) }
});
}, 500);
});
});
"""))
)
/** Render a single grid item */
private def renderGridItem(
alias: String,
widget: DashboardWidget,
config: WidgetConfig,
content: Frag,
canDelete: Boolean
): Frag =
val pos = config.position
val hasUpdates = widget.updates(config).isDefined
div(cls := "grid-stack-item",
attr("gs-id") := alias,
attr("gs-x") := pos.x.toString,
attr("gs-y") := pos.y.toString,
attr("gs-w") := pos.w.toString,
attr("gs-h") := pos.h.toString,
attr("gs-min-w") := "2",
attr("gs-min-h") := "2",
div(cls := "grid-stack-item-content widget-container card",
// SSE connection for real-time updates
if hasUpdates then Seq(
attr("hx-ext") := "sse",
attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
attr("sse-swap") := "content"
) else Nil,
// Widget header
div(cls := "card-header d-flex justify-content-between align-items-center",
span(cls := "widget-title fw-bold",
config.properties.get("title").map(_.toString).getOrElse(widget.name)
),
div(cls := "widget-controls",
button(cls := "btn btn-sm btn-link",
attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
attr("hx-target") := "#modal-container",
attr("title") := "Configure",
i(cls := "icon-cog")
),
if canDelete then
button(cls := "btn btn-sm btn-link text-danger",
attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
attr("hx-confirm") := "Remove this widget?",
attr("title") := "Remove",
i(cls := "icon-trash")
)
else frag()
)
),
// Widget body
div(cls := "card-body widget-body", id := s"widget-content-$alias",
content
)
)
)
/** Render add widget popup */
def renderAddWidgetPopup(
available: Map[String, (WidgetInfo, DashboardWidget)]
): Frag =
div(cls := "modal-dialog modal-lg",
div(cls := "modal-content",
div(cls := "modal-header",
h5(cls := "modal-title", "Add Widget"),
button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "modal")
),
div(cls := "modal-body",
div(cls := "row g-3",
available.toList.sortBy(_._2._1.name).map { case (widgetId, (info, widget)) =>
div(cls := "col-md-6",
div(cls := "card h-100 widget-option",
attr("hx-post") := s"/admin/dashboard/add-widget",
attr("hx-vals") := s"""{"widgetId": "$widgetId"}""",
attr("hx-swap") := "none",
attr("onclick") := "bootstrap.Modal.getInstance(this.closest('.modal')).hide()",
div(cls := "card-body",
h6(cls := "card-title", info.name),
p(cls := "card-text text-muted small", info.description)
)
)
)
}
)
)
)
)
```
**DashboardController.scala** - HTTP routes:
```scala
package summercms.admin.dashboard
import zio._
import zio.http._
import io.circe._
import io.circe.parser._
import io.circe.syntax._
class DashboardController(
widgetRegistry: WidgetRegistry,
prefsService: DashboardPreferenceService
):
def routes: Routes[Any, Nothing] =
Routes(
// Main dashboard page
Method.GET / "admin" -> handler { (req: Request) =>
renderIndex(req)
},
// Save layout (AJAX)
Method.POST / "admin" / "dashboard" / "save-layout" -> handler { (req: Request) =>
saveLayout(req)
},
// Add widget popup
Method.GET / "admin" / "dashboard" / "add-widget-popup" -> handler { (req: Request) =>
addWidgetPopup(req)
},
// Add widget
Method.POST / "admin" / "dashboard" / "add-widget" -> handler { (req: Request) =>
addWidget(req)
},
// Remove widget
Method.POST / "admin" / "dashboard" / "remove-widget" / string("alias") -> handler {
(alias: String, req: Request) => removeWidget(alias, req)
},
// Reset to default
Method.POST / "admin" / "dashboard" / "reset" -> handler { (req: Request) =>
resetDashboard(req)
},
// Widget config popup
Method.GET / "admin" / "dashboard" / "widget-config" / string("alias") -> handler {
(alias: String, req: Request) => widgetConfigPopup(alias, req)
}
)
private def renderIndex(req: Request): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
ctx = DashboardContext(userId, "Admin", Set.empty) // TODO: real user
configs <- prefsService.getPreferences(userId, "dashboard")
widgets <- ZIO.foreach(configs) { stored =>
widgetRegistry.resolveWidget(stored.widgetId).map { maybeWidget =>
maybeWidget.map { widget =>
(stored.alias, widget, toWidgetConfig(stored))
}
}
}.map(_.flatten)
html <- Dashboard.renderDashboard(widgets, canAddAndDelete = true, ctx)
yield Response.html(wrapInLayout(html))).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
private def saveLayout(req: Request): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
body <- req.body.asString
form <- ZIO.fromEither(parseFormData(body))
layoutJson <- ZIO.fromOption(form.get("layout"))
.orElseFail(new RuntimeException("Missing layout"))
layoutUpdates <- ZIO.fromEither(parse(layoutJson).flatMap(_.as[List[LayoutUpdate]]))
currentPrefs <- prefsService.getPreferences(userId, "dashboard")
// Merge layout updates into current preferences
updatedPrefs = currentPrefs.map { stored =>
layoutUpdates.find(_.alias == stored.alias) match
case Some(update) =>
stored.copy(configuration = stored.configuration ++ Map(
"x" -> Json.fromInt(update.x),
"y" -> Json.fromInt(update.y),
"w" -> Json.fromInt(update.w),
"h" -> Json.fromInt(update.h)
))
case None => stored
}
_ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
yield Response.ok).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
}
private def addWidgetPopup(req: Request): ZIO[Any, Nothing, Response] =
(for
available <- widgetRegistry.listWidgetsForContext("dashboard")
html = Dashboard.renderAddWidgetPopup(available)
yield Response.html(html.render)).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
private def addWidget(req: Request): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
body <- req.body.asString
form <- ZIO.fromEither(parseFormData(body))
widgetId <- ZIO.fromOption(form.get("widgetId"))
.orElseFail(new RuntimeException("Missing widgetId"))
widget <- widgetRegistry.resolveWidget(widgetId)
.someOrFail(new RuntimeException(s"Unknown widget: $widgetId"))
currentPrefs <- prefsService.getPreferences(userId, "dashboard")
nextOrder = currentPrefs.map(_.sortOrder).maxOption.getOrElse(0) + 10
alias = s"widget_${System.currentTimeMillis()}"
newConfig = StoredWidgetConfig(
widgetId = widgetId,
alias = alias,
sortOrder = nextOrder,
configuration = Map(
"x" -> Json.fromInt(0),
"y" -> Json.fromInt(currentPrefs.size),
"w" -> Json.fromInt(widget.defaultPosition.w),
"h" -> Json.fromInt(widget.defaultPosition.h)
)
)
_ <- prefsService.setPreferences(userId, "dashboard", currentPrefs :+ newConfig)
yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
}
private def removeWidget(alias: String, req: Request): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
currentPrefs <- prefsService.getPreferences(userId, "dashboard")
updatedPrefs = currentPrefs.filterNot(_.alias == alias)
_ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
private def resetDashboard(req: Request): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
_ <- prefsService.resetToDefault(userId, "dashboard")
yield Response.redirect(URL.decode("/admin").toOption.get)).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
// Helper methods
private def extractUserId(req: Request): Task[Long] =
// TODO: Extract from session/JWT. For now, return test user ID
ZIO.succeed(1L)
private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
WidgetConfig(
alias = stored.alias,
properties = stored.configuration.view.mapValues(jsonToAny).toMap,
position = WidgetPosition(
x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
)
)
private def jsonToAny(json: Json): Any =
json.fold(
jsonNull = null,
jsonBoolean = identity,
jsonNumber = n => n.toInt.getOrElse(n.toDouble),
jsonString = identity,
jsonArray = _.map(jsonToAny).toList,
jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
)
private def parseFormData(body: String): Either[Throwable, Map[String, String]] =
Right(body.split("&").flatMap { pair =>
pair.split("=", 2) match
case Array(k, v) => Some(java.net.URLDecoder.decode(k, "UTF-8") -> java.net.URLDecoder.decode(v, "UTF-8"))
case _ => None
}.toMap)
private def wrapInLayout(content: Frag): String =
// TODO: Use proper admin layout from Phase 7
s"""<!DOCTYPE html>
<html>
<head>
<title>Dashboard - SummerCMS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet">
<style>
.grid-stack-item-content { overflow: hidden; }
.widget-option { cursor: pointer; transition: transform 0.2s; }
.widget-option:hover { transform: scale(1.02); }
</style>
</head>
<body>
<div class="container-fluid py-4">
${content.render}
</div>
<div id="modal-container" class="modal fade"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.4/sse.js"></script>
</body>
</html>"""
case class LayoutUpdate(alias: String, x: Int, y: Int, w: Int, h: Int)
object LayoutUpdate:
given Decoder[LayoutUpdate] = io.circe.generic.semiauto.deriveDecoder
```
Note: extractUserId returns hardcoded 1L for now - will be integrated with Phase 6 auth.
</action>
<verify>
mill summercms.admin.compile succeeds
Dashboard.renderDashboard generates HTML with grid-stack classes
DashboardController routes compile
</verify>
<done>
Dashboard.scala renders Gridstack.js grid with widget items
Gridstack initialization includes debounced save on layout change
DashboardController handles index, save-layout, add/remove widget, reset
Layout merges position updates from Gridstack.js into stored preferences
</done>
</task>
<task type="auto">
<name>Task 2: Core Widgets</name>
<files>
summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
</files>
<action>
Create the default dashboard widgets:
**WelcomeWidget.scala** - Welcome message for admins:
```scala
package summercms.admin.dashboard.widgets
import scalatags.Text.all._
import zio._
import summercms.admin.dashboard._
class WelcomeWidget extends DashboardWidget:
val widgetId = "welcome"
val name = "Welcome"
val description = "Welcome message with getting started tips"
override def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 7, 3)
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
ZIO.succeed(
div(cls := "welcome-widget",
h4(cls := "mb-3", s"Welcome back, ${ctx.userName}!"),
p(cls := "text-muted",
"This is your SummerCMS dashboard. Use the widgets below to monitor your site and access quick actions."
),
div(cls := "mt-3",
h6("Getting Started"),
ul(cls := "list-unstyled",
li(cls := "mb-2",
i(cls := "icon-book me-2"),
a(href := "/admin/docs", "Read the documentation")
),
li(cls := "mb-2",
i(cls := "icon-puzzle me-2"),
a(href := "/admin/system/updates", "Install plugins")
),
li(cls := "mb-2",
i(cls := "icon-brush me-2"),
a(href := "/admin/cms/themes", "Customize your theme")
)
)
)
)
)
```
**SystemStatusWidget.scala** - System health information:
```scala
package summercms.admin.dashboard.widgets
import scalatags.Text.all._
import zio._
import zio.stream._
import summercms.admin.dashboard._
class SystemStatusWidget extends DashboardWidget:
val widgetId = "system-status"
val name = "System Status"
val description = "Shows system health and resource usage"
override def defaultPosition: WidgetPosition = WidgetPosition(7, 0, 5, 3)
override def defineProperties: Map[String, PropertyDef] =
super.defineProperties ++ Map(
"showMemory" -> PropertyDef(
title = "Show Memory Usage",
`type` = PropertyType.Checkbox,
default = Some("true")
)
)
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
for
runtime <- ZIO.succeed(Runtime.getRuntime)
usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
maxMemory = runtime.maxMemory() / (1024 * 1024)
cpuCount = runtime.availableProcessors()
javaVersion = System.getProperty("java.version")
scalaVersion = util.Properties.versionNumberString
yield div(cls := "system-status-widget",
div(cls := "row g-3",
// JVM Info
div(cls := "col-6",
div(cls := "stat-card p-2 bg-light rounded",
div(cls := "stat-value h5 mb-0", s"Java $javaVersion"),
div(cls := "stat-label small text-muted", "JVM Version")
)
),
div(cls := "col-6",
div(cls := "stat-card p-2 bg-light rounded",
div(cls := "stat-value h5 mb-0", s"$cpuCount"),
div(cls := "stat-label small text-muted", "CPU Cores")
)
),
// Memory (if enabled)
if config.properties.get("showMemory").exists(_ == true) then
frag(
div(cls := "col-12 mt-2",
div(cls := "memory-bar",
label(cls := "small text-muted", s"Memory: $usedMemory MB / $maxMemory MB"),
div(cls := "progress mt-1",
div(cls := "progress-bar",
style := s"width: ${(usedMemory.toDouble / maxMemory * 100).toInt}%",
role := "progressbar"
)
)
)
)
)
else frag()
)
)
// Real-time updates every 30 seconds
override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
Some(
ZStream.repeatZIOWithSchedule(
render(config, DashboardContext(0, "", Set.empty)).map { content =>
WidgetUpdate(config.alias, content)
}.orDie,
Schedule.fixed(30.seconds)
)
)
```
**QuickActionsWidget.scala** - Common admin actions:
```scala
package summercms.admin.dashboard.widgets
import scalatags.Text.all._
import zio._
import summercms.admin.dashboard._
class QuickActionsWidget extends DashboardWidget:
val widgetId = "quick-actions"
val name = "Quick Actions"
val description = "Shortcuts to common admin tasks"
override def defaultPosition: WidgetPosition = WidgetPosition(0, 3, 12, 2)
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
ZIO.succeed(
div(cls := "quick-actions-widget",
div(cls := "d-flex flex-wrap gap-2",
actionButton("/admin/cms/pages/create", "icon-file", "New Page"),
actionButton("/admin/blog/posts/create", "icon-pencil", "New Post"),
actionButton("/admin/users/create", "icon-user", "New User"),
actionButton("/admin/system/settings", "icon-cog", "Settings"),
actionButton("/admin/cms/media", "icon-image", "Media Library"),
actionButton("/admin/system/updates", "icon-download", "Updates")
)
)
)
private def actionButton(href: String, icon: String, label: String): Frag =
a(cls := "btn btn-outline-secondary", attr("href") := href,
i(cls := s"$icon me-1"), label
)
```
Register widgets in application boot:
```scala
// In main application setup
for
registry <- ZIO.service[WidgetRegistry]
_ <- registry.registerWidget(new WelcomeWidget, WidgetInfo("Welcome", "Welcome message"))
_ <- registry.registerWidget(new SystemStatusWidget, WidgetInfo("System Status", "System health", permissions = List("system.access_dashboard")))
_ <- registry.registerWidget(new QuickActionsWidget, WidgetInfo("Quick Actions", "Common shortcuts"))
yield ()
```
</action>
<verify>
mill summercms.admin.compile succeeds
WelcomeWidget, SystemStatusWidget, QuickActionsWidget all implement DashboardWidget
SystemStatusWidget has updates() returning Some(ZStream)
</verify>
<done>
WelcomeWidget displays welcome message and getting started links
SystemStatusWidget shows JVM version, CPU cores, memory usage with real-time updates
QuickActionsWidget provides shortcuts to common admin tasks
All widgets define defaultPosition for initial layout
</done>
</task>
<task type="auto">
<name>Task 3: SSE Endpoint for Real-Time Updates</name>
<files>
summercms/admin/src/dashboard/sse/WidgetEventStream.scala
</files>
<action>
Create the SSE endpoint for streaming widget updates:
**WidgetEventStream.scala** - Multiplexed SSE for all widgets:
```scala
package summercms.admin.dashboard.sse
import zio._
import zio.http._
import zio.stream._
import summercms.admin.dashboard._
object WidgetEventStream:
/** Routes for SSE widget updates */
def routes(
widgetRegistry: WidgetRegistry,
prefsService: DashboardPreferenceService
): Routes[Any, Nothing] =
Routes(
// Single widget stream
Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") -> handler {
(alias: String, req: Request) =>
streamWidgetUpdates(alias, widgetRegistry, prefsService, req)
}
)
private def streamWidgetUpdates(
alias: String,
registry: WidgetRegistry,
prefs: DashboardPreferenceService,
req: Request
): ZIO[Any, Nothing, Response] =
(for
userId <- extractUserId(req)
configs <- prefs.getPreferences(userId, "dashboard")
stored <- ZIO.fromOption(configs.find(_.alias == alias))
.orElseFail(new RuntimeException(s"Widget not found: $alias"))
widget <- registry.resolveWidget(stored.widgetId)
.someOrFail(new RuntimeException(s"Unknown widget: ${stored.widgetId}"))
config = toWidgetConfig(stored)
// Get the update stream from the widget (if it provides one)
stream = widget.updates(config) match
case Some(updateStream) =>
// Map widget updates to SSE events
updateStream.map { update =>
ServerSentEvent(
data = Some(update.content.render),
eventType = Some("content"),
id = Some(java.util.UUID.randomUUID().toString)
)
}
case None =>
// Widget doesn't provide updates - just send heartbeats
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,
Header.Custom("X-Accel-Buffering", "no") // Disable nginx buffering
),
body = Body.fromCharSequenceStreamChunked(
stream.map(sse => encodeSSE(sse))
)
)).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.NotFound))
}
/** Encode SSE event to string format */
private def encodeSSE(event: ServerSentEvent): String =
val sb = new StringBuilder
event.id.foreach(id => sb.append(s"id: $id\n"))
event.eventType.foreach(t => sb.append(s"event: $t\n"))
event.data.foreach { data =>
// SSE data must have each line prefixed with "data: "
data.split("\n").foreach { line =>
sb.append(s"data: $line\n")
}
}
sb.append("\n")
sb.toString
private def extractUserId(req: Request): Task[Long] =
// TODO: Extract from session/JWT
ZIO.succeed(1L)
private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
WidgetConfig(
alias = stored.alias,
properties = stored.configuration.view.mapValues(jsonToAny).toMap,
position = WidgetPosition(
x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
)
)
private def jsonToAny(json: io.circe.Json): Any =
json.fold(
jsonNull = null,
jsonBoolean = identity,
jsonNumber = n => n.toInt.getOrElse(n.toDouble),
jsonString = identity,
jsonArray = _.map(jsonToAny).toList,
jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
)
/** SSE event representation */
case class ServerSentEvent(
data: Option[String] = None,
eventType: Option[String] = None,
id: Option[String] = None,
retry: Option[Int] = None
)
```
The SSE endpoint:
1. Looks up the widget by alias from user's preferences
2. Resolves the widget class from registry
3. Calls widget.updates() to get the update stream
4. Maps updates to SSE events and streams to client
5. HTMX SSE extension on frontend receives events and swaps content
Frontend integration (in Dashboard.scala) already has:
- `hx-ext="sse"` on widget container
- `sse-connect="/admin/dashboard/widget-stream/{alias}"` for connection
- `sse-swap="content"` to swap on "content" events
</action>
<verify>
mill summercms.admin.compile succeeds
WidgetEventStream.routes compiles
SSE encoding produces valid event-stream format
</verify>
<done>
SSE endpoint streams widget updates to connected clients
Widgets without updates() receive heartbeat events to keep connection alive
ServerSentEvent properly encoded with id, event, data fields
X-Accel-Buffering header disables nginx buffering for real-time
</done>
</task>
</tasks>
<verification>
1. `mill summercms.admin.compile` succeeds
2. Start server and navigate to /admin - dashboard renders with Gridstack
3. Drag/resize widgets - layout saves after 500ms debounce
4. SystemStatusWidget updates every 30 seconds via SSE
5. Add Widget popup shows available widgets
6. Remove widget removes from layout
7. Reset restores default layout
</verification>
<success_criteria>
- Dashboard page renders with Gridstack.js grid
- Widgets display with drag-drop and resize capabilities
- Layout changes saved to database automatically
- SSE endpoint streams updates to real-time widgets
- WelcomeWidget, SystemStatusWidget, QuickActionsWidget functional
- Add/Remove/Reset widget operations work
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`
</output>