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:
862
.planning/phases/08-admin-dashboard/08-03-PLAN.md
Normal file
862
.planning/phases/08-admin-dashboard/08-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user