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()
}