--- 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" --- 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 @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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 Task 1: Dashboard Container and Controller summercms/admin/src/dashboard/Dashboard.scala summercms/admin/src/dashboard/DashboardController.scala 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""" Dashboard - SummerCMS
${content.render}
""" 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.
mill summercms.admin.compile succeeds Dashboard.renderDashboard generates HTML with grid-stack classes DashboardController routes compile 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
Task 2: Core Widgets summercms/admin/src/dashboard/widgets/WelcomeWidget.scala summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala 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 () ``` mill summercms.admin.compile succeeds WelcomeWidget, SystemStatusWidget, QuickActionsWidget all implement DashboardWidget SystemStatusWidget has updates() returning Some(ZStream) 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 Task 3: SSE Endpoint for Real-Time Updates summercms/admin/src/dashboard/sse/WidgetEventStream.scala 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 mill summercms.admin.compile succeeds WidgetEventStream.routes compiles SSE encoding produces valid event-stream format 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
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 - 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 After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`