clawmon 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/clawmon.js ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+
3
+ const BASE = process.env.CLAWMON_URL || "http://127.0.0.1:7070";
4
+
5
+ const [,, cmd, ...args] = process.argv;
6
+
7
+ async function api(path, opts) {
8
+ const res = await fetch(`${BASE}${path}`, opts);
9
+ const body = await res.json();
10
+ if (!res.ok) {
11
+ console.error(`Error ${res.status}: ${body.error || JSON.stringify(body)}`);
12
+ process.exit(1);
13
+ }
14
+ return body;
15
+ }
16
+
17
+ function flag(name) {
18
+ const i = args.indexOf(name);
19
+ if (i === -1) return undefined;
20
+ return args[i + 1];
21
+ }
22
+
23
+ function printTable(tasks) {
24
+ if (!tasks.length) { console.log("No tasks found."); return; }
25
+ const cols = [
26
+ { key: "id", label: "ID", w: 14 },
27
+ { key: "name", label: "Name", w: 30 },
28
+ { key: "kind", label: "Kind", w: 14 },
29
+ { key: "status", label: "Status", w: 10 },
30
+ ];
31
+ const header = cols.map(c => c.label.padEnd(c.w)).join(" ");
32
+ console.log(header);
33
+ console.log("-".repeat(header.length));
34
+ for (const t of tasks) {
35
+ const row = cols.map(c => String(t[c.key] ?? "").padEnd(c.w)).join(" ");
36
+ console.log(row);
37
+ }
38
+ }
39
+
40
+ async function main() {
41
+ switch (cmd) {
42
+ case "status": {
43
+ const data = await api("/api/overview");
44
+ console.log(`Total: ${data.total} Running: ${data.running} Failed: ${data.failed}`);
45
+ if (data.last_updated) {
46
+ console.log(`Last update: ${new Date(data.last_updated * 1000).toLocaleString()}`);
47
+ }
48
+ break;
49
+ }
50
+
51
+ case "tasks": {
52
+ const status = flag("--status");
53
+ let tasks;
54
+ if (status === "running") tasks = await api("/api/tasks/running");
55
+ else if (status === "failed") tasks = await api("/api/tasks/failed");
56
+ else tasks = await api("/api/tasks");
57
+ printTable(tasks);
58
+ break;
59
+ }
60
+
61
+ case "add": {
62
+ const name = args.filter(a => !a.startsWith("--"))[0];
63
+ if (!name) { console.error("Usage: clawmon add <name> [--kind <k>]"); process.exit(1); }
64
+ const kind = flag("--kind");
65
+ const body = { name };
66
+ if (kind) body.kind = kind;
67
+ const task = await api("/api/tasks", {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify(body),
71
+ });
72
+ console.log(`Created task ${task.id}: ${task.name}`);
73
+ break;
74
+ }
75
+
76
+ case "update": {
77
+ const id = args.filter(a => !a.startsWith("--"))[0];
78
+ if (!id) { console.error("Usage: clawmon update <id> --status <s>"); process.exit(1); }
79
+ const updates = {};
80
+ const status = flag("--status");
81
+ const error = flag("--error");
82
+ const exitCode = flag("--exit-code");
83
+ if (status) updates.status = status;
84
+ if (error) updates.error = error;
85
+ if (exitCode) updates.exit_code = Number(exitCode);
86
+ const task = await api(`/api/tasks/${id}`, {
87
+ method: "PATCH",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify(updates),
90
+ });
91
+ console.log(`Updated task ${task.id}: status=${task.status}`);
92
+ break;
93
+ }
94
+
95
+ case "delete": {
96
+ const id = args[0];
97
+ if (!id) { console.error("Usage: clawmon delete <id>"); process.exit(1); }
98
+ await api(`/api/tasks/${id}`, { method: "DELETE" });
99
+ console.log(`Deleted task ${id}`);
100
+ break;
101
+ }
102
+
103
+ case "load": {
104
+ const file = args[0];
105
+ if (!file) { console.error("Usage: clawmon load <file.json>"); process.exit(1); }
106
+ const { readFileSync } = await import("node:fs");
107
+ const data = JSON.parse(readFileSync(file, "utf-8"));
108
+ const tasks = Array.isArray(data) ? data : data.tasks;
109
+ if (!Array.isArray(tasks)) { console.error("JSON must be an array or {tasks: [...]}"); process.exit(1); }
110
+ const result = await api("/api/tasks/bulk", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ tasks }),
114
+ });
115
+ console.log(`Bulk load: ${result.created} created, ${result.updated} updated`);
116
+ break;
117
+ }
118
+
119
+ case "open": {
120
+ const { exec } = await import("node:child_process");
121
+ const url = BASE;
122
+ const platform = process.platform;
123
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
124
+ exec(`${cmd} ${url}`);
125
+ console.log(`Opening ${url}`);
126
+ break;
127
+ }
128
+
129
+ case "help":
130
+ case undefined:
131
+ case "--help":
132
+ case "-h": {
133
+ console.log(`clawmon — task monitor CLI
134
+
135
+ Usage:
136
+ clawmon status Overview counts
137
+ clawmon tasks [--status <s>] List tasks
138
+ clawmon add <name> [--kind <k>] Create task
139
+ clawmon update <id> --status <s> Update task
140
+ clawmon delete <id> Delete task
141
+ clawmon load <file.json> Bulk load from JSON
142
+ clawmon open Open web UI in browser
143
+ clawmon help Show this help
144
+
145
+ Environment:
146
+ CLAWMON_URL Server URL (default: http://127.0.0.1:7070)`);
147
+ break;
148
+ }
149
+
150
+ default:
151
+ console.error(`Unknown command: ${cmd}\nRun "clawmon help" for usage.`);
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ main().catch(err => {
157
+ console.error(err.message);
158
+ process.exit(1);
159
+ });
@@ -1,11 +1,12 @@
1
1
  {
2
+ "id": "clawmon",
2
3
  "name": "clawmon",
3
- "version": "0.1.0",
4
+ "version": "0.2.0",
4
5
  "description": "Local task monitor with web UI — tracks running, stale, and failed tasks",
5
6
  "entry": "src/index.ts",
6
- "config": {
7
- "port": 7070,
8
- "db_dir": "~/.openclaw/monitor",
9
- "retention_days": 7
7
+ "configSchema": {
8
+ "port": { "type": "number", "default": 7070 },
9
+ "db_dir": { "type": "string", "default": "~/.openclaw/monitor" },
10
+ "retention_days": { "type": "number", "default": 7 }
10
11
  }
11
12
  }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "clawmon",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Local task monitor plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
+ "bin": {
8
+ "clawmon": "bin/clawmon.js"
9
+ },
7
10
  "files": [
8
11
  "src",
9
12
  "ui",
13
+ "bin",
10
14
  "openclaw.plugin.json"
11
15
  ],
12
16
  "scripts": {
package/src/db.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { mkdirSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
3
4
  import type { MonitorConfig } from "./config.js";
4
5
 
5
6
  let db: Database.Database | null = null;
6
7
 
8
+ export interface TaskInput {
9
+ name: string;
10
+ kind?: string;
11
+ status?: string;
12
+ pid?: number;
13
+ id?: string;
14
+ }
15
+
16
+ export interface TaskUpdate {
17
+ name?: string;
18
+ kind?: string;
19
+ status?: string;
20
+ pid?: number;
21
+ exit_code?: number;
22
+ error?: string;
23
+ started_at?: number;
24
+ finished_at?: number;
25
+ }
26
+
7
27
  export function initDb(config: MonitorConfig): Database.Database {
8
28
  mkdirSync(config.dbDir, { recursive: true });
9
29
  db = new Database(config.dbPath);
@@ -22,6 +42,13 @@ export function initDb(config: MonitorConfig): Database.Database {
22
42
  error TEXT
23
43
  )
24
44
  `);
45
+ db.exec(`
46
+ CREATE TABLE IF NOT EXISTS webhooks (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ url TEXT NOT NULL,
49
+ enabled INTEGER NOT NULL DEFAULT 1
50
+ )
51
+ `);
25
52
  return db;
26
53
  }
27
54
 
@@ -44,3 +71,112 @@ export function query(sql: string, ...params: unknown[]): Record<string, unknown
44
71
  export function queryOne(sql: string, ...params: unknown[]): Record<string, unknown> | undefined {
45
72
  return (getDb().prepare(sql).get(...params) as Record<string, unknown>) ?? undefined;
46
73
  }
74
+
75
+ export function generateId(): string {
76
+ return randomBytes(6).toString("hex");
77
+ }
78
+
79
+ export function insertTask(input: TaskInput): Record<string, unknown> {
80
+ const id = input.id ?? generateId();
81
+ const now = Math.floor(Date.now() / 1000);
82
+ const status = input.status ?? "pending";
83
+ const startedAt = status === "running" ? now : null;
84
+
85
+ getDb().prepare(`
86
+ INSERT INTO tasks (id, name, kind, status, pid, started_at, updated_at)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?)
88
+ `).run(id, input.name, input.kind ?? null, status, input.pid ?? null, startedAt, now);
89
+
90
+ return queryOne("SELECT * FROM tasks WHERE id = ?", id)!;
91
+ }
92
+
93
+ export function updateTask(id: string, updates: TaskUpdate): Record<string, unknown> | undefined {
94
+ const fields: string[] = [];
95
+ const values: unknown[] = [];
96
+
97
+ if (updates.name !== undefined) { fields.push("name = ?"); values.push(updates.name); }
98
+ if (updates.kind !== undefined) { fields.push("kind = ?"); values.push(updates.kind); }
99
+ if (updates.status !== undefined) {
100
+ fields.push("status = ?"); values.push(updates.status);
101
+ if (updates.status === "running" && updates.started_at === undefined) {
102
+ fields.push("started_at = ?"); values.push(Math.floor(Date.now() / 1000));
103
+ }
104
+ if (updates.status === "done" || updates.status === "failed") {
105
+ fields.push("finished_at = ?"); values.push(Math.floor(Date.now() / 1000));
106
+ }
107
+ }
108
+ if (updates.pid !== undefined) { fields.push("pid = ?"); values.push(updates.pid); }
109
+ if (updates.exit_code !== undefined) { fields.push("exit_code = ?"); values.push(updates.exit_code); }
110
+ if (updates.error !== undefined) { fields.push("error = ?"); values.push(updates.error); }
111
+ if (updates.started_at !== undefined) { fields.push("started_at = ?"); values.push(updates.started_at); }
112
+ if (updates.finished_at !== undefined) { fields.push("finished_at = ?"); values.push(updates.finished_at); }
113
+
114
+ if (fields.length === 0) return queryOne("SELECT * FROM tasks WHERE id = ?", id);
115
+
116
+ fields.push("updated_at = ?");
117
+ values.push(Math.floor(Date.now() / 1000));
118
+ values.push(id);
119
+
120
+ const result = getDb().prepare(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
121
+ if (result.changes === 0) return undefined;
122
+ return queryOne("SELECT * FROM tasks WHERE id = ?", id);
123
+ }
124
+
125
+ export function deleteTask(id: string): boolean {
126
+ const result = getDb().prepare("DELETE FROM tasks WHERE id = ?").run(id);
127
+ return result.changes > 0;
128
+ }
129
+
130
+ export function bulkUpsert(tasks: TaskInput[]): { created: number; updated: number } {
131
+ const d = getDb();
132
+ const now = Math.floor(Date.now() / 1000);
133
+ let created = 0;
134
+ let updated = 0;
135
+
136
+ const upsert = d.prepare(`
137
+ INSERT INTO tasks (id, name, kind, status, pid, started_at, updated_at)
138
+ VALUES (?, ?, ?, ?, ?, ?, ?)
139
+ ON CONFLICT(id) DO UPDATE SET
140
+ name = excluded.name,
141
+ kind = excluded.kind,
142
+ status = excluded.status,
143
+ pid = excluded.pid,
144
+ updated_at = excluded.updated_at
145
+ `);
146
+
147
+ const tx = d.transaction(() => {
148
+ for (const t of tasks) {
149
+ const id = t.id ?? generateId();
150
+ const status = t.status ?? "pending";
151
+ const startedAt = status === "running" ? now : null;
152
+ const existing = queryOne("SELECT id FROM tasks WHERE id = ?", id);
153
+ upsert.run(id, t.name, t.kind ?? null, status, t.pid ?? null, startedAt, now);
154
+ if (existing) updated++; else created++;
155
+ }
156
+ });
157
+
158
+ tx();
159
+ return { created, updated };
160
+ }
161
+
162
+ // --- Webhook CRUD ---
163
+
164
+ export function getWebhooks(): Record<string, unknown>[] {
165
+ return query("SELECT * FROM webhooks ORDER BY id");
166
+ }
167
+
168
+ export function insertWebhook(url: string): Record<string, unknown> {
169
+ const result = getDb().prepare("INSERT INTO webhooks (url) VALUES (?)").run(url);
170
+ return queryOne("SELECT * FROM webhooks WHERE id = ?", result.lastInsertRowid)!;
171
+ }
172
+
173
+ export function deleteWebhook(id: number): boolean {
174
+ const result = getDb().prepare("DELETE FROM webhooks WHERE id = ?").run(id);
175
+ return result.changes > 0;
176
+ }
177
+
178
+ export function toggleWebhook(id: number, enabled: boolean): Record<string, unknown> | undefined {
179
+ const result = getDb().prepare("UPDATE webhooks SET enabled = ? WHERE id = ?").run(enabled ? 1 : 0, id);
180
+ if (result.changes === 0) return undefined;
181
+ return queryOne("SELECT * FROM webhooks WHERE id = ?", id);
182
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { insertTask, updateTask } from "./db.js";
2
+
3
+ interface PluginApi {
4
+ on(event: string, handler: (...args: unknown[]) => void): void;
5
+ }
6
+
7
+ export function registerHooks(api: PluginApi): void {
8
+ api.on("session_start", (ctx: unknown) => {
9
+ try {
10
+ const context = ctx as Record<string, unknown>;
11
+ const sessionId = (context.sessionId as string) ?? "unknown";
12
+ const taskId = `agent-${sessionId}`;
13
+ insertTask({
14
+ id: taskId,
15
+ name: `Agent session ${sessionId}`,
16
+ kind: "agent-session",
17
+ status: "running",
18
+ });
19
+ console.log(`[clawmon] tracking agent session: ${taskId}`);
20
+ } catch (err) {
21
+ console.error("[clawmon] session_start hook error:", (err as Error).message);
22
+ }
23
+ });
24
+
25
+ api.on("session_end", (ctx: unknown) => {
26
+ try {
27
+ const context = ctx as Record<string, unknown>;
28
+ const sessionId = (context.sessionId as string) ?? "unknown";
29
+ const taskId = `agent-${sessionId}`;
30
+ const failed = context.error || context.exitCode;
31
+ updateTask(taskId, {
32
+ status: failed ? "failed" : "done",
33
+ error: context.error ? String(context.error) : undefined,
34
+ exit_code: context.exitCode as number | undefined,
35
+ });
36
+ console.log(`[clawmon] session ended: ${taskId} (${failed ? "failed" : "done"})`);
37
+ } catch (err) {
38
+ console.error("[clawmon] session_end hook error:", (err as Error).message);
39
+ }
40
+ });
41
+
42
+ api.on("before_agent_start", (ctx: unknown) => {
43
+ try {
44
+ const context = ctx as Record<string, unknown>;
45
+ if (typeof context.addSystemPrompt === "function") {
46
+ (context.addSystemPrompt as (hint: string) => void)(
47
+ "You have access to clawmon task monitoring tools: " +
48
+ "clawmon_create_task, clawmon_update_task, clawmon_list_tasks, clawmon_bulk_load. " +
49
+ "Use them to track task progress and view status."
50
+ );
51
+ }
52
+ } catch (err) {
53
+ console.error("[clawmon] before_agent_start hook error:", (err as Error).message);
54
+ }
55
+ });
56
+
57
+ console.log("[clawmon] registered lifecycle hooks");
58
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ import { resolveConfig } from "./config.js";
2
2
  import { initDb, closeDb } from "./db.js";
3
3
  import { startWatcher } from "./watcher.js";
4
4
  import { createHttpServer, listenServer } from "./server.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { registerHooks } from "./hooks.js";
5
7
  import type { Server } from "node:http";
6
8
 
7
9
  interface PluginApi {
@@ -10,6 +12,13 @@ interface PluginApi {
10
12
  start: () => Promise<void>;
11
13
  stop: () => Promise<void>;
12
14
  }): void;
15
+ registerTool?(tool: {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: Record<string, unknown>;
19
+ handler: (input: Record<string, unknown>) => unknown;
20
+ }): void;
21
+ on?(event: string, handler: (...args: unknown[]) => void): void;
13
22
  config?: Record<string, unknown>;
14
23
  }
15
24
 
@@ -17,6 +26,16 @@ export function register(api: PluginApi): void {
17
26
  let stopWatcher: (() => void) | null = null;
18
27
  let server: Server | null = null;
19
28
 
29
+ // Register agent tools if the host supports it
30
+ if (api.registerTool) {
31
+ registerTools(api as { registerTool: NonNullable<PluginApi["registerTool"]> });
32
+ }
33
+
34
+ // Register lifecycle hooks if the host supports it
35
+ if (api.on) {
36
+ registerHooks(api as { on: NonNullable<PluginApi["on"]> });
37
+ }
38
+
20
39
  api.registerService({
21
40
  id: "clawmon",
22
41
 
package/src/server.ts CHANGED
@@ -2,10 +2,15 @@ import { createServer, type Server, type IncomingMessage, type ServerResponse }
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join, extname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { query, queryOne } from "./db.js";
5
+ import {
6
+ query, queryOne,
7
+ insertTask, updateTask, deleteTask, bulkUpsert,
8
+ getWebhooks, insertWebhook, deleteWebhook, toggleWebhook,
9
+ } from "./db.js";
10
+ import { fireWebhooks, fireTestWebhook } from "./webhooks.js";
6
11
  import type { MonitorConfig } from "./config.js";
7
12
 
8
- const VERSION = "0.1.0";
13
+ const VERSION = "0.2.0";
9
14
 
10
15
  const MIME: Record<string, string> = {
11
16
  ".html": "text/html; charset=utf-8",
@@ -25,9 +30,9 @@ let startTime = 0;
25
30
  export function createHttpServer(config: MonitorConfig): Server {
26
31
  startTime = Date.now();
27
32
 
28
- const server = createServer((req, res) => {
33
+ const server = createServer(async (req, res) => {
29
34
  try {
30
- route(req, res, config);
35
+ await route(req, res, config);
31
36
  } catch (err) {
32
37
  res.writeHead(500, { "Content-Type": "application/json" });
33
38
  res.end(JSON.stringify({ error: "Internal server error" }));
@@ -44,30 +49,136 @@ export function listenServer(server: Server, config: MonitorConfig): Promise<voi
44
49
  });
45
50
  }
46
51
 
47
- function route(req: IncomingMessage, res: ServerResponse, config: MonitorConfig): void {
52
+ // --- Body parser ---
53
+
54
+ function readBody(req: IncomingMessage): Promise<string> {
55
+ return new Promise((resolve, reject) => {
56
+ const chunks: Buffer[] = [];
57
+ let size = 0;
58
+ const MAX = 1024 * 1024; // 1MB
59
+ req.on("data", (chunk: Buffer) => {
60
+ size += chunk.length;
61
+ if (size > MAX) { req.destroy(); reject(new Error("Body too large")); return; }
62
+ chunks.push(chunk);
63
+ });
64
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
65
+ req.on("error", reject);
66
+ });
67
+ }
68
+
69
+ // --- CORS headers ---
70
+
71
+ function setCors(res: ServerResponse): void {
72
+ res.setHeader("Access-Control-Allow-Origin", "*");
73
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
74
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
75
+ }
76
+
77
+ // --- Router ---
78
+
79
+ async function route(req: IncomingMessage, res: ServerResponse, config: MonitorConfig): Promise<void> {
48
80
  const url = req.url ?? "/";
49
81
  const method = req.method ?? "GET";
50
82
 
51
- if (method !== "GET") {
52
- res.writeHead(405, { "Content-Type": "application/json" });
53
- res.end(JSON.stringify({ error: "Method not allowed" }));
83
+ setCors(res);
84
+
85
+ // Preflight
86
+ if (method === "OPTIONS") {
87
+ res.writeHead(204);
88
+ res.end();
89
+ return;
90
+ }
91
+
92
+ // --- GET routes ---
93
+ if (method === "GET") {
94
+ if (url === "/health") return json(res, { status: "ok" });
95
+ if (url === "/api/overview") return apiOverview(res);
96
+ if (url === "/api/tasks") return apiAllTasks(res);
97
+ if (url === "/api/tasks/running") return apiRunningTasks(res);
98
+ if (url === "/api/tasks/recent") return apiRecentTasks(res);
99
+ if (url === "/api/tasks/failed") return apiFailedTasks(res);
100
+ if (url === "/api/system") return apiSystem(res, config);
101
+ if (url === "/api/webhooks") return json(res, getWebhooks());
102
+
103
+ // Static files
104
+ if (url.startsWith("/static/")) { serveStatic(res, url.slice("/static/".length)); return; }
105
+
106
+ // Root → index.html
107
+ if (url === "/" || url === "/index.html") { serveStatic(res, "index.html"); return; }
108
+
109
+ res.writeHead(404, { "Content-Type": "application/json" });
110
+ res.end(JSON.stringify({ error: "Not found" }));
54
111
  return;
55
112
  }
56
113
 
57
- // API routes
58
- if (url === "/health") return json(res, { status: "ok" });
59
- if (url === "/api/overview") return apiOverview(res);
60
- if (url === "/api/tasks") return apiAllTasks(res);
61
- if (url === "/api/tasks/running") return apiRunningTasks(res);
62
- if (url === "/api/tasks/recent") return apiRecentTasks(res);
63
- if (url === "/api/tasks/failed") return apiFailedTasks(res);
64
- if (url === "/api/system") return apiSystem(res, config);
114
+ // --- POST routes ---
115
+ if (method === "POST") {
116
+ if (url === "/api/tasks") {
117
+ const body = JSON.parse(await readBody(req));
118
+ if (!body.name) return jsonError(res, 400, "name is required");
119
+ const task = insertTask(body);
120
+ fireWebhooks("task.created", task);
121
+ return json(res, task, 201);
122
+ }
123
+
124
+ if (url === "/api/tasks/bulk") {
125
+ const body = JSON.parse(await readBody(req));
126
+ if (!Array.isArray(body.tasks)) return jsonError(res, 400, "tasks array is required");
127
+ const result = bulkUpsert(body.tasks);
128
+ return json(res, result);
129
+ }
130
+
131
+ if (url === "/api/webhooks") {
132
+ const body = JSON.parse(await readBody(req));
133
+ if (!body.url) return jsonError(res, 400, "url is required");
134
+ const hook = insertWebhook(body.url);
135
+ return json(res, hook, 201);
136
+ }
65
137
 
66
- // Static files
67
- if (url.startsWith("/static/")) { serveStatic(res, url.slice("/static/".length)); return; }
138
+ if (url === "/api/webhooks/test") {
139
+ const result = fireTestWebhook();
140
+ return json(res, result);
141
+ }
142
+ }
68
143
 
69
- // Root index.html
70
- if (url === "/" || url === "/index.html") { serveStatic(res, "index.html"); return; }
144
+ // --- PATCH routes ---
145
+ if (method === "PATCH") {
146
+ const taskMatch = url.match(/^\/api\/tasks\/([a-zA-Z0-9_-]+)$/);
147
+ if (taskMatch) {
148
+ const body = JSON.parse(await readBody(req));
149
+ const task = updateTask(taskMatch[1], body);
150
+ if (!task) return jsonError(res, 404, "Task not found");
151
+ fireWebhooks("task.updated", task);
152
+ return json(res, task);
153
+ }
154
+
155
+ const webhookMatch = url.match(/^\/api\/webhooks\/(\d+)$/);
156
+ if (webhookMatch) {
157
+ const body = JSON.parse(await readBody(req));
158
+ const hook = toggleWebhook(Number(webhookMatch[1]), body.enabled);
159
+ if (!hook) return jsonError(res, 404, "Webhook not found");
160
+ return json(res, hook);
161
+ }
162
+ }
163
+
164
+ // --- DELETE routes ---
165
+ if (method === "DELETE") {
166
+ const taskMatch = url.match(/^\/api\/tasks\/([a-zA-Z0-9_-]+)$/);
167
+ if (taskMatch) {
168
+ const existing = queryOne("SELECT * FROM tasks WHERE id = ?", taskMatch[1]);
169
+ const ok = deleteTask(taskMatch[1]);
170
+ if (!ok) return jsonError(res, 404, "Task not found");
171
+ if (existing) fireWebhooks("task.deleted", existing);
172
+ return json(res, { ok: true });
173
+ }
174
+
175
+ const webhookMatch = url.match(/^\/api\/webhooks\/(\d+)$/);
176
+ if (webhookMatch) {
177
+ const ok = deleteWebhook(Number(webhookMatch[1]));
178
+ if (!ok) return jsonError(res, 404, "Webhook not found");
179
+ return json(res, { ok: true });
180
+ }
181
+ }
71
182
 
72
183
  res.writeHead(404, { "Content-Type": "application/json" });
73
184
  res.end(JSON.stringify({ error: "Not found" }));
@@ -115,15 +226,19 @@ function apiSystem(res: ServerResponse, config: MonitorConfig): void {
115
226
 
116
227
  // --- Helpers ---
117
228
 
118
- function json(res: ServerResponse, data: unknown): void {
229
+ function json(res: ServerResponse, data: unknown, status = 200): void {
119
230
  const body = JSON.stringify(data);
120
- res.writeHead(200, {
231
+ res.writeHead(status, {
121
232
  "Content-Type": "application/json; charset=utf-8",
122
233
  "Content-Length": Buffer.byteLength(body),
123
234
  });
124
235
  res.end(body);
125
236
  }
126
237
 
238
+ function jsonError(res: ServerResponse, status: number, message: string): void {
239
+ json(res, { error: message }, status);
240
+ }
241
+
127
242
  async function serveStatic(res: ServerResponse, filePath: string): Promise<void> {
128
243
  // Prevent directory traversal
129
244
  if (filePath.includes("..")) {
package/src/tools.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { insertTask, updateTask, query, bulkUpsert } from "./db.js";
2
+ import type { TaskInput } from "./db.js";
3
+
4
+ interface ToolDef {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: Record<string, unknown>;
8
+ handler: (input: Record<string, unknown>) => unknown;
9
+ }
10
+
11
+ interface PluginApi {
12
+ registerTool(tool: ToolDef): void;
13
+ }
14
+
15
+ export function registerTools(api: PluginApi): void {
16
+ api.registerTool({
17
+ name: "clawmon_create_task",
18
+ description: "Create a new task in the clawmon monitor. Returns the created task.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ name: { type: "string", description: "Task name (required)" },
23
+ kind: { type: "string", description: "Task kind/category" },
24
+ status: { type: "string", description: "Initial status (default: pending)" },
25
+ pid: { type: "number", description: "Process ID if applicable" },
26
+ },
27
+ required: ["name"],
28
+ },
29
+ handler(input) {
30
+ try {
31
+ const task = insertTask(input as TaskInput);
32
+ return { ok: true, task };
33
+ } catch (err: unknown) {
34
+ return { ok: false, error: (err as Error).message };
35
+ }
36
+ },
37
+ });
38
+
39
+ api.registerTool({
40
+ name: "clawmon_update_task",
41
+ description: "Update an existing task in the clawmon monitor. Returns the updated task.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ id: { type: "string", description: "Task ID (required)" },
46
+ status: { type: "string", description: "New status" },
47
+ exit_code: { type: "number", description: "Exit code" },
48
+ error: { type: "string", description: "Error message" },
49
+ name: { type: "string", description: "Updated name" },
50
+ kind: { type: "string", description: "Updated kind" },
51
+ },
52
+ required: ["id"],
53
+ },
54
+ handler(input) {
55
+ try {
56
+ const { id, ...updates } = input as { id: string; [key: string]: unknown };
57
+ const task = updateTask(id, updates);
58
+ if (!task) return { ok: false, error: "Task not found" };
59
+ return { ok: true, task };
60
+ } catch (err: unknown) {
61
+ return { ok: false, error: (err as Error).message };
62
+ }
63
+ },
64
+ });
65
+
66
+ api.registerTool({
67
+ name: "clawmon_list_tasks",
68
+ description: "List tasks from the clawmon monitor, optionally filtered by status.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ status: { type: "string", description: "Filter by status (running, pending, done, failed)" },
73
+ limit: { type: "number", description: "Max results (default: 50)" },
74
+ },
75
+ },
76
+ handler(input) {
77
+ try {
78
+ const status = input.status as string | undefined;
79
+ const limit = (input.limit as number) || 50;
80
+ let tasks;
81
+ if (status) {
82
+ tasks = query("SELECT * FROM tasks WHERE status = ? ORDER BY updated_at DESC LIMIT ?", status, limit);
83
+ } else {
84
+ tasks = query("SELECT * FROM tasks ORDER BY updated_at DESC LIMIT ?", limit);
85
+ }
86
+ return { ok: true, tasks, count: tasks.length };
87
+ } catch (err: unknown) {
88
+ return { ok: false, error: (err as Error).message };
89
+ }
90
+ },
91
+ });
92
+
93
+ api.registerTool({
94
+ name: "clawmon_bulk_load",
95
+ description: "Bulk upsert tasks into the clawmon monitor. Creates new tasks or updates existing ones by ID.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ tasks: {
100
+ type: "array",
101
+ description: "Array of task objects with at least a name field",
102
+ items: {
103
+ type: "object",
104
+ properties: {
105
+ id: { type: "string" },
106
+ name: { type: "string" },
107
+ kind: { type: "string" },
108
+ status: { type: "string" },
109
+ pid: { type: "number" },
110
+ },
111
+ required: ["name"],
112
+ },
113
+ },
114
+ },
115
+ required: ["tasks"],
116
+ },
117
+ handler(input) {
118
+ try {
119
+ const result = bulkUpsert(input.tasks as TaskInput[]);
120
+ return { ok: true, ...result };
121
+ } catch (err: unknown) {
122
+ return { ok: false, error: (err as Error).message };
123
+ }
124
+ },
125
+ });
126
+
127
+ console.log("[clawmon] registered 4 agent tools");
128
+ }
@@ -0,0 +1,56 @@
1
+ import { getWebhooks } from "./db.js";
2
+
3
+ type WebhookEvent = "task.created" | "task.updated" | "task.deleted";
4
+
5
+ export function fireWebhooks(event: WebhookEvent, task: Record<string, unknown>): void {
6
+ const payload = JSON.stringify({
7
+ event,
8
+ task,
9
+ timestamp: Math.floor(Date.now() / 1000),
10
+ });
11
+
12
+ const hooks = getWebhooks().filter((h) => h.enabled === 1);
13
+ for (const hook of hooks) {
14
+ fetch(hook.url as string, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: payload,
18
+ }).catch((err) => {
19
+ console.error(`[clawmon] webhook error (${hook.url}):`, err.message);
20
+ });
21
+ }
22
+ }
23
+
24
+ export function fireTestWebhook(): { sent_to: number } {
25
+ const sampleTask = {
26
+ id: "test-000000000000",
27
+ name: "Test Task",
28
+ kind: "test",
29
+ status: "running",
30
+ pid: null,
31
+ started_at: Math.floor(Date.now() / 1000),
32
+ updated_at: Math.floor(Date.now() / 1000),
33
+ finished_at: null,
34
+ exit_code: null,
35
+ error: null,
36
+ };
37
+
38
+ const hooks = getWebhooks().filter((h) => h.enabled === 1);
39
+ const payload = JSON.stringify({
40
+ event: "task.updated" as WebhookEvent,
41
+ task: sampleTask,
42
+ timestamp: Math.floor(Date.now() / 1000),
43
+ });
44
+
45
+ for (const hook of hooks) {
46
+ fetch(hook.url as string, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: payload,
50
+ }).catch((err) => {
51
+ console.error(`[clawmon] test webhook error (${hook.url}):`, err.message);
52
+ });
53
+ }
54
+
55
+ return { sent_to: hooks.length };
56
+ }
package/ui/app.js CHANGED
@@ -17,6 +17,9 @@
17
17
  });
18
18
 
19
19
  function fetchAndRender() {
20
+ // Static views (no fetch needed)
21
+ if (currentView === "docs") { renderDocs(); return; }
22
+
20
23
  var url = endpoints[currentView];
21
24
  if (!url) return;
22
25
  fetch(url)
@@ -33,7 +36,8 @@
33
36
  running: "/api/tasks/running",
34
37
  recent: "/api/tasks/recent",
35
38
  failed: "/api/tasks/failed",
36
- system: "/api/system"
39
+ system: "/api/system",
40
+ webhooks: "/api/webhooks"
37
41
  };
38
42
 
39
43
  var renderers = {
@@ -41,7 +45,8 @@
41
45
  running: renderRunning,
42
46
  recent: renderRecent,
43
47
  failed: renderFailed,
44
- system: renderSystem
48
+ system: renderSystem,
49
+ webhooks: renderWebhooks
45
50
  };
46
51
 
47
52
  function esc(s) {
@@ -133,6 +138,209 @@
133
138
  "</ul>";
134
139
  }
135
140
 
141
+ // --- Webhooks View ---
142
+
143
+ function renderWebhooks(hooks) {
144
+ var html = "<h2>Webhooks</h2>";
145
+
146
+ // Add form
147
+ html += '<div class="webhook-form">' +
148
+ '<input type="text" id="webhook-url" class="input" placeholder="https://example.com/webhook" />' +
149
+ '<button class="btn" onclick="window.__clawmon.addWebhook()">Add Webhook</button>' +
150
+ '<button class="btn btn-secondary" onclick="window.__clawmon.testWebhooks()">Send Test</button>' +
151
+ '<span id="webhook-feedback" class="feedback"></span>' +
152
+ '</div>';
153
+
154
+ // List
155
+ if (hooks.length) {
156
+ html += "<table><thead><tr>" +
157
+ "<th>ID</th><th>URL</th><th>Enabled</th><th>Actions</th>" +
158
+ "</tr></thead><tbody>";
159
+ hooks.forEach(function (h) {
160
+ html += "<tr>" +
161
+ "<td>" + esc(h.id) + "</td>" +
162
+ "<td>" + esc(h.url) + "</td>" +
163
+ "<td>" +
164
+ '<label class="toggle">' +
165
+ '<input type="checkbox" ' + (h.enabled ? "checked" : "") +
166
+ ' onchange="window.__clawmon.toggleWebhook(' + h.id + ', this.checked)" />' +
167
+ '<span class="toggle-slider"></span>' +
168
+ '</label>' +
169
+ "</td>" +
170
+ "<td>" +
171
+ '<button class="btn btn-small btn-danger" onclick="window.__clawmon.deleteWebhook(' + h.id + ')">Delete</button>' +
172
+ "</td>" +
173
+ "</tr>";
174
+ });
175
+ html += "</tbody></table>";
176
+ } else {
177
+ html += '<div class="empty">No webhooks configured.</div>';
178
+ }
179
+
180
+ // Payload example
181
+ html += '<div class="docs-section">' +
182
+ "<h3>Webhook Payload</h3>" +
183
+ '<pre class="code-block">' + esc(JSON.stringify({
184
+ event: "task.created | task.updated | task.deleted",
185
+ task: { id: "abc123", name: "My Task", kind: "build", status: "running" },
186
+ timestamp: 1234567890
187
+ }, null, 2)) + "</pre>" +
188
+ "</div>";
189
+
190
+ document.getElementById("content").innerHTML = html;
191
+ }
192
+
193
+ // Expose webhook actions globally
194
+ window.__clawmon = {
195
+ addWebhook: function () {
196
+ var url = document.getElementById("webhook-url").value.trim();
197
+ if (!url) return;
198
+ fetch("/api/webhooks", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ url: url })
202
+ })
203
+ .then(function (r) { return r.json(); })
204
+ .then(function () {
205
+ showFeedback("Webhook added", false);
206
+ fetchAndRender();
207
+ })
208
+ .catch(function () { showFeedback("Failed to add webhook", true); });
209
+ },
210
+
211
+ deleteWebhook: function (id) {
212
+ fetch("/api/webhooks/" + id, { method: "DELETE" })
213
+ .then(function () { fetchAndRender(); })
214
+ .catch(function () { showFeedback("Failed to delete", true); });
215
+ },
216
+
217
+ toggleWebhook: function (id, enabled) {
218
+ fetch("/api/webhooks/" + id, {
219
+ method: "PATCH",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify({ enabled: enabled })
222
+ })
223
+ .then(function () { fetchAndRender(); })
224
+ .catch(function () { showFeedback("Failed to update", true); });
225
+ },
226
+
227
+ testWebhooks: function () {
228
+ fetch("/api/webhooks/test", { method: "POST" })
229
+ .then(function (r) { return r.json(); })
230
+ .then(function (data) {
231
+ showFeedback("Test sent to " + data.sent_to + " webhook(s)", false);
232
+ })
233
+ .catch(function () { showFeedback("Failed to send test", true); });
234
+ }
235
+ };
236
+
237
+ function showFeedback(msg, isError) {
238
+ var el = document.getElementById("webhook-feedback");
239
+ if (!el) return;
240
+ el.textContent = msg;
241
+ el.className = "feedback " + (isError ? "feedback-error" : "feedback-ok");
242
+ setTimeout(function () { if (el) el.textContent = ""; }, 3000);
243
+ }
244
+
245
+ // --- Docs View ---
246
+
247
+ function renderDocs() {
248
+ document.getElementById("content").innerHTML =
249
+ "<h2>How to Use</h2>" +
250
+
251
+ '<div class="docs-section">' +
252
+ "<h3>CLI Commands</h3>" +
253
+ '<pre class="code-block">' +
254
+ "clawmon status # Overview counts\n" +
255
+ "clawmon tasks [--status &lt;s&gt;] # List tasks\n" +
256
+ "clawmon add &lt;name&gt; [--kind &lt;k&gt;] # Create task\n" +
257
+ "clawmon update &lt;id&gt; --status &lt;s&gt; # Update task\n" +
258
+ "clawmon delete &lt;id&gt; # Delete task\n" +
259
+ "clawmon load &lt;file.json&gt; # Bulk load from JSON\n" +
260
+ "clawmon open # Open web UI in browser\n" +
261
+ "clawmon help # Show help\n\n" +
262
+ "# Environment: CLAWMON_URL (default http://127.0.0.1:7070)" +
263
+ "</pre>" +
264
+ "</div>" +
265
+
266
+ '<div class="docs-section">' +
267
+ "<h3>REST API</h3>" +
268
+ "<table><thead><tr><th>Method</th><th>Endpoint</th><th>Description</th></tr></thead><tbody>" +
269
+ "<tr><td>GET</td><td>/api/overview</td><td>Task counts</td></tr>" +
270
+ "<tr><td>GET</td><td>/api/tasks</td><td>All tasks</td></tr>" +
271
+ "<tr><td>GET</td><td>/api/tasks/running</td><td>Running tasks</td></tr>" +
272
+ "<tr><td>GET</td><td>/api/tasks/recent</td><td>Last 50 tasks</td></tr>" +
273
+ "<tr><td>GET</td><td>/api/tasks/failed</td><td>Failed tasks</td></tr>" +
274
+ "<tr><td>POST</td><td>/api/tasks</td><td>Create task</td></tr>" +
275
+ "<tr><td>POST</td><td>/api/tasks/bulk</td><td>Bulk upsert</td></tr>" +
276
+ "<tr><td>PATCH</td><td>/api/tasks/:id</td><td>Update task</td></tr>" +
277
+ "<tr><td>DELETE</td><td>/api/tasks/:id</td><td>Delete task</td></tr>" +
278
+ "<tr><td>GET</td><td>/api/webhooks</td><td>List webhooks</td></tr>" +
279
+ "<tr><td>POST</td><td>/api/webhooks</td><td>Add webhook</td></tr>" +
280
+ "<tr><td>PATCH</td><td>/api/webhooks/:id</td><td>Toggle webhook</td></tr>" +
281
+ "<tr><td>DELETE</td><td>/api/webhooks/:id</td><td>Remove webhook</td></tr>" +
282
+ "<tr><td>POST</td><td>/api/webhooks/test</td><td>Fire test payload</td></tr>" +
283
+ "<tr><td>GET</td><td>/api/system</td><td>Version &amp; uptime</td></tr>" +
284
+ "<tr><td>GET</td><td>/health</td><td>Health check</td></tr>" +
285
+ "</tbody></table>" +
286
+ "</div>" +
287
+
288
+ '<div class="docs-section">' +
289
+ "<h3>curl Examples</h3>" +
290
+ '<pre class="code-block">' +
291
+ "# Create a task\n" +
292
+ "curl -X POST http://127.0.0.1:7070/api/tasks \\\n" +
293
+ ' -H "Content-Type: application/json" \\\n' +
294
+ " -d '{\"name\": \"build-app\", \"kind\": \"build\"}'\n\n" +
295
+ "# Update task status\n" +
296
+ "curl -X PATCH http://127.0.0.1:7070/api/tasks/TASK_ID \\\n" +
297
+ ' -H "Content-Type: application/json" \\\n' +
298
+ " -d '{\"status\": \"done\", \"exit_code\": 0}'\n\n" +
299
+ "# Delete a task\n" +
300
+ "curl -X DELETE http://127.0.0.1:7070/api/tasks/TASK_ID\n\n" +
301
+ "# Bulk load\n" +
302
+ "curl -X POST http://127.0.0.1:7070/api/tasks/bulk \\\n" +
303
+ ' -H "Content-Type: application/json" \\\n' +
304
+ " -d '{\"tasks\": [{\"name\": \"t1\"}, {\"name\": \"t2\"}]}'" +
305
+ "</pre>" +
306
+ "</div>" +
307
+
308
+ '<div class="docs-section">' +
309
+ "<h3>Agent Tools</h3>" +
310
+ "<table><thead><tr><th>Tool</th><th>Description</th></tr></thead><tbody>" +
311
+ "<tr><td>clawmon_create_task</td><td>Create a task. Input: {name, kind?, status?, pid?}</td></tr>" +
312
+ "<tr><td>clawmon_update_task</td><td>Update a task. Input: {id, status?, exit_code?, error?}</td></tr>" +
313
+ "<tr><td>clawmon_list_tasks</td><td>List tasks. Input: {status?, limit?}</td></tr>" +
314
+ "<tr><td>clawmon_bulk_load</td><td>Bulk upsert. Input: {tasks: [...]}</td></tr>" +
315
+ "</tbody></table>" +
316
+ "</div>" +
317
+
318
+ '<div class="docs-section">' +
319
+ "<h3>Task JSON Schema</h3>" +
320
+ '<pre class="code-block">' + esc(JSON.stringify({
321
+ id: "string (auto-generated 12-char hex)",
322
+ name: "string (required)",
323
+ kind: "string | null",
324
+ status: "pending | running | done | failed | unknown",
325
+ pid: "number | null",
326
+ started_at: "unix epoch seconds | null",
327
+ updated_at: "unix epoch seconds | null",
328
+ finished_at: "unix epoch seconds | null",
329
+ exit_code: "number | null",
330
+ error: "string | null"
331
+ }, null, 2)) + "</pre>" +
332
+ "</div>" +
333
+
334
+ '<div class="docs-section">' +
335
+ "<h3>Webhook Payload</h3>" +
336
+ '<pre class="code-block">' + esc(JSON.stringify({
337
+ event: "task.created | task.updated | task.deleted",
338
+ task: { "...": "full task object" },
339
+ timestamp: 1234567890
340
+ }, null, 2)) + "</pre>" +
341
+ "</div>";
342
+ }
343
+
136
344
  // Initial fetch + polling
137
345
  fetchAndRender();
138
346
  pollTimer = setInterval(fetchAndRender, 2000);
package/ui/index.html CHANGED
@@ -15,6 +15,8 @@
15
15
  <li class="menu-item" data-view="running">Running Tasks</li>
16
16
  <li class="menu-item" data-view="recent">Recent Tasks</li>
17
17
  <li class="menu-item" data-view="failed">Failed Tasks</li>
18
+ <li class="menu-item" data-view="webhooks">Webhooks</li>
19
+ <li class="menu-item" data-view="docs">How to Use</li>
18
20
  <li class="menu-item" data-view="system">System Info</li>
19
21
  </ul>
20
22
  </nav>
package/ui/styles.css CHANGED
@@ -197,3 +197,151 @@ tr:last-child td {
197
197
  color: #666;
198
198
  text-align: center;
199
199
  }
200
+
201
+ /* Docs */
202
+ .docs-section {
203
+ margin-top: 24px;
204
+ }
205
+
206
+ .docs-section h3 {
207
+ font-size: 15px;
208
+ color: #e94560;
209
+ margin-bottom: 8px;
210
+ }
211
+
212
+ .code-block {
213
+ background: #0d1117;
214
+ border: 1px solid #0f3460;
215
+ border-radius: 6px;
216
+ padding: 16px;
217
+ font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
218
+ font-size: 13px;
219
+ color: #c9d1d9;
220
+ overflow-x: auto;
221
+ white-space: pre;
222
+ line-height: 1.5;
223
+ }
224
+
225
+ /* Webhook form */
226
+ .webhook-form {
227
+ display: flex;
228
+ gap: 8px;
229
+ align-items: center;
230
+ margin-bottom: 16px;
231
+ flex-wrap: wrap;
232
+ }
233
+
234
+ .input {
235
+ background: #0d1117;
236
+ border: 1px solid #0f3460;
237
+ border-radius: 4px;
238
+ color: #e0e0e0;
239
+ padding: 8px 12px;
240
+ font-size: 14px;
241
+ flex: 1;
242
+ min-width: 250px;
243
+ }
244
+
245
+ .input:focus {
246
+ outline: none;
247
+ border-color: #e94560;
248
+ }
249
+
250
+ .btn {
251
+ background: #e94560;
252
+ color: #fff;
253
+ border: none;
254
+ border-radius: 4px;
255
+ padding: 8px 16px;
256
+ font-size: 13px;
257
+ font-weight: 600;
258
+ cursor: pointer;
259
+ transition: background 0.15s;
260
+ white-space: nowrap;
261
+ }
262
+
263
+ .btn:hover {
264
+ background: #d63851;
265
+ }
266
+
267
+ .btn-secondary {
268
+ background: #0f3460;
269
+ }
270
+
271
+ .btn-secondary:hover {
272
+ background: #1a4a7a;
273
+ }
274
+
275
+ .btn-small {
276
+ padding: 4px 10px;
277
+ font-size: 12px;
278
+ }
279
+
280
+ .btn-danger {
281
+ background: #4d1e1e;
282
+ color: #fc5c65;
283
+ }
284
+
285
+ .btn-danger:hover {
286
+ background: #6b2a2a;
287
+ }
288
+
289
+ /* Toggle switch */
290
+ .toggle {
291
+ position: relative;
292
+ display: inline-block;
293
+ width: 36px;
294
+ height: 20px;
295
+ }
296
+
297
+ .toggle input {
298
+ opacity: 0;
299
+ width: 0;
300
+ height: 0;
301
+ }
302
+
303
+ .toggle-slider {
304
+ position: absolute;
305
+ cursor: pointer;
306
+ top: 0;
307
+ left: 0;
308
+ right: 0;
309
+ bottom: 0;
310
+ background: #2d2d2d;
311
+ border-radius: 20px;
312
+ transition: background 0.2s;
313
+ }
314
+
315
+ .toggle-slider::before {
316
+ content: "";
317
+ position: absolute;
318
+ height: 14px;
319
+ width: 14px;
320
+ left: 3px;
321
+ bottom: 3px;
322
+ background: #e0e0e0;
323
+ border-radius: 50%;
324
+ transition: transform 0.2s;
325
+ }
326
+
327
+ .toggle input:checked + .toggle-slider {
328
+ background: #26de81;
329
+ }
330
+
331
+ .toggle input:checked + .toggle-slider::before {
332
+ transform: translateX(16px);
333
+ }
334
+
335
+ /* Feedback */
336
+ .feedback {
337
+ font-size: 13px;
338
+ margin-left: 4px;
339
+ }
340
+
341
+ .feedback-ok {
342
+ color: #26de81;
343
+ }
344
+
345
+ .feedback-error {
346
+ color: #fc5c65;
347
+ }