Files
Jakub Zych 5b0928f292 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
2026-02-05 15:16:30 +01:00

30 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
08-admin-dashboard 03 execute 2
08-01
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
true
truths artifacts key_links
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
path provides contains
summercms/admin/src/dashboard/Dashboard.scala Dashboard container with Gridstack.js integration def renderDashboard
path provides exports
summercms/admin/src/dashboard/DashboardController.scala HTTP routes for dashboard operations
DashboardController
path provides contains
summercms/admin/src/dashboard/sse/WidgetEventStream.scala SSE endpoint for widget updates ServerSentEvent
path provides contains
summercms/admin/src/dashboard/widgets/WelcomeWidget.scala Welcome widget for new users class WelcomeWidget
path provides contains
summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala System status display widget class SystemStatusWidget
from to via pattern
DashboardController DashboardPreferenceService loads/saves user preferences prefsService.(get|set)Preferences
from to via pattern
Dashboard.scala WidgetEventStream sse-connect attribute in Gridstack items sse-connect.*widget-stream
from to via pattern
Dashboard.scala Gridstack.js grid-stack classes and initialization 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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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

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:

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:

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. 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:

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:

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:

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:

// 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:

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

<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>
After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`