task monitor
package io.github.cymoo.cleary.dashboard
import io.github.cymoo.cleary.TaskScheduler
import io.github.cymoo.cleary.TaskSchedulerConfig
import io.github.cymoo.cleary.TaskCompleteEvent
import io.github.cymoo.cleary.TaskRetryEvent
import io.github.cymoo.cleary.TaskStartEvent
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
// =============================================================================
// Monitor Data Structures
// =============================================================================
/**
* A single execution history entry for a task.
*/
data class ExecutionRecord(
val startTime: Long,
val endTime: Long,
val durationMs: Long,
val success: Boolean,
val errorMessage: String?,
val errorType: String?,
val scheduledTime: Long?
) {
val startTimeFormatted: String = formatEpoch(startTime)
}
/**
* Per-task aggregated statistics, thread-safe via atomic counters.
*/
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
// Ring buffer of recent executions (max 50)
val recentExecutions: CopyOnWriteArrayList<ExecutionRecord> = CopyOnWriteArrayList()
fun avgDurationMs(): Long {
val count = totalExecutions.get()
return if (count == 0L) 0L else totalDurationMs.get() / count
}
fun successRate(): Double {
val total = totalExecutions.get()
return if (total == 0L) 0.0 else successfulExecutions.get().toDouble() / total * 100.0
}
fun addRecord(record: ExecutionRecord) {
recentExecutions.add(record)
// Keep only the 50 most recent
while (recentExecutions.size > 50) {
recentExecutions.removeAt(0)
}
}
}
// =============================================================================
// TaskSchedulerMonitor
// =============================================================================
/**
* Wraps a [TaskScheduler] instance and hooks into its lifecycle callbacks
* to collect real-time statistics for the dashboard UI.
*
* Usage:
* ```kotlin
* val scheduler = TaskScheduler { autoStart = true }
* val monitor = TaskSchedulerMonitor(scheduler)
* TaskSchedulerDashboard(monitor).start(port = 9090)
* ```
*
* If you need to configure callbacks yourself, use [TaskSchedulerMonitor.configure]
* on a [TaskSchedulerConfig] before passing it to [TaskScheduler].
*/
class TaskSchedulerMonitor(val scheduler: TaskScheduler) {
private val statsMap = ConcurrentHashMap<String, TaskStats>()
private val globalStarted = AtomicLong(System.currentTimeMillis())
val globalTotalExecutions = AtomicLong(0)
val globalSuccessExecutions = AtomicLong(0)
val globalFailedExecutions = AtomicLong(0)
val globalTotalRetries = AtomicLong(0)
// These are injected by hooking the scheduler's config events.
// Call attachTo() after construction to wire them up, or use the companion factory.
private val inFlightStart = ConcurrentHashMap<String, Long>()
fun onTaskStart(event: TaskStartEvent) {
inFlightStart[event.taskName] = event.actualTime
statsFor(event.taskName).currentlyExecuting = true
}
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()
} else {
stats.failedExecutions.incrementAndGet()
stats.lastFailureTime.set(event.endTime)
stats.lastErrorMessage = event.error?.message ?: "Unknown error"
stats.lastErrorType = event.error?.javaClass?.simpleName
globalFailedExecutions.incrementAndGet()
}
val record = ExecutionRecord(
startTime = event.startTime,
endTime = event.endTime,
durationMs = event.duration,
success = event.isSuccess,
errorMessage = event.error?.message,
errorType = event.error?.javaClass?.simpleName,
scheduledTime = inFlightStart.remove(event.taskName)
)
stats.addRecord(record)
}
fun onRetry(event: TaskRetryEvent) {
statsFor(event.taskName).totalRetries.incrementAndGet()
globalTotalRetries.incrementAndGet()
}
private fun statsFor(name: String): TaskStats =
statsMap.getOrPut(name) { TaskStats(name) }
/** Returns stats for a given task, or null if never executed. */
fun getStats(name: String): TaskStats? = statsMap[name]
/** Returns stats for all known tasks (union of scheduler registry + execution history). */
fun getAllStats(): Map<String, TaskStats> = statsMap.toMap()
/** Wall-clock time when this monitor was created (proxy for scheduler uptime). */
fun uptimeMs(): Long = System.currentTimeMillis() - globalStarted.get()
companion object {
/**
* Creates a [TaskScheduler] with monitor hooks already wired up.
*
* ```kotlin
* val (scheduler, monitor) = TaskSchedulerMonitor.create {
* autoStart = true
* registerShutdownHook = true
* }
* ```
*/
fun create(block: TaskSchedulerConfig.() -> Unit = {}): Pair<TaskScheduler, TaskSchedulerMonitor> {
// We need to wire the monitor into callbacks, but monitor needs the scheduler.
// Use a lazy reference trick: capture a mutable cell.
var monitorRef: TaskSchedulerMonitor? = null
val scheduler = TaskScheduler {
block()
onTaskStart = { event -> monitorRef?.onTaskStart(event) }
onTaskComplete = { event -> monitorRef?.onTaskComplete(event) }
onRetry = { event -> monitorRef?.onRetry(event) }
}
val monitor = TaskSchedulerMonitor(scheduler)
monitorRef = monitor
return scheduler to monitor
}
}
}
// =============================================================================
// Dashboard Web Server
// =============================================================================
/**
* Mounts a monitoring dashboard for [TaskSchedulerMonitor] onto a [Colleen] app
* (or creates a new one) and starts listening on [port].
*
* All routes are prefixed with [prefix] (default `""`), so you can embed the
* dashboard into an existing app at e.g. `"/admin"`.
*
* ### Standalone usage
* ```kotlin
* val (scheduler, monitor) = TaskSchedulerMonitor.create { autoStart = true }
* scheduler.task("ping") { every(5.seconds); run { println("ping") } }
* TaskSchedulerDashboard(monitor).start(port = 9090)
* ```
*
* ### Embedded usage
* ```kotlin
* val app = Colleen()
* TaskSchedulerDashboard(monitor, prefix = "/admin").mount(app)
* app.listen(8080)
* ```
*/
class TaskSchedulerDashboard(
private val monitor: TaskSchedulerMonitor,
private val prefix: String = ""
) {
private val scheduler get() = monitor.scheduler
fun start(port: Int = 9090) {
val app = Colleen()
mount(app)
app.listen(port)
println("✅ TaskScheduler Dashboard running on http://localhost:$port${prefix}/")
}
fun mount(app: Colleen) {
app.provide(monitor)
app.group(prefix) {
// ---- HTML Dashboard ----
get("/") { ctx ->
ctx.html(dashboardHtml())
}
// ---- REST API ----
group("/api/scheduler") {
// Global summary
get("/summary") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val taskNames = m.scheduler.listTaskNames()
val allStats = m.getAllStats()
val enabled = taskNames.count { name ->
m.scheduler.getTaskInfo(name)?.enabled == true
}
mapOf(
"isRunning" to m.scheduler.isRunning,
"isTerminated" to m.scheduler.isTerminated,
"uptimeMs" to m.uptimeMs(),
"uptimeFormatted" to formatUptime(m.uptimeMs()),
"totalTasks" to taskNames.size,
"enabledTasks" to enabled,
"disabledTasks" to (taskNames.size - enabled),
"totalExecutions" to m.globalTotalExecutions.get(),
"successfulExecutions" to m.globalSuccessExecutions.get(),
"failedExecutions" to m.globalFailedExecutions.get(),
"totalRetries" to m.globalTotalRetries.get(),
"globalSuccessRate" to run {
val total = m.globalTotalExecutions.get()
if (total == 0L) 100.0
else m.globalSuccessExecutions.get().toDouble() / total * 100.0
}
)
}
// All tasks list
get("/tasks") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val allStats = m.getAllStats()
m.scheduler.listTaskNames().map { name ->
val info = m.scheduler.getTaskInfo(name)
val stats = allStats[name]
mapOf(
"name" to name,
"enabled" to (info?.enabled ?: false),
"allowConcurrent" to (info?.allowConcurrent ?: false),
"scheduleDescription" to (info?.scheduleDescription ?: "manual"),
"retryPolicy" to info?.retryPolicy?.let {
mapOf(
"maxAttempts" to it.maxAttempts,
"initialDelayMs" to it.initialDelay.toMillis(),
"backoffMultiplier" to it.backoffMultiplier,
"maxDelayMs" to it.maxDelay.toMillis()
)
},
"stats" to (stats?.toMap() ?: emptyMap<String, Any>()),
"currentlyExecuting" to (stats?.currentlyExecuting ?: false)
)
}
}
// Single task detail
get("/tasks/{name}") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("Task name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
val info = m.scheduler.getTaskInfo(name)
val stats = m.getStats(name)
mapOf(
"name" to name,
"enabled" to (info?.enabled ?: false),
"allowConcurrent" to (info?.allowConcurrent ?: false),
"scheduleDescription" to (info?.scheduleDescription ?: "manual"),
"retryPolicy" to info?.retryPolicy?.let {
mapOf(
"maxAttempts" to it.maxAttempts,
"initialDelayMs" to it.initialDelay.toMillis(),
"backoffMultiplier" to it.backoffMultiplier,
"maxDelayMs" to it.maxDelay.toMillis()
)
},
"stats" to (stats?.toMap() ?: emptyMap<String, Any>()),
"recentExecutions" to (stats?.recentExecutions?.takeLast(50)?.reversed()?.map { r ->
mapOf(
"startTime" to r.startTime,
"startTimeFormatted" to r.startTimeFormatted,
"durationMs" to r.durationMs,
"success" to r.success,
"errorMessage" to r.errorMessage,
"errorType" to r.errorType
)
} ?: emptyList<Any>()),
"currentlyExecuting" to (stats?.currentlyExecuting ?: false)
)
}
// Enable task
post("/tasks/{name}/enable") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("Task name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
m.scheduler.enable(name)
mapOf("success" to true, "message" to "Task '$name' enabled")
}
// Disable task
post("/tasks/{name}/disable") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("Task name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
m.scheduler.disable(name)
mapOf("success" to true, "message" to "Task '$name' disabled")
}
// Remove task
delete("/tasks/{name}") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("Task name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
m.scheduler.remove(name)
mapOf("success" to true, "message" to "Task '$name' removed")
}
// Trigger task manually
post("/tasks/{name}/trigger") { ctx ->
val m = ctx.getService<TaskSchedulerMonitor>()
val name = ctx.pathParam("name") ?: throw BadRequest("Task name required")
if (!m.scheduler.exists(name)) throw NotFound("Task '$name' not found")
if (!m.scheduler.isRunning) throw BadRequest("Scheduler is not running")
m.scheduler.run(name)
mapOf("success" to true, "message" to "Task '$name' triggered")
}
}
}
}
// =========================================================================
// Serialization helpers
// =========================================================================
private fun TaskStats.toMap(): Map<String, Any?> = mapOf(
"totalExecutions" to totalExecutions.get(),
"successfulExecutions" to successfulExecutions.get(),
"failedExecutions" to failedExecutions.get(),
"totalRetries" to totalRetries.get(),
"avgDurationMs" to avgDurationMs(),
"successRate" to successRate(),
"lastExecutionTime" to lastExecutionTime.get().takeIf { it > 0 },
"lastExecutionTimeFormatted" to lastExecutionTime.get().takeIf { it > 0 }?.let { formatEpoch(it) },
"lastFailureTime" to lastFailureTime.get().takeIf { it > 0 },
"lastFailureTimeFormatted" to lastFailureTime.get().takeIf { it > 0 }?.let { formatEpoch(it) },
"lastErrorMessage" to lastErrorMessage,
"lastErrorType" to lastErrorType,
"currentlyExecuting" to currentlyExecuting
)
// =========================================================================
// HTML
// =========================================================================
private fun dashboardHtml(): 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 · Control Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-0: #0a0b0e;
--bg-1: #0f1117;
--bg-2: #161922;
--bg-3: #1e2330;
--border: rgba(255,255,255,0.06);
--border-accent: rgba(255,255,255,0.12);
--text-primary: #e8eaf0;
--text-secondary: #7a8099;
--text-muted: #444a5e;
--accent: #5b7fff;
--accent-glow: rgba(91,127,255,0.15);
--accent-dim: rgba(91,127,255,0.08);
--green: #3dd68c;
--green-glow: rgba(61,214,140,0.12);
--red: #ff5f5f;
--red-glow: rgba(255,95,95,0.12);
--amber: #ffb547;
--amber-glow: rgba(255,181,71,0.12);
--cyan: #3dd5f3;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
--radius: 10px;
--radius-sm: 6px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg-0);
color: var(--text-primary);
font-family: var(--sans);
min-height: 100vh;
overflow-x: hidden;
}
/* Ambient grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(91,127,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(91,127,255,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Glow orb */
body::after {
content: '';
position: fixed;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 400px;
background: radial-gradient(ellipse, rgba(91,127,255,0.08) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
#app {
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
padding: 0 24px 60px;
}
/* ---- Header ---- */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 0 32px;
border-bottom: 1px solid var(--border);
margin-bottom: 32px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 0 20px rgba(91,127,255,0.4);
}
.logo-text {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
}
.logo-sub {
font-size: 11px;
font-family: var(--mono);
color: var(--text-muted);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-badge {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-family: var(--mono);
font-weight: 500;
border: 1px solid transparent;
transition: all 0.3s;
}
.status-badge.running {
background: var(--green-glow);
border-color: rgba(61,214,140,0.2);
color: var(--green);
}
.status-badge.stopped {
background: var(--red-glow);
border-color: rgba(255,95,95,0.2);
color: var(--red);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-dot.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
50% { opacity: 0.7; box-shadow: 0 0 0 4px transparent; }
}
.uptime {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
}
.refresh-indicator {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
}
.refresh-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
transition: opacity 0.3s;
}
.refresh-dot.active { opacity: 1; }
/* ---- Summary Cards ---- */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 36px;
}
.metric-card {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.metric-card:hover {
border-color: var(--border-accent);
transform: translateY(-1px);
}
.metric-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, var(--accent), transparent); }
.metric-card.green::before { background: linear-gradient(90deg, var(--green), transparent); }
.metric-card.red::before { background: linear-gradient(90deg, var(--red), transparent); }
.metric-card.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
.metric-card.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
.metric-card.purple::before { background: linear-gradient(90deg, #8b5cf6, transparent); }
.metric-label {
font-size: 11px;
font-family: var(--mono);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.metric-value {
font-size: 32px;
font-weight: 700;
letter-spacing: -1px;
line-height: 1;
margin-bottom: 6px;
}
.metric-card.blue .metric-value { color: var(--accent); }
.metric-card.green .metric-value { color: var(--green); }
.metric-card.red .metric-value { color: var(--red); }
.metric-card.amber .metric-value { color: var(--amber); }
.metric-card.cyan .metric-value { color: var(--cyan); }
.metric-card.purple .metric-value { color: #8b5cf6; }
.metric-sub {
font-size: 12px;
color: var(--text-muted);
}
/* ---- Section ---- */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 8px;
}
.section-count {
font-size: 11px;
font-family: var(--mono);
color: var(--text-muted);
background: var(--bg-3);
padding: 2px 8px;
border-radius: 10px;
}
/* ---- Tasks Table ---- */
.tasks-panel {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 32px;
}
.tasks-table {
width: 100%;
border-collapse: collapse;
}
.tasks-table thead th {
padding: 14px 16px;
text-align: left;
font-size: 10px;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
background: var(--bg-2);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.tasks-table tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.15s;
cursor: pointer;
}
.tasks-table tbody tr:last-child { border-bottom: none; }
.tasks-table tbody tr:hover { background: var(--bg-2); }
.tasks-table td {
padding: 14px 16px;
font-size: 13px;
vertical-align: middle;
}
.task-name {
font-family: var(--mono);
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.executing-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 9px;
font-family: var(--mono);
background: var(--accent-dim);
color: var(--accent);
border: 1px solid rgba(91,127,255,0.2);
padding: 2px 7px;
border-radius: 10px;
animation: executing-pulse 1.5s infinite;
}
@keyframes executing-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.schedule-pill {
font-family: var(--mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-3);
padding: 3px 9px;
border-radius: 4px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.enabled-tag {
font-size: 10px;
font-family: var(--mono);
padding: 3px 9px;
border-radius: 4px;
font-weight: 500;
}
.enabled-tag.on {
background: var(--green-glow);
color: var(--green);
border: 1px solid rgba(61,214,140,0.2);
}
.enabled-tag.off {
background: rgba(255,255,255,0.03);
color: var(--text-muted);
border: 1px solid var(--border);
}
.stat-num {
font-family: var(--mono);
font-size: 13px;
}
.stat-num.fail { color: var(--red); }
.stat-num.ok { color: var(--green); }
.stat-num.muted { color: var(--text-muted); }
.success-bar {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
height: 4px;
width: 60px;
background: var(--bg-3);
border-radius: 2px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s 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: 11px;
color: var(--text-secondary);
min-width: 36px;
}
/* ---- Action Buttons ---- */
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 11px;
font-family: var(--mono);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
background: none;
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-enable {
border-color: rgba(61,214,140,0.25);
color: var(--green);
background: var(--green-glow);
}
.btn-enable:hover:not(:disabled) {
background: rgba(61,214,140,0.2);
border-color: rgba(61,214,140,0.4);
}
.btn-disable {
border-color: rgba(255,181,71,0.2);
color: var(--amber);
background: var(--amber-glow);
}
.btn-disable:hover:not(:disabled) {
background: rgba(255,181,71,0.2);
}
.btn-trigger {
border-color: rgba(91,127,255,0.25);
color: var(--accent);
background: var(--accent-dim);
}
.btn-trigger:hover:not(:disabled) {
background: rgba(91,127,255,0.15);
border-color: rgba(91,127,255,0.4);
}
.btn-remove {
border-color: rgba(255,95,95,0.2);
color: var(--red);
background: var(--red-glow);
}
.btn-remove:hover:not(:disabled) {
background: rgba(255,95,95,0.18);
border-color: rgba(255,95,95,0.35);
}
.btn-details {
border-color: var(--border-accent);
color: var(--text-secondary);
background: var(--bg-3);
}
.btn-details:hover { color: var(--text-primary); border-color: rgba(255,255,255,0.2); }
/* ---- Modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
z-index: 1000;
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(--bg-1);
border: 1px solid var(--border-accent);
border-radius: 14px;
width: 90%;
max-width: 720px;
max-height: 85vh;
overflow-y: auto;
padding: 28px;
transform: translateY(12px);
transition: transform 0.2s;
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
}
.modal-overlay.open .modal { transform: translateY(0); }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.15s;
}
.modal-close:hover { color: var(--text-primary); border-color: var(--border-accent); }
.modal-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.modal-stat {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
}
.modal-stat-label {
font-size: 10px;
font-family: var(--mono);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.modal-stat-value {
font-size: 20px;
font-weight: 700;
font-family: var(--mono);
}
/* ---- History Table ---- */
.history-label {
font-size: 12px;
font-family: var(--mono);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-family: var(--mono);
}
.history-table th {
text-align: left;
padding: 8px 10px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
.history-table tr:last-child td { border-bottom: none; }
.history-table tr:hover td { background: var(--bg-2); }
.result-ok {
color: var(--green);
font-weight: 600;
}
.result-fail {
color: var(--red);
font-weight: 600;
}
.error-snippet {
font-size: 11px;
color: var(--red);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Toast ---- */
.toast-container {
position: fixed;
bottom: 28px;
right: 28px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
padding: 12px 18px;
border-radius: var(--radius-sm);
font-size: 13px;
font-family: var(--mono);
border: 1px solid transparent;
opacity: 0;
transform: translateX(20px);
transition: all 0.25s;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast.success {
background: rgba(61,214,140,0.1);
border-color: rgba(61,214,140,0.25);
color: var(--green);
}
.toast.error {
background: rgba(255,95,95,0.1);
border-color: rgba(255,95,95,0.25);
color: var(--red);
}
/* ---- Info section below modal stats ---- */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 20px;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
}
.info-key {
font-family: var(--mono);
color: var(--text-muted);
}
.info-val {
font-family: var(--mono);
color: var(--text-secondary);
font-size: 11px;
text-align: right;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Empty state ---- */
.empty-state {
padding: 60px 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.empty-state .icon {
font-size: 32px;
margin-bottom: 12px;
display: block;
}
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 3px; }
/* ---- Confirm dialog ---- */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 1500;
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(--bg-1);
border: 1px solid var(--border-accent);
border-radius: 12px;
padding: 24px;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
transform: scale(0.96);
transition: transform 0.15s;
}
.confirm-overlay.open .confirm-box { transform: scale(1); }
.confirm-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
}
.confirm-msg {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.confirm-btn {
padding: 8px 18px;
border-radius: 6px;
border: 1px solid var(--border);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
background: var(--bg-2);
color: var(--text-secondary);
}
.confirm-btn:hover { color: var(--text-primary); border-color: var(--border-accent); }
.confirm-btn.danger {
background: var(--red-glow);
border-color: rgba(255,95,95,0.25);
color: var(--red);
}
.confirm-btn.danger:hover { background: rgba(255,95,95,0.18); }
</style>
</head>
<body>
<div id="app">
<!-- Header -->
<header>
<div class="logo">
<div class="logo-icon">⚡</div>
<div>
<div class="logo-text">Task Scheduler</div>
<div class="logo-sub">Control Panel</div>
</div>
</div>
<div class="header-right">
<span class="uptime" id="uptimeDisplay">—</span>
<div class="refresh-indicator">
<div class="refresh-dot" id="refreshDot"></div>
<span>5s refresh</span>
</div>
<div class="status-badge" id="schedulerStatus">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">—</span>
</div>
</div>
</header>
<!-- Summary Metrics -->
<div class="summary-grid" id="summaryGrid">
<div class="metric-card blue">
<div class="metric-label">Total Tasks</div>
<div class="metric-value" id="mTotalTasks">—</div>
<div class="metric-sub" id="mTaskSub">—</div>
</div>
<div class="metric-card green">
<div class="metric-label">Total Executions</div>
<div class="metric-value" id="mTotalExec">—</div>
<div class="metric-sub" id="mExecSub">—</div>
</div>
<div class="metric-card red">
<div class="metric-label">Failed Executions</div>
<div class="metric-value" id="mFailed">—</div>
<div class="metric-sub" id="mFailedSub">—</div>
</div>
<div class="metric-card amber">
<div class="metric-label">Total Retries</div>
<div class="metric-value" id="mRetries">—</div>
<div class="metric-sub">across all tasks</div>
</div>
<div class="metric-card cyan">
<div class="metric-label">Success Rate</div>
<div class="metric-value" id="mSuccessRate">—</div>
<div class="metric-sub">global average</div>
</div>
</div>
<!-- Tasks Table -->
<div class="section-header">
<div class="section-title">
⬡ Registered Tasks
<span class="section-count" id="taskCount">0</span>
</div>
</div>
<div class="tasks-panel">
<table class="tasks-table" id="tasksTable">
<thead>
<tr>
<th>Task Name</th>
<th>Schedule</th>
<th>Status</th>
<th>Executions</th>
<th>Failures</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="9"><div class="empty-state"><span class="icon">⟳</span>Loading...</div></td></tr>
</tbody>
</table>
</div>
</div><!-- /#app -->
<!-- Task Detail Modal -->
<div class="modal-overlay" id="detailModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Task Details</div>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div 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-msg" id="confirmMsg"></div>
<div class="confirm-actions">
<button class="confirm-btn" onclick="closeConfirm()">Cancel</button>
<button class="confirm-btn danger" id="confirmOk">Confirm</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast-container" id="toastContainer"></div>
<script>
const API = '${prefix}/api/scheduler';
// ---- State ----
let allTasks = [];
let refreshTimer = null;
// ---- Bootstrap ----
async function init() {
await refresh();
refreshTimer = setInterval(refresh, 5000);
}
async function refresh() {
flashRefreshDot();
await Promise.all([loadSummary(), loadTasks()]);
}
function flashRefreshDot() {
const dot = document.getElementById('refreshDot');
dot.classList.add('active');
setTimeout(() => dot.classList.remove('active'), 600);
}
// ---- Summary ----
async function loadSummary() {
try {
const data = await get('/summary');
// Status badge
const badge = document.getElementById('schedulerStatus');
const dot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const running = data.isRunning && !data.isTerminated;
badge.className = 'status-badge ' + (running ? 'running' : 'stopped');
dot.className = 'status-dot' + (running ? ' pulse' : '');
statusText.textContent = data.isTerminated ? 'TERMINATED' : (data.isRunning ? 'RUNNING' : 'STOPPED');
document.getElementById('uptimeDisplay').textContent = 'up ' + data.uptimeFormatted;
document.getElementById('mTotalTasks').textContent = data.totalTasks;
document.getElementById('mTaskSub').textContent = data.enabledTasks + ' enabled, ' + data.disabledTasks + ' disabled';
document.getElementById('mTotalExec').textContent = data.totalExecutions;
document.getElementById('mExecSub').textContent = data.successfulExecutions + ' succeeded';
document.getElementById('mFailed').textContent = data.failedExecutions;
document.getElementById('mFailedSub').textContent = data.failedExecutions > 0 ? 'needs attention' : 'all good';
document.getElementById('mRetries').textContent = data.totalRetries;
const rate = data.globalSuccessRate;
document.getElementById('mSuccessRate').textContent = rate.toFixed(1) + '%';
} catch(e) { console.error('summary error', e); }
}
// ---- Tasks ----
async function loadTasks() {
try {
allTasks = await get('/tasks');
document.getElementById('taskCount').textContent = allTasks.length;
renderTasks(allTasks);
} catch(e) { console.error('tasks error', e); }
}
function renderTasks(tasks) {
const tbody = document.getElementById('tasksBody');
if (!tasks.length) {
tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><span class="icon">◌</span>No tasks registered</div></td></tr>';
return;
}
tbody.innerHTML = tasks.map(t => {
const s = t.stats || {};
const total = s.totalExecutions || 0;
const failed = s.failedExecutions || 0;
const rate = s.successRate != null ? s.successRate : (total === 0 ? 100 : 0);
const rateClass = rate >= 95 ? 'high' : rate >= 80 ? 'mid' : 'low';
const avgDur = s.avgDurationMs || 0;
const lastRun = s.lastExecutionTimeFormatted || '—';
const enabled = t.enabled;
const execBadge = t.currentlyExecuting
? '<span class="executing-badge">▶ running</span>'
: '';
return '<tr onclick="openDetail(\'' + esc(t.name) + '\')">' +
'<td><div class="task-name"><span>' + esc(t.name) + '</span>' + execBadge + '</div></td>' +
'<td><span class="schedule-pill" title="' + esc(t.scheduleDescription) + '">' + esc(t.scheduleDescription) + '</span></td>' +
'<td><span class="enabled-tag ' + (enabled ? 'on' : 'off') + '">' + (enabled ? '● ON' : '○ OFF') + '</span></td>' +
'<td><span class="stat-num">' + total + '</span></td>' +
'<td><span class="stat-num ' + (failed > 0 ? 'fail' : 'muted') + '">' + failed + '</span></td>' +
'<td><span class="stat-num muted">' + fmtDuration(avgDur) + '</span></td>' +
'<td>' + renderBar(rate, rateClass) + '</td>' +
'<td><span class="stat-num muted" style="font-size:11px">' + lastRun + '</span></td>' +
'<td>' + renderActions(t) + '</td>' +
'</tr>';
}).join('');
}
function renderBar(rate, cls) {
return '<div class="success-bar">' +
'<div class="bar-track"><div class="bar-fill ' + cls + '" style="width:' + Math.min(100, rate).toFixed(0) + '%"></div></div>' +
'<span class="bar-pct">' + rate.toFixed(1) + '%</span>' +
'</div>';
}
function renderActions(t) {
const name = esc(t.name);
const toggleBtn = t.enabled
? '<button class="btn btn-disable" onclick="disableTask(event,\'' + name + '\')" title="Disable">⏸ Disable</button>'
: '<button class="btn btn-enable" onclick="enableTask(event,\'' + name + '\')" title="Enable">▶ Enable</button>';
return '<div class="actions" onclick="event.stopPropagation()">' +
'<button class="btn btn-trigger" onclick="triggerTask(event,\'' + name + '\')" title="Run now">⚡ Run</button>' +
toggleBtn +
'<button class="btn btn-remove" onclick="removeTask(event,\'' + name + '\')" title="Remove">✕</button>' +
'</div>';
}
// ---- Detail Modal ----
async function openDetail(name) {
try {
const task = await get('/tasks/' + encodeURIComponent(name));
const s = task.stats || {};
const modal = document.getElementById('detailModal');
document.getElementById('modalTitle').innerHTML = '⬡ ' + name +
' <span class="enabled-tag ' + (task.enabled ? 'on' : 'off') + '" style="font-size:12px">' +
(task.enabled ? 'ENABLED' : 'DISABLED') + '</span>';
const histRows = (task.recentExecutions || []).map(r =>
'<tr>' +
'<td style="color:var(--text-secondary)">' + (r.startTimeFormatted || '—') + '</td>' +
'<td>' + (r.success ? '<span class="result-ok">✓ OK</span>' : '<span class="result-fail">✗ FAIL</span>') + '</td>' +
'<td style="color:var(--text-secondary)">' + fmtDuration(r.durationMs) + '</td>' +
'<td>' + (r.errorMessage ? '<span class="error-snippet" title="' + esc(r.errorMessage) + '">' + esc(r.errorMessage) + '</span>' : '<span style="color:var(--text-muted)">—</span>') + '</td>' +
'</tr>'
).join('');
const retryInfo = task.retryPolicy
? 'maxAttempts=' + task.retryPolicy.maxAttempts + ', initialDelay=' + fmtDuration(task.retryPolicy.initialDelayMs) +
', backoff=' + task.retryPolicy.backoffMultiplier + 'x'
: 'none';
document.getElementById('modalBody').innerHTML =
'<div class="modal-stats-grid">' +
modalStat('Executions', (s.totalExecutions||0), 'var(--accent)') +
modalStat('Failures', (s.failedExecutions||0), s.failedExecutions > 0 ? 'var(--red)' : 'var(--green)') +
modalStat('Retries', (s.totalRetries||0), 'var(--amber)') +
modalStat('Avg Duration', fmtDuration(s.avgDurationMs||0), 'var(--cyan)') +
modalStat('Success Rate', (s.successRate != null ? s.successRate.toFixed(1) : '100.0') + '%', 'var(--green)') +
modalStat('Concurrent?', task.allowConcurrent ? 'Yes' : 'No', 'var(--text-secondary)') +
'</div>' +
'<div class="info-grid">' +
infoRow('Schedule', task.scheduleDescription || 'manual') +
infoRow('Retry Policy', retryInfo) +
infoRow('Last Run', s.lastExecutionTimeFormatted || 'Never') +
infoRow('Last Failure', s.lastFailureTimeFormatted || 'Never') +
'</div>' +
(s.lastErrorMessage ? '<div style="background:var(--red-glow);border:1px solid rgba(255,95,95,0.2);border-radius:6px;padding:12px 14px;margin-bottom:20px;font-family:var(--mono);font-size:12px;color:var(--red)"><div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;opacity:0.6">Last Error · ' + esc(s.lastErrorType || '') + '</div>' + esc(s.lastErrorMessage) + '</div>' : '') +
'<div class="history-label">Execution History (last ' + (task.recentExecutions||[]).length + ')</div>' +
(histRows
? '<div style="background:var(--bg-2);border:1px solid var(--border);border-radius:6px;overflow:hidden"><table class="history-table"><thead><tr><th>Time</th><th>Result</th><th>Duration</th><th>Error</th></tr></thead><tbody>' + histRows + '</tbody></table></div>'
: '<div class="empty-state" style="padding:30px"><span class="icon" style="font-size:20px">◌</span>No executions yet</div>');
modal.classList.add('open');
} catch(e) {
toast('Failed to load task details', 'error');
}
}
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">' + key + '</span><span class="info-val" title="' + esc(value) + '">' + esc(value) + '</span></div>';
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
document.getElementById('detailModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ---- Actions ----
async function enableTask(e, name) {
e.stopPropagation();
try {
await post('/tasks/' + name + '/enable');
toast('Task "' + name + '" enabled', 'success');
await refresh();
} catch(err) { toast('Failed to enable task', 'error'); }
}
async function disableTask(e, name) {
e.stopPropagation();
try {
await post('/tasks/' + name + '/disable');
toast('Task "' + name + '" disabled', 'success');
await refresh();
} catch(err) { toast('Failed to disable task', 'error'); }
}
async function triggerTask(e, name) {
e.stopPropagation();
try {
await post('/tasks/' + name + '/trigger');
toast('Task "' + name + '" triggered', 'success');
setTimeout(refresh, 800);
} catch(err) { toast('Failed to trigger task: ' + (err.message||''), 'error'); }
}
function removeTask(e, name) {
e.stopPropagation();
showConfirm(
'Remove Task',
'Are you sure you want to remove "' + name + '"? This cannot be undone.',
async () => {
try {
await del('/tasks/' + name);
toast('Task "' + name + '" removed', 'success');
await refresh();
} catch(err) { toast('Failed to remove task', 'error'); }
}
);
}
// ---- Confirm ----
let _confirmCallback = null;
function showConfirm(title, msg, cb) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMsg').textContent = msg;
_confirmCallback = cb;
document.getElementById('confirmOverlay').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirmOverlay').classList.remove('open');
_confirmCallback = null;
}
document.getElementById('confirmOk').addEventListener('click', () => {
if (_confirmCallback) _confirmCallback();
closeConfirm();
});
// ---- Toast ----
function toast(msg, type='success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
container.appendChild(el);
requestAnimationFrame(() => { el.classList.add('show'); });
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 300);
}, 3000);
}
// ---- HTTP Helpers ----
async function get(path) {
const res = await fetch(API + path);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function post(path, body) {
const res = await fetch(API + path, { method: 'POST', headers: {'Content-Type':'application/json'}, body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
return res.json();
}
async function del(path) {
const res = await fetch(API + path, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ---- Formatters ----
function fmtDuration(ms) {
if (!ms || ms < 0) return '0ms';
if (ms < 1000) return ms + 'ms';
if (ms < 60000) return (ms/1000).toFixed(2) + 's';
const m = Math.floor(ms/60000);
const s = ((ms % 60000)/1000).toFixed(0);
return m + 'm ' + s + 's';
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// ---- Keyboard ----
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeModal(); closeConfirm(); }
});
init();
</script>
</body>
</html>"""
}
// =============================================================================
// Utilities
// =============================================================================
private fun formatEpoch(ms: Long): String {
val formatter = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss")
return Instant.ofEpochMilli(ms)
.atZone(ZoneId.systemDefault())
.format(formatter)
}
private fun formatUptime(ms: Long): String {
val totalSeconds = ms / 1000
val days = totalSeconds / 86400
val hours = (totalSeconds % 86400) / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return when {
days > 0 -> "${days}d ${hours}h ${minutes}m"
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${seconds}s"
}
}