monitor
package io.github.cymoo.cleary.dashboard
import io.github.cymoo.cleary.*
import io.github.cymoo.colleen.BadRequest
import io.github.cymoo.colleen.Colleen
import io.github.cymoo.colleen.NotFound
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
// =============================================================================
// Public Integration API
// =============================================================================
/**
* Attaches a [TaskSchedulerMonitor] to an already-created [TaskScheduler].
*
* Uses reflection to access the private `config` field and chains new callbacks
* with any previously registered ones, so nothing is lost.
*
* ### Usage
* ```kotlin
* val tasks = TaskScheduler { autoStart = true; registerShutdownHook = true }
* tasks.task("sync") { every(30.seconds); run { doSync() } }
* tasks.start()
*
* val monitor = tasks.attachMonitor()
*
* val app = Colleen()
* app.mount("/admin", monitor.dashboardApp(mountPath = "/admin"))
* app.listen(8080)
* ```
*/
fun TaskScheduler.attachMonitor(): TaskSchedulerMonitor {
val monitor = TaskSchedulerMonitor(this)
// config is private — access via reflection until it is made internal/public
val configField = TaskScheduler::class.java.getDeclaredField("config")
configField.isAccessible = true
val config = configField.get(this) as TaskSchedulerConfig
val prevStart = config.onTaskStart
val prevComplete = config.onTaskComplete
val prevRetry = config.onRetry
config.onTaskStart = { e -> prevStart?.invoke(e); monitor.onTaskStart(e) }
config.onTaskComplete = { e -> prevComplete?.invoke(e); monitor.onTaskComplete(e) }
config.onRetry = { e -> prevRetry?.invoke(e); monitor.onRetry(e) }
return monitor
}
/**
* Builds a self-contained [Colleen] sub-application to be mounted into a parent app.
* The parent's injected services are visible to all dashboard routes.
*
* [mountPath] **must match** the prefix passed to [Colleen.mount] so that the
* JavaScript inside the page can construct the correct API URLs. Because Colleen
* strips the mount prefix before the sub-app sees the request, the sub-app itself
* has no way to discover the prefix at runtime — the caller must supply it.
*
* ```kotlin
* app.mount("/admin", monitor.dashboardApp(mountPath = "/admin"))
* ```
*/
fun TaskSchedulerMonitor.dashboardApp(mountPath: String = ""): Colleen =
TaskSchedulerDashboard(this, mountPath = mountPath.trimEnd('/')).buildApp()
/**
* Standalone mode: spin up a dedicated server on [port].
* No mountPath needed — the dashboard is served at the root.
*
* ```kotlin
* monitor.startDashboard(port = 9090)
* ```
*/
fun TaskSchedulerMonitor.startDashboard(port: Int = 9090) =
TaskSchedulerDashboard(this, mountPath = "").start(port)
// =============================================================================
// Monitor — statistics collection
// =============================================================================
/**
* Immutable record of a single task execution.
* The task's return value is NOT stored here (kept separately as [LastResult]).
*/
data class ExecutionRecord(
val startTime: Long,
val endTime: Long,
val durationMs: Long,
val success: Boolean,
val errorMessage: String?,
val errorType: String?
) {
val startTimeFormatted: String get() = formatEpoch(startTime)
}
/**
* The most-recent successful result for a task.
* Stored as the raw JVM value and serialised lazily on demand.
*/
data class LastResult(
val capturedAt: Long,
val value: Any?,
val typeName: String,
val isNull: Boolean
) {
val capturedAtFormatted: String get() = formatEpoch(capturedAt)
}
/**
* Per-task statistics. All counters are thread-safe via [AtomicLong].
* The rolling [recentExecutions] window keeps at most 50 entries.
*/
class TaskStats(val taskName: String) {
val totalExecutions = AtomicLong(0)
val successfulExecutions = AtomicLong(0)
val failedExecutions = AtomicLong(0)
val totalRetries = AtomicLong(0)
val totalDurationMs = AtomicLong(0)
val lastExecutionTime = AtomicLong(0)
val lastFailureTime = AtomicLong(0)
@Volatile var lastErrorMessage: String? = null
@Volatile var lastErrorType: String? = null
@Volatile var currentlyExecuting: Boolean = false
/** Most-recent successful return value. Replaced on every successful run. */
@Volatile var lastResult: LastResult? = null
/** Rolling window: oldest at index 0, newest at the end. */
val recentExecutions: CopyOnWriteArrayList<ExecutionRecord> = CopyOnWriteArrayList()
fun avgDurationMs(): Long {
val n = totalExecutions.get()
return if (n == 0L) 0L else totalDurationMs.get() / n
}
fun successRate(): Double {
val n = totalExecutions.get()
return if (n == 0L) 100.0 else successfulExecutions.get().toDouble() / n * 100.0
}
internal fun addRecord(record: ExecutionRecord) {
recentExecutions.add(record)
while (recentExecutions.size > 50) {
recentExecutions.removeAt(0)
}
}
}
/**
* Observes a [TaskScheduler] and accumulates live statistics.
*
* Prefer [TaskScheduler.attachMonitor] (the extension function above) over
* constructing this class directly when the scheduler already exists.
*/
class TaskSchedulerMonitor(val scheduler: TaskScheduler) {
private val statsMap = ConcurrentHashMap<String, TaskStats>()
private val globalStartedAt = System.currentTimeMillis()
val globalTotalExecutions = AtomicLong(0)
val globalSuccessExecutions = AtomicLong(0)
val globalFailedExecutions = AtomicLong(0)
val globalTotalRetries = AtomicLong(0)
// ── Callback handlers ────────────────────────────────────────────────────
internal fun onTaskStart(event: TaskStartEvent) {
statsFor(event.taskName).currentlyExecuting = true
}
internal fun onTaskComplete(event: TaskCompleteEvent) {
val stats = statsFor(event.taskName)
stats.currentlyExecuting = false
stats.totalExecutions.incrementAndGet()
stats.totalDurationMs.addAndGet(event.duration)
stats.lastExecutionTime.set(event.endTime)
globalTotalExecutions.incrementAndGet()
if (event.isSuccess) {
stats.successfulExecutions.incrementAndGet()
globalSuccessExecutions.incrementAndGet()
// Capture only the most-recent successful result
stats.lastResult = LastResult(
capturedAt = event.endTime,
value = event.result,
typeName = event.result?.javaClass?.simpleName ?: "null",
isNull = event.result == null
)
} else {
stats.failedExecutions.incrementAndGet()
stats.lastFailureTime.set(event.endTime)
stats.lastErrorMessage = event.error?.message ?: "Unknown error"
stats.lastErrorType = event.error?.javaClass?.simpleName
globalFailedExecutions.incrementAndGet()
}
stats.addRecord(
ExecutionRecord(
startTime = event.startTime,
endTime = event.endTime,
durationMs = event.duration,
success = event.isSuccess,
errorMessage = event.error?.message,
errorType = event.error?.javaClass?.simpleName
)
)
}
internal fun onRetry(event: TaskRetryEvent) {
statsFor(event.taskName).totalRetries.incrementAndGet()
globalTotalRetries.incrementAndGet()
}
// ── Public query API ─────────────────────────────────────────────────────
fun getStats(name: String): TaskStats? = statsMap[name]
fun getAllStats(): Map<String, TaskStats> = statsMap.toMap()
fun uptimeMs(): Long = System.currentTimeMillis() - globalStartedAt
private fun statsFor(name: String): TaskStats =
statsMap.getOrPut(name) { TaskStats(name) }
// ── Factory for new scheduler + monitor together ──────────────────────────
companion object {
/**
* Creates a brand-new [TaskScheduler] with monitoring already wired.
* Use [TaskScheduler.attachMonitor] when the scheduler already exists.
*
* ```kotlin
* val (tasks, monitor) = TaskSchedulerMonitor.create { autoStart = true }
* ```
*/
fun create(block: TaskSchedulerConfig.() -> Unit = {}): Pair<TaskScheduler, TaskSchedulerMonitor> {
var monitorRef: TaskSchedulerMonitor? = null
val scheduler = TaskScheduler {
block()
val prevStart = onTaskStart
val prevComplete = onTaskComplete
val prevRetry = onRetry
onTaskStart = { e -> prevStart?.invoke(e); monitorRef?.onTaskStart(e) }
onTaskComplete = { e -> prevComplete?.invoke(e); monitorRef?.onTaskComplete(e) }
onRetry = { e -> prevRetry?.invoke(e); monitorRef?.onRetry(e) }
}
val monitor = TaskSchedulerMonitor(scheduler)
monitorRef = monitor
return scheduler to monitor
}
}
}
// =============================================================================
// Dashboard web application
// =============================================================================
internal class TaskSchedulerDashboard(
private val monitor: TaskSchedulerMonitor,
/** The prefix at which this sub-app is mounted in the parent, e.g. "/admin". Empty for standalone. */
private val mountPath: String = ""
) {
private val scheduler get() = monitor.scheduler
/** The API base that JavaScript will use for all fetch() calls, e.g. "/admin/api". */
private val apiBase: String get() = "$mountPath/api"
/** Returns a mountable sub-application (no listen call). */
fun buildApp(): Colleen {
val app = Colleen()
app.provide(monitor)
mountRoutes(app)
return app
}
/** Standalone mode: builds the app and starts listening on [port]. */
fun start(port: Int) {
buildApp().listen(port)
println("✅ TaskScheduler Dashboard → http://localhost:$port/")
}
// ── Routes ────────────────────────────────────────────────────────────────
private fun mountRoutes(app: Colleen) {
// The HTML page.
//
// apiBase is computed from the mountPath supplied at construction time,
// e.g. mountPath="/admin" → apiBase="/admin/api".
// We cannot derive the mount prefix from ctx at runtime because Colleen
// strips it before the sub-app sees the request, so ctx.path is already
// relative to the sub-app root.
app.get("/") { ctx ->
ctx.html(buildHtml(apiBase))
}
// REST API
app.group("/api") {
get("/summary") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val names = m.scheduler.listTaskNames()
val enabled = names.count { m.scheduler.getTaskInfo(it)?.enabled == true }
val total = m.globalTotalExecutions.get()
val success = m.globalSuccessExecutions.get()
val payload = mapOf(
"isRunning" to (m.scheduler.isRunning && !m.scheduler.isTerminated),
"isTerminated" to m.scheduler.isTerminated,
"uptimeMs" to m.uptimeMs(),
"uptimeFormatted" to formatUptime(m.uptimeMs()),
"totalTasks" to names.size,
"enabledTasks" to enabled,
"disabledTasks" to (names.size - enabled),
"totalExecutions" to total,
"successExecutions" to success,
"failedExecutions" to m.globalFailedExecutions.get(),
"totalRetries" to m.globalTotalRetries.get(),
"globalSuccessRate" to if (total == 0L) 100.0
else success.toDouble() / total * 100.0
)
ctx.json(payload)
}
get("/tasks") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
ctx.json(m.scheduler.listTaskNames().map { buildTaskRow(m, it) })
}
get("/tasks/{name}") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
ctx.json(buildTaskDetail(m, name))
}
// Full result — fetched separately on demand to keep the list payload light
get("/tasks/{name}/result") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
val lr = m.getStats(name)?.lastResult
val payload = if (lr == null) {
mapOf("hasResult" to false)
} else {
mapOf(
"hasResult" to true,
"capturedAtFormatted" to lr.capturedAtFormatted,
"typeName" to lr.typeName,
"isNull" to lr.isNull,
"json" to ctx.jsonMapper.toJsonString(lr.value ?: "null"),
)
}
ctx.json(payload)
}
post("/tasks/{name}/enable") { ctx -> ctx.json(controlTask(ctx, "enable")) }
post("/tasks/{name}/disable") { ctx -> ctx.json(controlTask(ctx, "disable")) }
post("/tasks/{name}/trigger") { ctx -> ctx.json(controlTask(ctx, "trigger")) }
delete("/tasks/{name}") { ctx -> ctx.json(controlTask(ctx, "remove")) }
}
}
// ── Response payload builders ─────────────────────────────────────────────
private fun buildTaskRow(m: TaskSchedulerMonitor, name: String): Map<String, Any?> {
val info = m.scheduler.getTaskInfo(name)
val stats = m.getStats(name)
return mapOf(
"name" to name,
"enabled" to (info?.enabled ?: false),
"allowConcurrent" to (info?.allowConcurrent ?: false),
"scheduleDescription" to (info?.scheduleDescription ?: "manual"),
"currentlyExecuting" to (stats?.currentlyExecuting ?: false),
"retryPolicy" to info?.retryPolicy?.let { rp ->
mapOf(
"maxAttempts" to rp.maxAttempts,
"initialDelayMs" to rp.initialDelay.toMillis(),
"backoffMultiplier" to rp.backoffMultiplier,
"maxDelayMs" to rp.maxDelay.toMillis()
)
},
"stats" to buildStatsMap(stats)
)
}
private fun buildTaskDetail(m: TaskSchedulerMonitor, name: String): Map<String, Any?> {
val info = m.scheduler.getTaskInfo(name)
val stats = m.getStats(name)
val lr = stats?.lastResult
return mapOf(
"name" to name,
"enabled" to (info?.enabled ?: false),
"allowConcurrent" to (info?.allowConcurrent ?: false),
"scheduleDescription" to (info?.scheduleDescription ?: "manual"),
"currentlyExecuting" to (stats?.currentlyExecuting ?: false),
"retryPolicy" to info?.retryPolicy?.let { rp ->
mapOf(
"maxAttempts" to rp.maxAttempts,
"initialDelayMs" to rp.initialDelay.toMillis(),
"backoffMultiplier" to rp.backoffMultiplier,
"maxDelayMs" to rp.maxDelay.toMillis()
)
},
"stats" to buildStatsMap(stats),
"lastResult" to lr?.let {
// Provide a short preview inline; full JSON via /result endpoint
val fullJson = lr.value.toString() // placeholder; full JSON via /result
val preview = if (fullJson.length > 400) fullJson.take(400) + "…" else fullJson
mapOf(
"capturedAtFormatted" to lr.capturedAtFormatted,
"typeName" to lr.typeName,
"isNull" to lr.isNull,
"preview" to preview,
"isTruncated" to (fullJson.length > 400)
)
},
"recentExecutions" to (stats?.recentExecutions?.takeLast(50)?.reversed()?.map { r ->
mapOf(
"startTimeFormatted" to r.startTimeFormatted,
"durationMs" to r.durationMs,
"success" to r.success,
"errorMessage" to r.errorMessage,
"errorType" to r.errorType
)
} ?: emptyList<Any>())
)
}
private fun buildStatsMap(stats: TaskStats?): Map<String, Any?> {
if (stats == null) return emptyMap()
return mapOf(
"totalExecutions" to stats.totalExecutions.get(),
"successfulExecutions" to stats.successfulExecutions.get(),
"failedExecutions" to stats.failedExecutions.get(),
"totalRetries" to stats.totalRetries.get(),
"avgDurationMs" to stats.avgDurationMs(),
"successRate" to stats.successRate(),
"lastExecutionTimeFormatted" to stats.lastExecutionTime.get().takeIf { it > 0 }?.let { formatEpoch(it) },
"lastFailureTimeFormatted" to stats.lastFailureTime.get().takeIf { it > 0 }?.let { formatEpoch(it) },
"lastErrorMessage" to stats.lastErrorMessage,
"lastErrorType" to stats.lastErrorType
)
}
private fun controlTask(ctx: io.github.cymoo.colleen.Context, action: String): Map<String, Any> {
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
when (action) {
"enable" -> m.scheduler.enable(name)
"disable" -> m.scheduler.disable(name)
"trigger" -> {
if (!m.scheduler.isRunning) throw BadRequest("Scheduler is not running")
m.scheduler.run(name)
}
"remove" -> m.scheduler.remove(name)
}
return mapOf("ok" to true, "message" to "Task '$name': $action")
}
// =========================================================================
// HTML / CSS / JS
// =========================================================================
/**
* Renders the full single-page dashboard.
*
* [apiBase] is injected server-side so fetch() calls in JavaScript always
* hit the correct path, regardless of where the sub-app is mounted.
*
* Example: mounted at "/admin" → apiBase = "/admin/api"
*/
private fun buildHtml(apiBase: String) = $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Scheduler — Monitor</title>
<style>
/* ── Reset & Variables ─────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Warm off-white palette — precise, professional */
--bg: #f4f3f0;
--surface: #ffffff;
--surface-2: #f0eeeb;
--surface-3: #e8e5e1;
--border: #d8d4ce;
--border-2: #c5c0b8;
/* Text */
--text-primary: #18170f;
--text-secondary: #4a4740;
--text-muted: #848078;
--text-faint: #b0aba2;
/* Accent colours */
--blue: #1856c8;
--blue-light: #eef2fc;
--blue-mid: #c2d0f4;
--green: #14703e;
--green-light: #eaf5ef;
--green-mid: #a8d9be;
--red: #b52d20;
--red-light: #fdf0ee;
--red-mid: #edbbb5;
--amber: #8c5800;
--amber-light: #fdf6e6;
--amber-mid: #e0c070;
--purple: #5e35a8;
--purple-light: #f3effe;
--purple-mid: #ccb8f0;
/* Shared */
--radius: 5px;
--radius-lg: 8px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.07), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow: 0 3px 10px rgba(0, 0, 0, 0.09), 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.13), 0 3px 10px rgba(0, 0, 0, 0.06);
--mono: ui-monospace, 'Cascadia Code', 'Fira Mono', Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
font-family: var(--sans);
font-size: 14px;
background: var(--bg);
color: var(--text-secondary);
line-height: 1.6;
min-height: 100vh;
}
/* ── Layout ────────────────────────────────────────────────────────── */
#app {
max-width: 1380px;
margin: 0 auto;
padding: 0 24px 80px;
}
/* ── Header ────────────────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 0 20px;
border-bottom: 1.5px solid var(--border);
margin-bottom: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 36px;
height: 36px;
border-radius: 7px;
background: var(--blue);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
letter-spacing: -1px;
flex-shrink: 0;
}
.brand-name {
font-family: var(--mono);
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.brand-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.3px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-pill {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
padding: 5px 13px;
border-radius: 20px;
border: 1px solid transparent;
letter-spacing: 0.3px;
}
.status-pill.running {
background: var(--green-light);
border-color: var(--green-mid);
color: var(--green);
}
.status-pill.stopped {
background: var(--red-light);
border-color: var(--red-mid);
color: var(--red);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status-dot.live {
animation: blink 2s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.header-meta {
font-family: var(--mono);
font-size: 12px;
color: var(--text-faint);
}
.refresh-btn {
font-family: var(--mono);
font-size: 12px;
padding: 5px 12px;
border-radius: var(--radius);
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.refresh-btn:hover {
border-color: var(--blue);
color: var(--blue);
}
/* ── Summary Cards ─────────────────────────────────────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px 20px;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
}
/* Bottom accent line */
.summary-card::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2.5px;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.summary-card.blue::after { background: var(--blue); }
.summary-card.green::after { background: var(--green); }
.summary-card.red::after { background: var(--red); }
.summary-card.amber::after { background: var(--amber); }
.summary-card.purple::after { background: var(--purple); }
.card-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-faint);
margin-bottom: 8px;
}
.card-value {
font-family: var(--mono);
font-size: 30px;
font-weight: 700;
line-height: 1;
letter-spacing: -1.5px;
margin-bottom: 5px;
}
.summary-card.blue .card-value { color: var(--blue); }
.summary-card.green .card-value { color: var(--green); }
.summary-card.red .card-value { color: var(--red); }
.summary-card.amber .card-value { color: var(--amber); }
.summary-card.purple .card-value { color: var(--purple); }
.card-hint {
font-size: 12px;
color: var(--text-faint);
}
/* ── Section header ────────────────────────────────────────────────── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.count-badge {
font-family: var(--mono);
font-size: 11px;
background: var(--surface-3);
color: var(--text-muted);
padding: 1px 8px;
border-radius: 10px;
border: 1px solid var(--border);
}
/* ── Tasks table ───────────────────────────────────────────────────── */
.table-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: 32px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: var(--surface-2);
padding: 11px 16px;
text-align: left;
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-faint);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid var(--surface-2);
transition: background 0.12s;
cursor: pointer;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: #faf8f5;
}
td {
padding: 13px 16px;
vertical-align: middle;
}
/* Task name cell */
.task-name-cell {
font-family: var(--mono);
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.running-badge {
font-size: 10px;
font-family: var(--mono);
font-weight: 600;
background: var(--blue-light);
color: var(--blue);
border: 1px solid var(--blue-mid);
padding: 2px 8px;
border-radius: 10px;
animation: blink 1.2s ease-in-out infinite;
}
/* Schedule pill */
.schedule-pill {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
background: var(--surface-2);
border: 1px solid var(--border);
padding: 3px 9px;
border-radius: var(--radius);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
/* State tag */
.state-tag {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: var(--radius);
border: 1px solid transparent;
}
.state-tag.on {
background: var(--green-light);
color: var(--green);
border-color: var(--green-mid);
}
.state-tag.off {
background: var(--surface-2);
color: var(--text-faint);
border-color: var(--border);
}
/* Numeric cells */
.num {
font-family: var(--mono);
font-size: 14px;
color: var(--text-secondary);
}
.num-fail {
color: var(--red);
font-weight: 700;
}
.num-muted {
color: var(--text-faint);
}
/* Success rate bar */
.rate-bar {
display: flex;
align-items: center;
gap: 9px;
}
.bar-track {
height: 4px;
width: 56px;
background: var(--surface-3);
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.bar-fill.high { background: var(--green); }
.bar-fill.mid { background: var(--amber); }
.bar-fill.low { background: var(--red); }
.bar-pct {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
min-width: 42px;
}
/* ── Row action buttons ────────────────────────────────────────────── */
.row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.btn {
font-family: var(--mono);
font-size: 12px;
padding: 5px 11px;
border-radius: var(--radius);
border: 1px solid transparent;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
background: none;
white-space: nowrap;
font-weight: 500;
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-run {
background: var(--blue-light);
border-color: var(--blue-mid);
color: var(--blue);
}
.btn-run:hover:not(:disabled) { background: #d8e4f8; }
.btn-enable {
background: var(--green-light);
border-color: var(--green-mid);
color: var(--green);
}
.btn-enable:hover:not(:disabled) { background: #d0eadb; }
.btn-disable {
background: var(--amber-light);
border-color: var(--amber-mid);
color: var(--amber);
}
.btn-disable:hover:not(:disabled) { background: #f4e8c0; }
.btn-remove {
background: var(--red-light);
border-color: var(--red-mid);
color: var(--red);
}
.btn-remove:hover:not(:disabled) { background: #f8d8d4; }
/* ── Modal overlay ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(24, 22, 15, 0.38);
backdrop-filter: blur(4px);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.open {
opacity: 1;
pointer-events: all;
}
.modal {
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius-lg);
width: 92%;
max-width: 760px;
max-height: 88vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
transform: translateY(10px);
transition: transform 0.2s;
}
.modal-overlay.open .modal {
transform: translateY(0);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.modal-title {
font-family: var(--mono);
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
width: 30px;
height: 30px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: border-color 0.12s, color 0.12s;
line-height: 1;
}
.modal-close:hover {
border-color: var(--border-2);
color: var(--text-primary);
}
.modal-body {
padding: 22px;
}
/* Modal stat grid */
.modal-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.modal-stat {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
}
.modal-stat-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-faint);
margin-bottom: 6px;
}
.modal-stat-value {
font-family: var(--mono);
font-size: 22px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.5px;
}
/* Info key-value grid */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
margin-bottom: 16px;
}
.info-row {
display: flex;
align-items: baseline;
justify-content: space-between;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 9px 13px;
gap: 10px;
}
.info-key {
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
}
.info-val {
font-family: var(--mono);
font-size: 12px;
color: var(--text-secondary);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Error box */
.error-box {
background: var(--red-light);
border: 1px solid var(--red-mid);
border-radius: var(--radius);
padding: 12px 15px;
margin-bottom: 16px;
}
.error-type {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--red);
opacity: 0.7;
margin-bottom: 4px;
}
.error-message {
font-family: var(--mono);
font-size: 13px;
color: var(--red);
word-break: break-all;
}
/* Result block */
.result-block {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
overflow: hidden;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
background: var(--surface-3);
border-bottom: 1px solid var(--border);
}
.result-meta {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 10px;
}
.result-type {
font-weight: 700;
color: var(--purple);
}
.result-time {
color: var(--text-faint);
}
.result-body {
font-family: var(--mono);
font-size: 12px;
color: var(--text-secondary);
padding: 12px 14px;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
max-height: 220px;
overflow-y: auto;
}
.load-full-btn {
font-family: var(--mono);
font-size: 11px;
padding: 4px 10px;
border-radius: var(--radius);
border: 1px solid var(--border-2);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.load-full-btn:hover {
border-color: var(--blue);
color: var(--blue);
}
/* History table */
.history-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.7px;
color: var(--text-faint);
margin-bottom: 9px;
}
.history-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-family: var(--mono);
font-size: 13px;
}
.history-table th {
text-align: left;
padding: 9px 13px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.history-table td {
padding: 10px 13px;
border-bottom: 1px solid var(--surface-2);
color: var(--text-muted);
}
.history-table tr:last-child td { border-bottom: none; }
.history-table tr:hover td { background: var(--bg); }
.result-ok { color: var(--green); font-weight: 700; }
.result-fail { color: var(--red); font-weight: 700; }
.error-snippet {
color: var(--red);
max-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
/* ── Confirm dialog ────────────────────────────────────────────────── */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(24, 22, 15, 0.42);
backdrop-filter: blur(3px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.confirm-overlay.open {
opacity: 1;
pointer-events: all;
}
.confirm-box {
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius-lg);
padding: 24px 26px;
width: 380px;
box-shadow: var(--shadow-lg);
transform: scale(0.97);
transition: transform 0.15s;
}
.confirm-overlay.open .confirm-box { transform: scale(1); }
.confirm-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.confirm-message {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 22px;
line-height: 1.6;
}
.confirm-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.confirm-btn {
font-family: var(--mono);
font-size: 12px;
padding: 7px 16px;
border-radius: var(--radius);
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.confirm-btn:hover { background: var(--surface-3); color: var(--text-primary); }
.confirm-btn.danger {
background: var(--red-light);
border-color: var(--red-mid);
color: var(--red);
}
.confirm-btn.danger:hover { background: #f6d0cc; }
/* ── Toasts ────────────────────────────────────────────────────────── */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
font-family: var(--mono);
font-size: 13px;
padding: 10px 16px;
border-radius: var(--radius);
border: 1px solid transparent;
opacity: 0;
transform: translateX(12px);
transition: opacity 0.2s, transform 0.2s;
max-width: 320px;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast.ok { background: var(--green-light); border-color: var(--green-mid); color: var(--green); }
.toast.err { background: var(--red-light); border-color: var(--red-mid); color: var(--red); }
/* ── Empty state ───────────────────────────────────────────────────── */
.empty-state {
padding: 56px 20px;
text-align: center;
font-family: var(--mono);
font-size: 13px;
color: var(--text-faint);
}
/* ── Scrollbar ─────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }
</style>
</head>
<body>
<div id="app">
<!-- Header -->
<header>
<div class="brand">
<div class="brand-mark">TS</div>
<div>
<div class="brand-name">task-scheduler</div>
<div class="brand-sub">monitor · v3</div>
</div>
</div>
<div class="header-right">
<span class="header-meta" id="uptimeEl">—</span>
<span class="header-meta" id="clockEl">—</span>
<button class="refresh-btn" onclick="refresh()">↻ refresh</button>
<div class="status-pill" id="statusPill">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">—</span>
</div>
</div>
</header>
<!-- Summary cards -->
<div class="summary-grid" id="summaryGrid"></div>
<!-- Tasks -->
<div class="section-header">
<div class="section-title">
Registered Tasks
<span class="count-badge" id="taskCount">0</span>
</div>
</div>
<div class="table-card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>State</th>
<th>Runs</th>
<th>Failures</th>
<th>Retries</th>
<th>Avg Duration</th>
<th>Success Rate</th>
<th>Last Run</th>
<th style="text-align: right">Actions</th>
</tr>
</thead>
<tbody id="tasksBody">
<tr><td colspan="10"><div class="empty-state">Loading…</div></td></tr>
</tbody>
</table>
</div>
</div><!-- /#app -->
<!-- Detail modal -->
<div class="modal-overlay" id="detailOverlay">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">—</div>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<!-- Confirm dialog -->
<div class="confirm-overlay" id="confirmOverlay">
<div class="confirm-box">
<div class="confirm-title" id="confirmTitle">Confirm</div>
<div class="confirm-message" id="confirmMessage"></div>
<div class="confirm-actions">
<button class="confirm-btn" onclick="closeConfirm()">Cancel</button>
<button class="confirm-btn danger" id="confirmOkBtn">Confirm</button>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── Configuration ────────────────────────────────────────────────────────
//
// apiBase is injected server-side, e.g. "/admin/api".
// All fetch() calls use this as a prefix so the dashboard works correctly
// no matter where the sub-app is mounted.
const API_BASE = "$${apiBase}";
const REFRESH_INTERVAL_MS = 5000;
// ── State ────────────────────────────────────────────────────────────────
let tasks = [];
let confirmCallback = null;
// ── Initialise ───────────────────────────────────────────────────────────
(async function init() {
startClock();
await refresh();
setInterval(refresh, REFRESH_INTERVAL_MS);
})();
function startClock() {
const el = document.getElementById("clockEl");
function tick() {
el.textContent = new Date().toLocaleTimeString("en-GB", { hour12: false });
}
tick();
setInterval(tick, 1000);
}
// ── Data refresh ─────────────────────────────────────────────────────────
async function refresh() {
await Promise.all([fetchSummary(), fetchTasks()]);
}
async function fetchSummary() {
try {
const data = await apiGet("/summary");
const running = data.isRunning && !data.isTerminated;
const pill = document.getElementById("statusPill");
pill.className = "status-pill " + (running ? "running" : "stopped");
document.getElementById("statusDot").className = "status-dot" + (running ? " live" : "");
document.getElementById("statusText").textContent = data.isTerminated ? "TERMINATED" : (running ? "RUNNING" : "STOPPED");
document.getElementById("uptimeEl").textContent = "up " + data.uptimeFormatted;
document.getElementById("summaryGrid").innerHTML = [
summaryCard("Tasks", data.totalTasks, data.enabledTasks + " enabled / " + data.disabledTasks + " disabled", "blue"),
summaryCard("Executions", data.totalExecutions, data.successExecutions + " succeeded", "green"),
summaryCard("Failures", data.failedExecutions, data.failedExecutions > 0 ? "needs attention" : "all clear", "red"),
summaryCard("Retries", data.totalRetries, "across all tasks", "amber"),
summaryCard("Success", data.globalSuccessRate.toFixed(1) + "%", "global average", "purple"),
].join("");
} catch (err) {
console.error("summary fetch failed", err);
}
}
function summaryCard(label, value, hint, color) {
return `<div class="summary-card ${color}">
<div class="card-label">${label}</div>
<div class="card-value">${value}</div>
<div class="card-hint">${hint}</div>
</div>`;
}
async function fetchTasks() {
try {
tasks = await apiGet("/tasks");
document.getElementById("taskCount").textContent = tasks.length;
renderTasksTable(tasks);
} catch (err) {
console.error("tasks fetch failed", err);
}
}
// ── Tasks table ──────────────────────────────────────────────────────────
function renderTasksTable(tasks) {
const tbody = document.getElementById("tasksBody");
if (tasks.length === 0) {
tbody.innerHTML = `<tr><td colspan="10"><div class="empty-state">No tasks registered.</div></td></tr>`;
return;
}
tbody.innerHTML = tasks.map(task => {
const s = task.stats || {};
const total = s.totalExecutions || 0;
const fails = s.failedExecutions || 0;
const retries = s.totalRetries || 0;
const rate = s.successRate != null ? s.successRate : 100;
const rateClass = rate >= 95 ? "high" : rate >= 80 ? "mid" : "low";
const avg = s.avgDurationMs || 0;
const lastRun = s.lastExecutionTimeFormatted || "—";
const runningBadge = task.currentlyExecuting
? `<span class="running-badge">RUNNING</span>`
: "";
const toggleBtn = task.enabled
? `<button class="btn btn-disable" onclick="doDisable(event, '${esc(task.name)}')">Pause</button>`
: `<button class="btn btn-enable" onclick="doEnable(event, '${esc(task.name)}')">Enable</button>`;
return `<tr onclick="openDetail('${esc(task.name)}')">
<td><div class="task-name-cell">${esc(task.name)} ${runningBadge}</div></td>
<td><span class="schedule-pill" title="${esc(task.scheduleDescription)}">${esc(task.scheduleDescription)}</span></td>
<td><span class="state-tag ${task.enabled ? 'on' : 'off'}">${task.enabled ? "ON" : "OFF"}</span></td>
<td><span class="num">${total}</span></td>
<td><span class="num ${fails > 0 ? 'num-fail' : 'num-muted'}">${fails}</span></td>
<td><span class="num num-muted">${retries}</span></td>
<td><span class="num num-muted">${fmtDuration(avg)}</span></td>
<td>
<div class="rate-bar">
<div class="bar-track">
<div class="bar-fill ${rateClass}" style="width: ${Math.min(100, rate).toFixed(0)}%"></div>
</div>
<span class="bar-pct">${rate.toFixed(1)}%</span>
</div>
</td>
<td><span class="num num-muted" style="font-size: 12px">${esc(lastRun)}</span></td>
<td>
<div class="row-actions" onclick="event.stopPropagation()">
<button class="btn btn-run" onclick="doTrigger(event, '${esc(task.name)}')">▶ Run</button>
${toggleBtn}
<button class="btn btn-remove" onclick="doRemove(event, '${esc(task.name)}')">Remove</button>
</div>
</td>
</tr>`;
}).join("");
}
// ── Detail modal ─────────────────────────────────────────────────────────
async function openDetail(name) {
try {
const task = await apiGet("/tasks/" + encodeURIComponent(name));
const s = task.stats || {};
document.getElementById("modalTitle").innerHTML =
esc(task.name) +
` <span class="state-tag ${task.enabled ? 'on' : 'off'}" style="font-size: 12px">${task.enabled ? "ENABLED" : "DISABLED"}</span>`;
const rp = task.retryPolicy;
const retryDesc = rp
? `max ${rp.maxAttempts} attempts · init ${fmtDuration(rp.initialDelayMs)} · backoff ×${rp.backoffMultiplier}`
: "disabled";
const statsHtml = `
<div class="modal-stats">
${modalStat("Executions", s.totalExecutions || 0, "var(--blue)")}
${modalStat("Failures", s.failedExecutions || 0, (s.failedExecutions || 0) > 0 ? "var(--red)" : "var(--green)")}
${modalStat("Retries", s.totalRetries || 0, "var(--amber)")}
${modalStat("Avg Duration", fmtDuration(s.avgDurationMs || 0), "var(--text-secondary)")}
${modalStat("Success Rate", (s.successRate != null ? s.successRate.toFixed(1) : "100.0") + "%", "var(--green)")}
${modalStat("Concurrent", task.allowConcurrent ? "YES" : "NO", "var(--text-muted)")}
</div>`;
const infoHtml = `
<div class="info-grid">
${infoRow("Schedule", task.scheduleDescription || "manual")}
${infoRow("Retry Policy", retryDesc)}
${infoRow("Last Run", s.lastExecutionTimeFormatted || "never")}
${infoRow("Last Failure", s.lastFailureTimeFormatted || "never")}
</div>`;
const errorHtml = s.lastErrorMessage ? `
<div class="error-box">
<div class="error-type">${esc(s.lastErrorType || "Error")}</div>
<div class="error-message">${esc(s.lastErrorMessage)}</div>
</div>` : "";
const resultHtml = buildResultHtml(task, name);
const historyHtml = buildHistoryHtml(task.recentExecutions || []);
document.getElementById("modalBody").innerHTML =
statsHtml + infoHtml + errorHtml + resultHtml + historyHtml;
document.getElementById("detailOverlay").classList.add("open");
} catch (err) {
toast("Failed to load task details", "err");
console.error(err);
}
}
function modalStat(label, value, color) {
return `<div class="modal-stat">
<div class="modal-stat-label">${label}</div>
<div class="modal-stat-value" style="color: ${color}">${value}</div>
</div>`;
}
function infoRow(key, value) {
return `<div class="info-row">
<span class="info-key">${esc(key)}</span>
<span class="info-val" title="${esc(value)}">${esc(value)}</span>
</div>`;
}
function buildResultHtml(task, name) {
const lr = task.lastResult;
if (!lr) return "";
const loadFullBtn = lr.isTruncated
? `<button class="load-full-btn" id="loadFullBtn" onclick="loadFullResult('${esc(name)}')">Load full ↓</button>`
: "";
return `
<div class="result-block">
<div class="result-header">
<div class="result-meta">
<span>Last result</span>
<span class="result-type">${esc(lr.isNull ? "null" : lr.typeName)}</span>
<span class="result-time">${esc(lr.capturedAtFormatted)}</span>
</div>
${loadFullBtn}
</div>
<div class="result-body" id="resultBody">${esc(lr.preview || "")}</div>
</div>`;
}
async function loadFullResult(name) {
const btn = document.getElementById("loadFullBtn");
if (btn) { btn.textContent = "Loading…"; btn.disabled = true; }
try {
const data = await apiGet("/tasks/" + encodeURIComponent(name) + "/result");
if (data.hasResult) {
document.getElementById("resultBody").textContent = data.json;
}
if (btn) btn.style.display = "none";
} catch (err) {
if (btn) { btn.textContent = "Retry ↓"; btn.disabled = false; }
toast("Failed to load result", "err");
}
}
function buildHistoryHtml(records) {
const rows = records.map(r => `
<tr>
<td>${esc(r.startTimeFormatted || "—")}</td>
<td>${r.success
? `<span class="result-ok">OK</span>`
: `<span class="result-fail">FAIL</span>`}
</td>
<td>${fmtDuration(r.durationMs)}</td>
<td>${r.errorMessage
? `<span class="error-snippet" title="${esc(r.errorMessage)}">${esc(r.errorMessage)}</span>`
: `<span style="color: var(--text-faint)">—</span>`}
</td>
</tr>`).join("");
const tableHtml = rows
? `<div class="history-wrap">
<table class="history-table">
<thead>
<tr><th>Time</th><th>Result</th><th>Duration</th><th>Error</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`
: `<div class="empty-state" style="padding: 28px">No executions recorded yet.</div>`;
return `<div class="history-label">Execution History (${records.length} recent)</div>` + tableHtml;
}
function closeModal() {
document.getElementById("detailOverlay").classList.remove("open");
}
document.getElementById("detailOverlay").addEventListener("click", function (e) {
if (e.target === this) closeModal();
});
// ── Task control actions ──────────────────────────────────────────────────
async function doEnable(event, name) {
event.stopPropagation();
try {
await apiPost("/tasks/" + name + "/enable");
toast(`"${name}" enabled`, "ok");
await refresh();
} catch (err) { toast("Enable failed", "err"); }
}
async function doDisable(event, name) {
event.stopPropagation();
try {
await apiPost("/tasks/" + name + "/disable");
toast(`"${name}" disabled`, "ok");
await refresh();
} catch (err) { toast("Disable failed", "err"); }
}
async function doTrigger(event, name) {
event.stopPropagation();
try {
await apiPost("/tasks/" + name + "/trigger");
toast(`"${name}" triggered`, "ok");
setTimeout(refresh, 700);
} catch (err) { toast("Trigger failed: " + (err.message || ""), "err"); }
}
function doRemove(event, name) {
event.stopPropagation();
showConfirm(
"Remove Task",
`Remove "${name}"? This cannot be undone.`,
async () => {
try {
await apiDelete("/tasks/" + name);
toast(`"${name}" removed`, "ok");
await refresh();
} catch (err) { toast("Remove failed", "err"); }
}
);
}
// ── Confirm dialog ────────────────────────────────────────────────────────
function showConfirm(title, message, callback) {
document.getElementById("confirmTitle").textContent = title;
document.getElementById("confirmMessage").textContent = message;
confirmCallback = callback;
document.getElementById("confirmOverlay").classList.add("open");
}
function closeConfirm() {
document.getElementById("confirmOverlay").classList.remove("open");
confirmCallback = null;
}
document.getElementById("confirmOkBtn").addEventListener("click", () => {
if (confirmCallback) confirmCallback();
closeConfirm();
});
// ── Toast ─────────────────────────────────────────────────────────────────
function toast(message, type = "ok") {
const container = document.getElementById("toastContainer");
const el = document.createElement("div");
el.className = "toast " + type;
el.textContent = message;
container.appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, 3200);
}
// ── HTTP helpers ──────────────────────────────────────────────────────────
async function apiGet(path) {
const res = await fetch(API_BASE + path);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || err.message || res.statusText);
}
return res.json();
}
async function apiPost(path, body) {
const res = await fetch(API_BASE + path, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || err.message || res.statusText);
}
return res.json();
}
async function apiDelete(path) {
const res = await fetch(API_BASE + path, { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || err.message || res.statusText);
}
return res.json();
}
// ── Utilities ─────────────────────────────────────────────────────────────
function fmtDuration(ms) {
if (!ms || ms <= 0) return "0ms";
if (ms < 1000) return ms + "ms";
if (ms < 60000) return (ms / 1000).toFixed(2) + "s";
return Math.floor(ms / 60000) + "m " + ((ms % 60000) / 1000).toFixed(0) + "s";
}
function esc(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") { closeModal(); closeConfirm(); }
});
</script>
</body>
</html>
""".trimIndent()
}
// =============================================================================
// Shared utilities
// =============================================================================
private fun formatEpoch(ms: Long): String =
Instant.ofEpochMilli(ms)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"))
private fun formatUptime(ms: Long): String {
val totalSeconds = ms / 1000
return when {
totalSeconds < 60 -> "${totalSeconds}s"
totalSeconds < 3600 -> "${totalSeconds / 60}m ${totalSeconds % 60}s"
totalSeconds < 86400 -> "${totalSeconds / 3600}h ${(totalSeconds % 3600) / 60}m"
else -> "${totalSeconds / 86400}d ${(totalSeconds % 86400) / 3600}h"
}
}
fun main() {
val tasks = TaskScheduler()
tasks.task("foo") {
every(10.seconds)
run {
println("hello world")
"foo"
}
}
tasks.task("cleanup") {
cron("0 0 0 * * ?") // every day at midnight
retry(maxAttempts = 3, initialDelay = 1.second, backoffMultiplier = 2.0)
run {
println("running nightly cleanup")
}
}
tasks.start()
val monitor = tasks.attachMonitor() // ← 一行搞定
val app = Colleen()
app.mount("/admin", monitor.dashboardApp(mountPath = "/admin"))
app.listen()
}