diff --git a/docs/screenshots/run-adaptive.png b/docs/screenshots/run-adaptive.png new file mode 100644 index 0000000..eea7663 Binary files /dev/null and b/docs/screenshots/run-adaptive.png differ diff --git a/internal/engine/adaptive.go b/internal/engine/adaptive.go new file mode 100644 index 0000000..d515f74 --- /dev/null +++ b/internal/engine/adaptive.go @@ -0,0 +1,55 @@ +package engine + +import ( + "context" + "database/sql" + "time" + + "marathon/internal/engine/throttle" +) + +// adaptiveDefaultCeiling bounds an adaptive run whose task has no explicit rate +// (rate_per_sec == 0). Without a ceiling the controller would ramp unbounded. +const adaptiveDefaultCeiling = 50_000 + +// probeInterval limits how often we sample target latency — at most once per +// this window — so a fast batch loop doesn't flood the DB with probes. +const probeInterval = 250 * time.Millisecond + +// adaptiveCeiling turns a task rate into the controller's upper bound: the +// task's rate when set, otherwise a sane default. +func adaptiveCeiling(taskRate int) float64 { + if taskRate > 0 { + return float64(taskRate) + } + + return adaptiveDefaultCeiling +} + +// newAdaptive builds an AIMD controller starting at the ceiling and ramping +// within [min, ceiling] based on observed target latency. +func newAdaptive(taskRate int) *throttle.AIMD { + ceiling := adaptiveCeiling(taskRate) + + return throttle.NewAIMD(throttle.AIMDConfig{ + Start: ceiling, // begin optimistic; back off on the first painful sample + Ceiling: ceiling, + }) +} + +// probeQuery is the round-trip used to sample target latency. A test seam lets +// integration tests substitute a deliberately slow query to exercise back-off. +var probeQuery = "SELECT 1" + +// probeLatency times a trivial round-trip to the target. This is the health +// signal the controller reacts to: when the DB is loaded, even `SELECT 1` slows. +func probeLatency(ctx context.Context, db *sql.DB) time.Duration { + start := time.Now() + + if err := db.QueryRowContext(ctx, probeQuery).Scan(new(int)); err != nil { + // A failed/slow probe is itself a strong "back off" signal. + return time.Second + } + + return time.Since(start) +} diff --git a/internal/engine/adaptive_test.go b/internal/engine/adaptive_test.go new file mode 100644 index 0000000..9d812a9 --- /dev/null +++ b/internal/engine/adaptive_test.go @@ -0,0 +1,96 @@ +package engine + +import ( + "context" + "testing" + + "marathon/internal/models" + "marathon/internal/store" +) + +func TestAdaptiveCeiling(t *testing.T) { + if got := adaptiveCeiling(8000); got != 8000 { + t.Errorf("adaptiveCeiling(8000) = %v, want 8000", got) + } + + if got := adaptiveCeiling(0); got != adaptiveDefaultCeiling { + t.Errorf("adaptiveCeiling(0) = %v, want default %d", got, adaptiveDefaultCeiling) + } +} + +// An adaptive run completes correctly against a healthy DB — the feedback loop +// must never break correctness (every row applied exactly once). +func TestIntegration_AdaptiveRunCompletes(t *testing.T) { + control, target := openOrSkip(t) + controlSchema(t, control) + + const n = 10000 + seedTarget(t, target, "items", n, "") + + task := &models.Task{ + Name: "adaptive-ok", TargetDSN: dsn("MARATHON_TEST_TARGET_DSN", "postgres://demo:demo@localhost:5434/demo?sslmode=disable"), + SourceTable: "items", CursorColumn: "id", BatchSize: 1000, + RatePerSec: 20000, Adaptive: true, + OperationSQL: "UPDATE items SET applied = applied + 1 WHERE id >= $1 AND id <= $2", + } + newTask(t, control, task) + + run, err := (store.RunStore{}).Create(context.Background(), control, task.ID) + if err != nil { + t.Fatal(err) + } + + r := NewRunner(control, nopLogger{}, *task, run.ID) + if r.adaptive == nil { + t.Fatal("adaptive controller should be set for an adaptive task") + } + + r.Run(context.Background()) + + assertState(t, control, run.ID, models.RunSucceeded) + assertCount(t, target, "SELECT count(*) FROM items WHERE applied <> 1", 0, "rows not applied exactly once") +} + +// Under injected latency the controller backs the rate off well below its +// ceiling — the point of the auto-throttle. +func TestIntegration_AdaptiveBacksOffUnderLatency(t *testing.T) { + control, target := openOrSkip(t) + controlSchema(t, control) + + const n = 4000 + seedTarget(t, target, "items", n, "") + + // Make each probe take ~80ms (> the 50ms high watermark) so every sample + // tells the controller to decrease. + orig := probeQuery + probeQuery = "SELECT 1 FROM pg_sleep(0.08)" + defer func() { probeQuery = orig }() + + const ceiling = 20000 + task := &models.Task{ + Name: "adaptive-backoff", TargetDSN: dsn("MARATHON_TEST_TARGET_DSN", "postgres://demo:demo@localhost:5434/demo?sslmode=disable"), + SourceTable: "items", CursorColumn: "id", BatchSize: 500, + RatePerSec: ceiling, Adaptive: true, + OperationSQL: "UPDATE items SET applied = applied + 1 WHERE id >= $1 AND id <= $2", + } + newTask(t, control, task) + + run, err := (store.RunStore{}).Create(context.Background(), control, task.ID) + if err != nil { + t.Fatal(err) + } + + r := NewRunner(control, nopLogger{}, *task, run.ID) + r.Run(context.Background()) + + assertState(t, control, run.ID, models.RunSucceeded) + + // The controller should have driven the rate far below the ceiling in + // response to the sustained high latency. + if got := r.adaptive.Rate(); got >= ceiling { + t.Errorf("adaptive rate = %.0f, expected well below ceiling %d after sustained latency", got, ceiling) + } + + // Correctness is unaffected by throttling. + assertCount(t, target, "SELECT count(*) FROM items WHERE applied <> 1", 0, "rows not applied exactly once") +} diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index bd5e772..3b0f18c 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -99,7 +99,8 @@ func controlSchema(t *testing.T, db *sql.DB) { ADD COLUMN IF NOT EXISTS operation_url TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS target_driver TEXT NOT NULL DEFAULT 'postgres', ADD COLUMN IF NOT EXISTS schedule_seconds INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS connection_id BIGINT REFERENCES connections(id)`); err != nil { + ADD COLUMN IF NOT EXISTS connection_id BIGINT REFERENCES connections(id), + ADD COLUMN IF NOT EXISTS adaptive BOOLEAN NOT NULL DEFAULT false`); err != nil { t.Fatalf("control alter: %v", err) } diff --git a/internal/engine/leaserunner.go b/internal/engine/leaserunner.go index e0ced7f..2e3060b 100644 --- a/internal/engine/leaserunner.go +++ b/internal/engine/leaserunner.go @@ -33,9 +33,11 @@ type LeaseWorker struct { queue *coord.Queue task models.Task runID int64 - bucket *throttle.Bucket - lastRate int - emit ProgressFunc + bucket *throttle.Bucket + adaptive *throttle.AIMD // non-nil when the task opts into auto-throttle + lastRate int + lastProbe time.Time + emit ProgressFunc tasks store.TaskStore runs store.RunStore @@ -43,7 +45,7 @@ type LeaseWorker struct { } func NewLeaseWorker(id string, db store.DB, log store.Logger, queue *coord.Queue, task models.Task, runID int64) *LeaseWorker { - return &LeaseWorker{ + w := &LeaseWorker{ id: id, db: db, log: log, @@ -53,19 +55,47 @@ func NewLeaseWorker(id string, db store.DB, log store.Logger, queue *coord.Queue bucket: throttle.New(task.RatePerSec), lastRate: task.RatePerSec, } + + if task.Adaptive { + w.adaptive = newAdaptive(task.RatePerSec) + } + + return w } // refreshRate picks up a live throttle change (from the dashboard speed slider) -// by re-reading the task's rate and adjusting this worker's bucket. Called once -// per lease, so already-running fleet workers respond to speed changes. +// by re-reading the task's rate. Called once per lease, so already-running fleet +// workers respond to speed changes. In adaptive mode the rate is the ceiling and +// the controller still governs the actual rate; otherwise it sets the bucket. func (w *LeaseWorker) refreshRate(ctx context.Context) { rate, err := w.tasks.GetRate(ctx, w.db, w.task.ID) if err != nil || rate == w.lastRate { return } - w.bucket.SetRate(rate) w.lastRate = rate + + if w.adaptive != nil { + w.adaptive.SetCeiling(adaptiveCeiling(rate)) + return + } + + w.bucket.SetRate(rate) +} + +// tune samples target latency (rate-limited) and steers the bucket rate via the +// AIMD controller. No-op unless the task opts into adaptive throttling. +func (w *LeaseWorker) tune(ctx context.Context, target *sql.DB) { + if w.adaptive == nil { + return + } + + if !w.lastProbe.IsZero() && time.Since(w.lastProbe) < probeInterval { + return + } + + w.lastProbe = time.Now() + w.bucket.SetRate(int(w.adaptive.Observe(probeLatency(ctx, target)))) } func (w *LeaseWorker) WithProgress(fn ProgressFunc) *LeaseWorker { @@ -170,6 +200,8 @@ func (w *LeaseWorker) processLease(ctx context.Context, target *sql.DB, lease co return ctx.Err() } + w.tune(ctx, target) + if err := w.bucket.Take(ctx, w.task.BatchSize); err != nil { return err } diff --git a/internal/engine/runner.go b/internal/engine/runner.go index 97022b6..a17b2d7 100644 --- a/internal/engine/runner.go +++ b/internal/engine/runner.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "sync/atomic" "time" _ "github.com/jackc/pgx/v5/stdlib" // target-DB driver @@ -34,8 +35,10 @@ type Runner struct { task models.Task runID int64 - bucket *throttle.Bucket - emit ProgressFunc + bucket *throttle.Bucket + adaptive *throttle.AIMD // non-nil when the task opts into auto-throttle + ceiling atomic.Int64 // live ceiling (rows/sec) for adaptive mode + emit ProgressFunc batchIdx int // crashHook, when set, is called after a batch's operation commits on the @@ -66,13 +69,20 @@ type Progress struct { } func NewRunner(db store.DB, log store.Logger, task models.Task, runID int64) *Runner { - return &Runner{ + r := &Runner{ db: db, log: log, task: task, runID: runID, bucket: throttle.New(task.RatePerSec), } + + if task.Adaptive { + r.adaptive = newAdaptive(task.RatePerSec) + r.ceiling.Store(int64(adaptiveCeiling(task.RatePerSec))) + } + + return r } // WithProgress registers a live-progress emitter. @@ -82,8 +92,16 @@ func (r *Runner) WithProgress(fn ProgressFunc) *Runner { } // SetRate changes the throttle rate (rows/sec, 0 = unlimited) of a running -// runner, live. Used by the dashboard speed slider. +// runner, live. Used by the dashboard speed slider. In adaptive mode the slider +// sets the ceiling; the controller still governs the actual rate below it. The +// ceiling is applied in the runner goroutine (via an atomic) so the AIMD is +// never mutated concurrently. func (r *Runner) SetRate(rowsPerSec int) { + if r.adaptive != nil { + r.ceiling.Store(int64(adaptiveCeiling(rowsPerSec))) + return + } + r.bucket.SetRate(rowsPerSec) } @@ -146,6 +164,8 @@ func (r *Runner) run(ctx context.Context) error { } } + var lastProbe time.Time + for { state, err := r.runs.GetState(ctx, r.db, r.runID) if err != nil { @@ -169,6 +189,8 @@ func (r *Runner) run(ctx context.Context) error { return nil // terminal state reached elsewhere; stop quietly } + r.tune(ctx, target, &lastProbe) + if err := r.bucket.Take(ctx, r.task.BatchSize); err != nil { return err } @@ -215,6 +237,25 @@ func (r *Runner) run(ctx context.Context) error { } } +// tune, in adaptive mode, samples target latency (rate-limited by probeInterval) +// and steers the token-bucket rate via the AIMD controller. All AIMD access is +// in this goroutine; SetRate only stores the ceiling atomically. +func (r *Runner) tune(ctx context.Context, target *sql.DB, lastProbe *time.Time) { + if r.adaptive == nil { + return + } + + if !lastProbe.IsZero() && time.Since(*lastProbe) < probeInterval { + return + } + + *lastProbe = time.Now() + + r.adaptive.SetCeiling(float64(r.ceiling.Load())) + rate := r.adaptive.Observe(probeLatency(ctx, target)) + r.bucket.SetRate(int(rate)) +} + // emitSnapshot publishes the run's current state to live subscribers. func (r *Runner) emitSnapshot(ctx context.Context, batchRows int) { if r.emit == nil { diff --git a/internal/models/task.go b/internal/models/task.go index 043a854..c998002 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -35,6 +35,10 @@ type Task struct { OperationSQL string `json:"operation_sql"` OperationURL string `json:"operation_url"` RatePerSec int `json:"rate_per_sec"` + // Adaptive enables the AIMD auto-throttle: the worker watches the target + // database's latency and adjusts the rate automatically, backing off when + // the DB is under load. rate_per_sec then acts as the ceiling. + Adaptive bool `json:"adaptive"` // ScheduleSeconds > 0 makes the task recurring: the scheduler starts a new // run every N seconds (skipping ticks while a run is still active). 0 = manual. ScheduleSeconds int `json:"schedule_seconds"` diff --git a/internal/store/task.go b/internal/store/task.go index d0fd15d..aaef902 100644 --- a/internal/store/task.go +++ b/internal/store/task.go @@ -15,11 +15,11 @@ type TaskStore struct{} func (TaskStore) Create(ctx context.Context, db DB, t *models.Task) error { err := db.QueryRowContext(ctx, ` INSERT INTO tasks (name, connection_id, target_driver, target_dsn, source_table, cursor_column, row_filter, - batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds, adaptive) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at`, t.Name, t.ConnectionID, t.TargetDriver, t.TargetDSN, t.SourceTable, t.CursorColumn, t.RowFilter, - t.BatchSize, t.OperationType, t.OperationSQL, t.OperationURL, t.RatePerSec, t.ScheduleSeconds, + t.BatchSize, t.OperationType, t.OperationSQL, t.OperationURL, t.RatePerSec, t.ScheduleSeconds, t.Adaptive, ).Scan(&t.ID, &t.CreatedAt) if err != nil { return fmt.Errorf("create task: %w", err) @@ -55,10 +55,10 @@ func (TaskStore) Get(ctx context.Context, db DB, id int64) (models.Task, error) err := db.QueryRowContext(ctx, ` SELECT id, name, connection_id, target_driver, target_dsn, source_table, cursor_column, row_filter, - batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds, created_at + batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds, adaptive, created_at FROM tasks WHERE id = $1`, id, ).Scan(&t.ID, &t.Name, &t.ConnectionID, &t.TargetDriver, &t.TargetDSN, &t.SourceTable, &t.CursorColumn, &t.RowFilter, - &t.BatchSize, &t.OperationType, &t.OperationSQL, &t.OperationURL, &t.RatePerSec, &t.ScheduleSeconds, &t.CreatedAt) + &t.BatchSize, &t.OperationType, &t.OperationSQL, &t.OperationURL, &t.RatePerSec, &t.ScheduleSeconds, &t.Adaptive, &t.CreatedAt) if err != nil { return models.Task{}, fmt.Errorf("get task %d: %w", id, err) } @@ -69,7 +69,7 @@ func (TaskStore) Get(ctx context.Context, db DB, id int64) (models.Task, error) func (TaskStore) List(ctx context.Context, db DB) ([]models.Task, error) { rows, err := db.QueryContext(ctx, ` SELECT id, name, connection_id, target_driver, target_dsn, source_table, cursor_column, row_filter, - batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds, created_at + batch_size, operation_type, operation_sql, operation_url, rate_per_sec, schedule_seconds, adaptive, created_at FROM tasks ORDER BY id DESC`) if err != nil { return nil, fmt.Errorf("list tasks: %w", err) @@ -83,7 +83,7 @@ func (TaskStore) List(ctx context.Context, db DB) ([]models.Task, error) { if err := rows.Scan(&t.ID, &t.Name, &t.ConnectionID, &t.TargetDriver, &t.TargetDSN, &t.SourceTable, &t.CursorColumn, &t.RowFilter, &t.BatchSize, &t.OperationType, &t.OperationSQL, &t.OperationURL, - &t.RatePerSec, &t.ScheduleSeconds, &t.CreatedAt); err != nil { + &t.RatePerSec, &t.ScheduleSeconds, &t.Adaptive, &t.CreatedAt); err != nil { return nil, fmt.Errorf("scan task: %w", err) } diff --git a/migrations/011_task_adaptive.go b/migrations/011_task_adaptive.go new file mode 100644 index 0000000..3f77d6e --- /dev/null +++ b/migrations/011_task_adaptive.go @@ -0,0 +1,17 @@ +package migrations + +import "gofr.dev/pkg/gofr/migration" + +// Adds the adaptive auto-throttle opt-in: when true, the worker steers the rate +// by observed target-DB latency, using rate_per_sec as the ceiling. +func addAdaptive() migration.Migrate { + return migration.Migrate{ + UP: func(d migration.Datasource) error { + _, err := d.SQL.Exec(` + ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS adaptive BOOLEAN NOT NULL DEFAULT false`) + + return err + }, + } +} diff --git a/migrations/all.go b/migrations/all.go index ca02ad8..0de43ef 100644 --- a/migrations/all.go +++ b/migrations/all.go @@ -16,5 +16,6 @@ func All() map[int64]migration.Migrate { 8: addTargetDriver(), 9: addSchedule(), 10: addTaskConnection(), + 11: addAdaptive(), } } diff --git a/web/src/api.ts b/web/src/api.ts index 8bb0693..f9ce113 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -77,7 +77,12 @@ export function liveSocket( ws.onopen = () => ws.send(JSON.stringify({ run_id: runId })); ws.onmessage = (ev) => { try { - onMessage(JSON.parse(ev.data)); + const p = JSON.parse(ev.data); + // The server can emit a `null` frame (e.g. an empty handshake result); + // only forward well-formed progress objects. + if (p && typeof p === "object" && "run_id" in p) { + onMessage(p as import("./types").Progress); + } } catch { /* ignore malformed frame */ } diff --git a/web/src/pages/RunLive.tsx b/web/src/pages/RunLive.tsx index b6fce88..3b8b6ac 100644 --- a/web/src/pages/RunLive.tsx +++ b/web/src/pages/RunLive.tsx @@ -21,6 +21,7 @@ export function RunLive({ runId, onBack }: { runId: number; onBack: () => void } const [notice, setNotice] = useState(""); const [speed, setSpeed] = useState(null); const [speedMax, setSpeedMax] = useState(100000); + const [adaptive, setAdaptive] = useState(false); const last = useRef<{ t: number; processed: number } | null>(null); const speedTimer = useRef | undefined>(undefined); @@ -31,6 +32,7 @@ export function RunLive({ runId, onBack }: { runId: number; onBack: () => void } .then((t) => { setSpeed(t.rate_per_sec); setSpeedMax(Math.max(100000, (t.rate_per_sec || 0) * 2)); + setAdaptive(t.adaptive); }) .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -47,6 +49,7 @@ export function RunLive({ runId, onBack }: { runId: number; onBack: () => void } useEffect(() => { api.getRun(runId).then(setRun).catch(() => {}); const ws = liveSocket(runId, (p: Progress) => { + if (!p) return; setRun((prev) => ({ ...(prev as Run), ...p, id: p.run_id })); const now = Date.now(); if (last.current) { @@ -142,8 +145,8 @@ export function RunLive({ runId, onBack }: { runId: number; onBack: () => void } {active && speed !== null && (
- Throttle - {speed === 0 ? "Max (unthrottled)" : `${fmt(speed)} rows/s`} + Throttle {adaptive && AUTO} + {speed === 0 ? (adaptive ? "Auto (no ceiling)" : "Max (unthrottled)") : `${adaptive ? "ceiling " : ""}${fmt(speed)} rows/s`}
void } onChange={(e) => onSpeed(Number(e.target.value))} aria-label="Throttle rate" /> + {adaptive &&
Auto-throttle adjusts the live rate by the target's latency; the slider sets the ceiling.
}
)} diff --git a/web/src/pages/TaskWizard.tsx b/web/src/pages/TaskWizard.tsx index 2203037..d4077e4 100644 --- a/web/src/pages/TaskWizard.tsx +++ b/web/src/pages/TaskWizard.tsx @@ -22,6 +22,7 @@ type Form = { operation_url: string; batch_size: number; rate_per_sec: number; + adaptive: boolean; schedule_seconds: number; }; @@ -38,6 +39,7 @@ const initial: Form = { operation_url: "", batch_size: 2000, rate_per_sec: 10000, + adaptive: false, schedule_seconds: 0, }; @@ -300,9 +302,9 @@ export function TaskWizard({ set("batch_size", +e.target.value)} />
- + set("rate_per_sec", +e.target.value)} /> -
0 = unthrottled.
+
{f.adaptive ? "Upper bound; auto-throttle stays below it." : "0 = unthrottled."}
@@ -310,6 +312,14 @@ export function TaskWizard({
0 = manual only.
+ + diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index e46a2c1..153d747 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -78,7 +78,7 @@ export function Tasks({ onOpenRun, onNew }: { onOpenRun: (runId: number) => void {t.target_driver || "postgres"} {t.operation_type === "http" ? "http callback" : "sql"} {t.source_table} · batch {fmt(t.batch_size)} - · {t.rate_per_sec ? `${fmt(t.rate_per_sec)}/s` : "max rate"} + · {t.adaptive ? `auto ≤ ${t.rate_per_sec ? fmt(t.rate_per_sec) : "50k"}/s` : t.rate_per_sec ? `${fmt(t.rate_per_sec)}/s` : "max rate"} {t.schedule_seconds > 0 && · ⟳ every {t.schedule_seconds}s} diff --git a/web/src/styles.css b/web/src/styles.css index 912c0f7..76f1332 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -22,9 +22,13 @@ --accent-ring: #1d54d633; --accent-border: #d3e0fb; - --good: #12805c; --good-weak: #e4f3ec; - --warn: #a8690f; --warn-weak: #f9efdb; - --crit: #c23548; --crit-weak: #fbe7ea; + --good: #12805c; --good-weak: #e4f3ec; --good-border: #bfe3d1; + --warn: #a8690f; --warn-weak: #f9efdb; --warn-border: #e7d3a8; + --crit: #c23548; --crit-weak: #fbe7ea; --crit-border: #eec3c9; + + --hover-bg: #fafbfd; /* row / subtle hover surface */ + --btn-hover-bg: #f5f7fa; /* default button hover */ + --btn-hover-border: #c2cad6; --code-bg: #eef2f8; --code-fg: #263043; --code-border: #dde3ee; --grid: #00000010; @@ -54,9 +58,13 @@ --accent-ring: #5488ff44; --accent-border: #24365c; - --good: #34c78d; --good-weak: #10281f; - --warn: #e0a951; --warn-weak: #2a2113; - --crit: #ef6b7a; --crit-weak: #2c1720; + --good: #34c78d; --good-weak: #10281f; --good-border: #24503b; + --warn: #e0a951; --warn-weak: #2a2113; --warn-border: #5a4a24; + --crit: #ef6b7a; --crit-weak: #2c1720; --crit-border: #5c2a34; + + --hover-bg: #1a2130; /* row / subtle hover surface */ + --btn-hover-bg: #1c2434; /* default button hover */ + --btn-hover-border: #3a4557; --code-bg: #0a0e16; --code-fg: #cdd7ea; --code-border: #212836; --grid: #ffffff12; @@ -81,7 +89,7 @@ a { color: var(--accent); text-decoration: none; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 20px; border: 3px solid var(--paper); } -::-webkit-scrollbar-thumb:hover { background: #c2cad6; } +::-webkit-scrollbar-thumb:hover { background: var(--btn-hover-border); } /* ---------- shell -------------------------------------------------------- */ .app { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; } @@ -157,13 +165,15 @@ a { color: var(--accent); text-decoration: none; } background: var(--card); color: var(--ink); font: inherit; font-weight: 550; font-size: 13.5px; cursor: pointer; transition: all .14s; white-space: nowrap; } -.btn:hover { border-color: #c2cad6; background: #fafbfc; } +.btn:hover { border-color: var(--btn-hover-border); background: var(--btn-hover-bg); } .btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); } .btn:disabled { opacity: .5; cursor: not-allowed; } .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 6px 16px -8px var(--accent-ring); } .btn.primary:hover { background: var(--accent-press); border-color: var(--accent-press); } -.btn.warn { background: var(--warn-weak); border-color: #e7d3a8; color: var(--warn); } -.btn.danger { background: var(--crit-weak); border-color: #eec3c9; color: var(--crit); } +.btn.warn { background: var(--warn-weak); border-color: var(--warn-border); color: var(--warn); } +.btn.warn:hover { border-color: var(--warn); } +.btn.danger { background: var(--crit-weak); border-color: var(--crit-border); color: var(--crit); } +.btn.danger:hover { border-color: var(--crit); } .btn.ghost { background: transparent; border-color: transparent; color: var(--ink-2); } .btn.ghost:hover { background: var(--paper-2); color: var(--ink); } .btn.lg { padding: 12px 22px; font-size: 15px; } @@ -187,6 +197,11 @@ a { color: var(--accent); text-decoration: none; } .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; } +.toggle-row { display: flex; gap: 11px; align-items: flex-start; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper-2); cursor: pointer; } +.toggle-row input { margin-top: 3px; width: 16px; height: 16px; accent-color: var(--accent); flex: none; cursor: pointer; } +.toggle-row strong { font-size: 13.5px; } +.toggle-row .hint { font-size: 12.5px; color: var(--ink-2); } + .seg { display: inline-flex; background: var(--paper-2); border: 1px solid var(--line-2); border-radius: 9px; padding: 3px; gap: 3px; } .seg button { border: 0; background: transparent; color: var(--ink-2); font: inherit; font-weight: 600; font-size: 13px; padding: 7px 15px; border-radius: 6px; cursor: pointer; transition: all .14s; } .seg button.on { background: var(--card); color: var(--accent); box-shadow: 0 1px 2px #0002; } @@ -206,25 +221,25 @@ a { color: var(--accent); text-decoration: none; } /* ---------- badges / pills ---------------------------------------------- */ .badge { display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; border: 1px solid transparent; } .badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } -.badge.running { background: var(--accent-weak); color: var(--accent); border-color: #cfddfb; } +.badge.running { background: var(--accent-weak); color: var(--accent); border-color: var(--accent-border); } .badge.running .dot { animation: pulse 1.5s infinite; } -.badge.paused { background: var(--warn-weak); color: var(--warn); border-color: #ecd9b0; } -.badge.succeeded { background: var(--good-weak); color: var(--good); border-color: #c2e2d3; } -.badge.failed, .badge.killed { background: var(--crit-weak); color: var(--crit); border-color: #efc7ce; } +.badge.paused { background: var(--warn-weak); color: var(--warn); border-color: var(--warn-border); } +.badge.succeeded { background: var(--good-weak); color: var(--good); border-color: var(--good-border); } +.badge.failed, .badge.killed { background: var(--crit-weak); color: var(--crit); border-color: var(--crit-border); } .badge.queued { background: var(--paper-2); color: var(--ink-2); border-color: var(--line-2); } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } } @media (prefers-reduced-motion: reduce) { .badge .dot { animation: none; } } .pill { font-family: var(--mono); font-size: 11px; padding: 2px 8px; border-radius: 6px; background: var(--paper-2); color: var(--ink-2); border: 1px solid var(--line); } -.pill.pg { color: var(--accent); background: var(--accent-weak); border-color: #d3e0fb; } -.pill.mysql { color: var(--warn); background: var(--warn-weak); border-color: #ecd9b0; } +.pill.pg { color: var(--accent); background: var(--accent-weak); border-color: var(--accent-border); } +.pill.mysql { color: var(--warn); background: var(--warn-weak); border-color: var(--warn-border); } .pill.pending { color: var(--warn); } .pill.resolved { color: var(--good); } /* ---------- task list ---------------------------------------------------- */ .tasklist { display: flex; flex-direction: column; } .taskrow { display: flex; align-items: center; gap: 16px; padding: 18px 26px; border-top: 1px solid var(--line); transition: background .14s; } .taskrow:first-child { border-top: 0; } -.taskrow:hover { background: #fafbfd; } +.taskrow:hover { background: var(--hover-bg); } .taskrow .tname { font-weight: 640; font-size: 15px; letter-spacing: -0.2px; } .taskrow .tmeta { color: var(--faint); font-size: 12px; margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .taskrow .spacer { flex: 1; } @@ -286,6 +301,7 @@ a { color: var(--accent); text-decoration: none; } border: 3px solid var(--card); box-shadow: 0 1px 4px #0003; cursor: pointer; } .speed input[type="range"]:focus-visible { box-shadow: 0 0 0 3px var(--accent-ring); } +.auto-chip { font-size: 9px; font-weight: 800; letter-spacing: 0.6px; padding: 1px 6px; border-radius: 5px; background: var(--good-weak); color: var(--good); margin-left: 6px; vertical-align: 1px; } .controls { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; align-items: center; } .done-note { color: var(--good); font-weight: 550; font-size: 13.5px; } diff --git a/web/src/types.ts b/web/src/types.ts index 659d71a..eb913fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -19,6 +19,7 @@ export interface Task { operation_sql: string; operation_url: string; rate_per_sec: number; + adaptive: boolean; schedule_seconds: number; created_at: string; }