clawmon 0.1.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/README.md +48 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +21 -0
- package/src/config.ts +56 -0
- package/src/db.ts +46 -0
- package/src/index.ts +45 -0
- package/src/server.ts +149 -0
- package/src/watcher.ts +63 -0
- package/ui/app.js +139 -0
- package/ui/index.html +27 -0
- package/ui/styles.css +199 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# OpenClaw Monitor
|
|
2
|
+
|
|
3
|
+
Local task monitor plugin for OpenClaw. Tracks running, stale, and failed tasks via SQLite and serves a web UI on `127.0.0.1:7070`.
|
|
4
|
+
|
|
5
|
+
## Install as OpenClaw Plugin
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install ./openclaw-monitor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Development
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Start (installs deps, seeds DB if missing, runs server)
|
|
15
|
+
./dev.sh
|
|
16
|
+
|
|
17
|
+
# Force reseed test data
|
|
18
|
+
./dev.sh --seed
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open [http://127.0.0.1:7070](http://127.0.0.1:7070)
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
| Env Var | Default | Description |
|
|
26
|
+
|---------|---------|-------------|
|
|
27
|
+
| `OPENCLAW_MONITOR_PORT` | `7070` | HTTP port |
|
|
28
|
+
| `OPENCLAW_MONITOR_DB_DIR` | `~/.openclaw/monitor` | Directory for SQLite database |
|
|
29
|
+
| `OPENCLAW_MONITOR_RETENTION_DAYS` | `7` | Days to keep finished tasks |
|
|
30
|
+
|
|
31
|
+
These can also be set in `openclaw.json` plugin config.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| Endpoint | Description |
|
|
36
|
+
|----------|-------------|
|
|
37
|
+
| `GET /health` | Health check |
|
|
38
|
+
| `GET /api/overview` | Task counts and last update |
|
|
39
|
+
| `GET /api/tasks` | All tasks |
|
|
40
|
+
| `GET /api/tasks/running` | Running tasks |
|
|
41
|
+
| `GET /api/tasks/recent` | Last 50 tasks |
|
|
42
|
+
| `GET /api/tasks/failed` | Failed tasks |
|
|
43
|
+
| `GET /api/system` | Version, uptime, DB path |
|
|
44
|
+
| `GET /` | Web UI |
|
|
45
|
+
|
|
46
|
+
## Docs
|
|
47
|
+
|
|
48
|
+
- [Features](docs/FEATURES.md)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clawmon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local task monitor with web UI — tracks running, stale, and failed tasks",
|
|
5
|
+
"entry": "src/index.ts",
|
|
6
|
+
"config": {
|
|
7
|
+
"port": 7070,
|
|
8
|
+
"db_dir": "~/.openclaw/monitor",
|
|
9
|
+
"retention_days": 7
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clawmon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local task monitor plugin for OpenClaw",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"files": ["src", "ui", "openclaw.plugin.json"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "tsx scripts/dev-standalone.ts",
|
|
10
|
+
"seed": "node scripts/seed.js",
|
|
11
|
+
"dev:seed": "node scripts/seed.js && tsx scripts/dev-standalone.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"better-sqlite3": "^11.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"tsx": "^4.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface MonitorConfig {
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
dbDir: string;
|
|
8
|
+
dbPath: string;
|
|
9
|
+
pollInterval: number;
|
|
10
|
+
staleTimeout: number;
|
|
11
|
+
retentionDays: number;
|
|
12
|
+
cleanupInterval: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveConfig(pluginConfig?: Record<string, unknown>): MonitorConfig {
|
|
16
|
+
const dbDir =
|
|
17
|
+
env("OPENCLAW_MONITOR_DB_DIR") ??
|
|
18
|
+
str(pluginConfig?.db_dir) ??
|
|
19
|
+
join(homedir(), ".openclaw", "monitor");
|
|
20
|
+
|
|
21
|
+
const port =
|
|
22
|
+
int(env("OPENCLAW_MONITOR_PORT")) ??
|
|
23
|
+
int(pluginConfig?.port) ??
|
|
24
|
+
7070;
|
|
25
|
+
|
|
26
|
+
const retentionDays =
|
|
27
|
+
int(env("OPENCLAW_MONITOR_RETENTION_DAYS")) ??
|
|
28
|
+
int(pluginConfig?.retention_days) ??
|
|
29
|
+
7;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
host: "127.0.0.1",
|
|
33
|
+
port,
|
|
34
|
+
dbDir,
|
|
35
|
+
dbPath: join(dbDir, "status.db"),
|
|
36
|
+
pollInterval: 2_000,
|
|
37
|
+
staleTimeout: 300,
|
|
38
|
+
retentionDays,
|
|
39
|
+
cleanupInterval: 3_600_000,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function env(key: string): string | undefined {
|
|
44
|
+
const v = process.env[key];
|
|
45
|
+
return v !== undefined && v !== "" ? v : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function str(v: unknown): string | undefined {
|
|
49
|
+
return typeof v === "string" ? v : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function int(v: unknown): number | undefined {
|
|
53
|
+
if (v === undefined || v === null) return undefined;
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return Number.isFinite(n) ? n : undefined;
|
|
56
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import type { MonitorConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
let db: Database.Database | null = null;
|
|
6
|
+
|
|
7
|
+
export function initDb(config: MonitorConfig): Database.Database {
|
|
8
|
+
mkdirSync(config.dbDir, { recursive: true });
|
|
9
|
+
db = new Database(config.dbPath);
|
|
10
|
+
db.pragma("journal_mode = WAL");
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
kind TEXT,
|
|
16
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
17
|
+
pid INTEGER,
|
|
18
|
+
started_at INTEGER,
|
|
19
|
+
updated_at INTEGER,
|
|
20
|
+
finished_at INTEGER,
|
|
21
|
+
exit_code INTEGER,
|
|
22
|
+
error TEXT
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getDb(): Database.Database {
|
|
29
|
+
if (!db) throw new Error("Database not initialized — call initDb() first");
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function closeDb(): void {
|
|
34
|
+
if (db) {
|
|
35
|
+
db.close();
|
|
36
|
+
db = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function query(sql: string, ...params: unknown[]): Record<string, unknown>[] {
|
|
41
|
+
return getDb().prepare(sql).all(...params) as Record<string, unknown>[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function queryOne(sql: string, ...params: unknown[]): Record<string, unknown> | undefined {
|
|
45
|
+
return (getDb().prepare(sql).get(...params) as Record<string, unknown>) ?? undefined;
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolveConfig } from "./config.js";
|
|
2
|
+
import { initDb, closeDb } from "./db.js";
|
|
3
|
+
import { startWatcher } from "./watcher.js";
|
|
4
|
+
import { createHttpServer, listenServer } from "./server.js";
|
|
5
|
+
import type { Server } from "node:http";
|
|
6
|
+
|
|
7
|
+
interface PluginApi {
|
|
8
|
+
registerService(service: {
|
|
9
|
+
id: string;
|
|
10
|
+
start: () => Promise<void>;
|
|
11
|
+
stop: () => Promise<void>;
|
|
12
|
+
}): void;
|
|
13
|
+
config?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function register(api: PluginApi): void {
|
|
17
|
+
let stopWatcher: (() => void) | null = null;
|
|
18
|
+
let server: Server | null = null;
|
|
19
|
+
|
|
20
|
+
api.registerService({
|
|
21
|
+
id: "clawmon",
|
|
22
|
+
|
|
23
|
+
async start() {
|
|
24
|
+
const config = resolveConfig(api.config);
|
|
25
|
+
initDb(config);
|
|
26
|
+
stopWatcher = startWatcher(config);
|
|
27
|
+
server = createHttpServer(config);
|
|
28
|
+
await listenServer(server, config);
|
|
29
|
+
console.log(`[clawmon] listening on http://${config.host}:${config.port}`);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async stop() {
|
|
33
|
+
if (stopWatcher) {
|
|
34
|
+
stopWatcher();
|
|
35
|
+
stopWatcher = null;
|
|
36
|
+
}
|
|
37
|
+
if (server) {
|
|
38
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
39
|
+
server = null;
|
|
40
|
+
}
|
|
41
|
+
closeDb();
|
|
42
|
+
console.log("[clawmon] stopped");
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join, extname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { query, queryOne } from "./db.js";
|
|
6
|
+
import type { MonitorConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
const VERSION = "0.1.0";
|
|
9
|
+
|
|
10
|
+
const MIME: Record<string, string> = {
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".css": "text/css; charset=utf-8",
|
|
13
|
+
".js": "application/javascript; charset=utf-8",
|
|
14
|
+
".json": "application/json; charset=utf-8",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
".ico": "image/x-icon",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const __dirname = join(fileURLToPath(import.meta.url), "..");
|
|
21
|
+
const UI_DIR = join(__dirname, "..", "ui");
|
|
22
|
+
|
|
23
|
+
let startTime = 0;
|
|
24
|
+
|
|
25
|
+
export function createHttpServer(config: MonitorConfig): Server {
|
|
26
|
+
startTime = Date.now();
|
|
27
|
+
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
route(req, res, config);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
33
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listenServer(server: Server, config: MonitorConfig): Promise<void> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
server.listen(config.port, config.host, () => resolve());
|
|
43
|
+
server.once("error", reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function route(req: IncomingMessage, res: ServerResponse, config: MonitorConfig): void {
|
|
48
|
+
const url = req.url ?? "/";
|
|
49
|
+
const method = req.method ?? "GET";
|
|
50
|
+
|
|
51
|
+
if (method !== "GET") {
|
|
52
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
53
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
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);
|
|
65
|
+
|
|
66
|
+
// Static files
|
|
67
|
+
if (url.startsWith("/static/")) { serveStatic(res, url.slice("/static/".length)); return; }
|
|
68
|
+
|
|
69
|
+
// Root → index.html
|
|
70
|
+
if (url === "/" || url === "/index.html") { serveStatic(res, "index.html"); return; }
|
|
71
|
+
|
|
72
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
73
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- API handlers ---
|
|
77
|
+
|
|
78
|
+
function apiOverview(res: ServerResponse): void {
|
|
79
|
+
const total = queryOne("SELECT COUNT(*) AS c FROM tasks") ?? { c: 0 };
|
|
80
|
+
const running = queryOne("SELECT COUNT(*) AS c FROM tasks WHERE status = 'running'") ?? { c: 0 };
|
|
81
|
+
const failed = queryOne("SELECT COUNT(*) AS c FROM tasks WHERE status = 'failed'") ?? { c: 0 };
|
|
82
|
+
const last = queryOne("SELECT MAX(updated_at) AS ts FROM tasks") ?? { ts: null };
|
|
83
|
+
json(res, {
|
|
84
|
+
total: total.c,
|
|
85
|
+
running: running.c,
|
|
86
|
+
failed: failed.c,
|
|
87
|
+
last_updated: last.ts ?? null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function apiAllTasks(res: ServerResponse): void {
|
|
92
|
+
json(res, query("SELECT * FROM tasks ORDER BY updated_at DESC"));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function apiRunningTasks(res: ServerResponse): void {
|
|
96
|
+
json(res, query("SELECT * FROM tasks WHERE status = 'running' ORDER BY started_at DESC"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function apiRecentTasks(res: ServerResponse): void {
|
|
100
|
+
json(res, query("SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 50"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apiFailedTasks(res: ServerResponse): void {
|
|
104
|
+
json(res, query("SELECT * FROM tasks WHERE status = 'failed' ORDER BY updated_at DESC"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function apiSystem(res: ServerResponse, config: MonitorConfig): void {
|
|
108
|
+
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
109
|
+
json(res, {
|
|
110
|
+
version: VERSION,
|
|
111
|
+
uptime_seconds: uptimeSeconds,
|
|
112
|
+
db_path: config.dbPath,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Helpers ---
|
|
117
|
+
|
|
118
|
+
function json(res: ServerResponse, data: unknown): void {
|
|
119
|
+
const body = JSON.stringify(data);
|
|
120
|
+
res.writeHead(200, {
|
|
121
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
122
|
+
"Content-Length": Buffer.byteLength(body),
|
|
123
|
+
});
|
|
124
|
+
res.end(body);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function serveStatic(res: ServerResponse, filePath: string): Promise<void> {
|
|
128
|
+
// Prevent directory traversal
|
|
129
|
+
if (filePath.includes("..")) {
|
|
130
|
+
res.writeHead(403);
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fullPath = join(UI_DIR, filePath);
|
|
136
|
+
try {
|
|
137
|
+
const data = await readFile(fullPath);
|
|
138
|
+
const ext = extname(fullPath);
|
|
139
|
+
const contentType = MIME[ext] ?? "application/octet-stream";
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
"Content-Type": contentType,
|
|
142
|
+
"Content-Length": data.length,
|
|
143
|
+
});
|
|
144
|
+
res.end(data);
|
|
145
|
+
} catch {
|
|
146
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
147
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getDb } from "./db.js";
|
|
2
|
+
import type { MonitorConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export function startWatcher(config: MonitorConfig): () => void {
|
|
5
|
+
let lastCleanup = 0;
|
|
6
|
+
|
|
7
|
+
const tick = () => {
|
|
8
|
+
try {
|
|
9
|
+
markStaleTasks(config);
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
if (now - lastCleanup >= config.cleanupInterval) {
|
|
12
|
+
pruneOldTasks(config);
|
|
13
|
+
lastCleanup = now;
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
// monitor should not crash on transient DB issues
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const id = setInterval(tick, config.pollInterval);
|
|
21
|
+
// Run immediately on start
|
|
22
|
+
tick();
|
|
23
|
+
|
|
24
|
+
return () => clearInterval(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function markStaleTasks(config: MonitorConfig): void {
|
|
28
|
+
const db = getDb();
|
|
29
|
+
const rows = db
|
|
30
|
+
.prepare("SELECT id, pid, updated_at FROM tasks WHERE status = 'running'")
|
|
31
|
+
.all() as { id: string; pid: number | null; updated_at: number | null }[];
|
|
32
|
+
|
|
33
|
+
const now = Math.floor(Date.now() / 1000);
|
|
34
|
+
const update = db.prepare("UPDATE tasks SET status = 'unknown', updated_at = ? WHERE id = ?");
|
|
35
|
+
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
let stale = false;
|
|
38
|
+
if (row.pid && !pidAlive(row.pid)) {
|
|
39
|
+
stale = true;
|
|
40
|
+
} else if (now - (row.updated_at ?? 0) > config.staleTimeout) {
|
|
41
|
+
stale = true;
|
|
42
|
+
}
|
|
43
|
+
if (stale) {
|
|
44
|
+
update.run(now, row.id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pruneOldTasks(config: MonitorConfig): void {
|
|
50
|
+
const cutoff = Math.floor(Date.now() / 1000) - config.retentionDays * 86400;
|
|
51
|
+
getDb()
|
|
52
|
+
.prepare("DELETE FROM tasks WHERE status IN ('done', 'failed', 'unknown') AND updated_at < ?")
|
|
53
|
+
.run(cutoff);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pidAlive(pid: number): boolean {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/ui/app.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var currentView = "overview";
|
|
5
|
+
var pollTimer = null;
|
|
6
|
+
|
|
7
|
+
// Menu switching
|
|
8
|
+
document.querySelector(".menu").addEventListener("click", function (e) {
|
|
9
|
+
var item = e.target.closest(".menu-item");
|
|
10
|
+
if (!item) return;
|
|
11
|
+
document.querySelectorAll(".menu-item").forEach(function (el) {
|
|
12
|
+
el.classList.remove("active");
|
|
13
|
+
});
|
|
14
|
+
item.classList.add("active");
|
|
15
|
+
currentView = item.getAttribute("data-view");
|
|
16
|
+
fetchAndRender();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function fetchAndRender() {
|
|
20
|
+
var url = endpoints[currentView];
|
|
21
|
+
if (!url) return;
|
|
22
|
+
fetch(url)
|
|
23
|
+
.then(function (r) { return r.json(); })
|
|
24
|
+
.then(function (data) { renderers[currentView](data); })
|
|
25
|
+
.catch(function () {
|
|
26
|
+
document.getElementById("content").innerHTML =
|
|
27
|
+
'<div class="empty">Failed to load data.</div>';
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var endpoints = {
|
|
32
|
+
overview: "/api/overview",
|
|
33
|
+
running: "/api/tasks/running",
|
|
34
|
+
recent: "/api/tasks/recent",
|
|
35
|
+
failed: "/api/tasks/failed",
|
|
36
|
+
system: "/api/system"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
var renderers = {
|
|
40
|
+
overview: renderOverview,
|
|
41
|
+
running: renderRunning,
|
|
42
|
+
recent: renderRecent,
|
|
43
|
+
failed: renderFailed,
|
|
44
|
+
system: renderSystem
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function esc(s) {
|
|
48
|
+
if (s == null) return "";
|
|
49
|
+
var d = document.createElement("div");
|
|
50
|
+
d.textContent = String(s);
|
|
51
|
+
return d.innerHTML;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtTime(epoch) {
|
|
55
|
+
if (!epoch) return "—";
|
|
56
|
+
var d = new Date(epoch * 1000);
|
|
57
|
+
return d.toLocaleString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function statusBadge(status) {
|
|
61
|
+
var cls = "status status-" + esc(status);
|
|
62
|
+
return '<span class="' + cls + '">' + esc(status) + "</span>";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderOverview(data) {
|
|
66
|
+
document.getElementById("content").innerHTML =
|
|
67
|
+
"<h2>Overview</h2>" +
|
|
68
|
+
'<div class="overview-grid">' +
|
|
69
|
+
'<div class="card"><div class="label">Total Tasks</div><div class="value">' + esc(data.total) + "</div></div>" +
|
|
70
|
+
'<div class="card"><div class="label">Running</div><div class="value">' + esc(data.running) + "</div></div>" +
|
|
71
|
+
'<div class="card"><div class="label">Failed</div><div class="value">' + esc(data.failed) + "</div></div>" +
|
|
72
|
+
'<div class="card"><div class="label">Last Update</div><div class="value" style="font-size:14px">' + fmtTime(data.last_updated) + "</div></div>" +
|
|
73
|
+
"</div>";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function taskTable(tasks, columns) {
|
|
77
|
+
if (!tasks.length) return '<div class="empty">No tasks found.</div>';
|
|
78
|
+
var html = "<table><thead><tr>";
|
|
79
|
+
columns.forEach(function (c) { html += "<th>" + esc(c.label) + "</th>"; });
|
|
80
|
+
html += "</tr></thead><tbody>";
|
|
81
|
+
tasks.forEach(function (t) {
|
|
82
|
+
html += "<tr>";
|
|
83
|
+
columns.forEach(function (c) { html += "<td>" + c.render(t) + "</td>"; });
|
|
84
|
+
html += "</tr>";
|
|
85
|
+
});
|
|
86
|
+
html += "</tbody></table>";
|
|
87
|
+
return html;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var baseCols = [
|
|
91
|
+
{ label: "Name", render: function (t) { return esc(t.name); } },
|
|
92
|
+
{ label: "Kind", render: function (t) { return esc(t.kind); } },
|
|
93
|
+
{ label: "Status", render: function (t) { return statusBadge(t.status); } },
|
|
94
|
+
{ label: "Started", render: function (t) { return fmtTime(t.started_at); } },
|
|
95
|
+
{ label: "Updated", render: function (t) { return fmtTime(t.updated_at); } }
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
function renderRunning(tasks) {
|
|
99
|
+
var cols = baseCols.concat([
|
|
100
|
+
{ label: "PID", render: function (t) { return esc(t.pid); } }
|
|
101
|
+
]);
|
|
102
|
+
document.getElementById("content").innerHTML =
|
|
103
|
+
"<h2>Running Tasks</h2>" + taskTable(tasks, cols);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderRecent(tasks) {
|
|
107
|
+
document.getElementById("content").innerHTML =
|
|
108
|
+
"<h2>Recent Tasks</h2>" + taskTable(tasks, baseCols);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderFailed(tasks) {
|
|
112
|
+
var cols = baseCols.concat([
|
|
113
|
+
{ label: "Exit Code", render: function (t) { return esc(t.exit_code); } },
|
|
114
|
+
{ label: "Error", render: function (t) { return esc(t.error); } }
|
|
115
|
+
]);
|
|
116
|
+
document.getElementById("content").innerHTML =
|
|
117
|
+
"<h2>Failed Tasks</h2>" + taskTable(tasks, cols);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderSystem(data) {
|
|
121
|
+
var uptime = data.uptime_seconds;
|
|
122
|
+
var h = Math.floor(uptime / 3600);
|
|
123
|
+
var m = Math.floor((uptime % 3600) / 60);
|
|
124
|
+
var s = uptime % 60;
|
|
125
|
+
var uptimeStr = h + "h " + m + "m " + s + "s";
|
|
126
|
+
|
|
127
|
+
document.getElementById("content").innerHTML =
|
|
128
|
+
"<h2>System Info</h2>" +
|
|
129
|
+
'<ul class="info-list">' +
|
|
130
|
+
'<li><span class="info-key">Version</span><span class="info-val">' + esc(data.version) + "</span></li>" +
|
|
131
|
+
'<li><span class="info-key">Uptime</span><span class="info-val">' + esc(uptimeStr) + "</span></li>" +
|
|
132
|
+
'<li><span class="info-key">Database Path</span><span class="info-val">' + esc(data.db_path) + "</span></li>" +
|
|
133
|
+
"</ul>";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Initial fetch + polling
|
|
137
|
+
fetchAndRender();
|
|
138
|
+
pollTimer = setInterval(fetchAndRender, 2000);
|
|
139
|
+
})();
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>🦞 OpenClaw Monitor</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="layout">
|
|
11
|
+
<nav class="sidebar">
|
|
12
|
+
<div class="sidebar-header">🦞 OpenClaw Monitor</div>
|
|
13
|
+
<ul class="menu">
|
|
14
|
+
<li class="menu-item active" data-view="overview">Overview</li>
|
|
15
|
+
<li class="menu-item" data-view="running">Running Tasks</li>
|
|
16
|
+
<li class="menu-item" data-view="recent">Recent Tasks</li>
|
|
17
|
+
<li class="menu-item" data-view="failed">Failed Tasks</li>
|
|
18
|
+
<li class="menu-item" data-view="system">System Info</li>
|
|
19
|
+
</ul>
|
|
20
|
+
</nav>
|
|
21
|
+
<main class="body" id="content">
|
|
22
|
+
<div class="loading">Loading…</div>
|
|
23
|
+
</main>
|
|
24
|
+
</div>
|
|
25
|
+
<script src="/static/app.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
package/ui/styles.css
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
9
|
+
font-size: 14px;
|
|
10
|
+
color: #e0e0e0;
|
|
11
|
+
background: #1a1a2e;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.layout {
|
|
15
|
+
display: flex;
|
|
16
|
+
height: 100vh;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Sidebar */
|
|
20
|
+
.sidebar {
|
|
21
|
+
width: 200px;
|
|
22
|
+
min-width: 200px;
|
|
23
|
+
background: #16213e;
|
|
24
|
+
border-right: 1px solid #0f3460;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.sidebar-header {
|
|
30
|
+
padding: 16px;
|
|
31
|
+
font-size: 14px;
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
color: #e94560;
|
|
34
|
+
border-bottom: 1px solid #0f3460;
|
|
35
|
+
letter-spacing: 0.5px;
|
|
36
|
+
white-space: nowrap;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.menu {
|
|
40
|
+
list-style: none;
|
|
41
|
+
padding: 8px 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.menu-item {
|
|
45
|
+
padding: 10px 16px;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
color: #a0a0b8;
|
|
48
|
+
transition: background 0.15s, color 0.15s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.menu-item:hover {
|
|
52
|
+
background: #1a1a3e;
|
|
53
|
+
color: #e0e0e0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.menu-item.active {
|
|
57
|
+
background: #0f3460;
|
|
58
|
+
color: #ffffff;
|
|
59
|
+
border-left: 3px solid #e94560;
|
|
60
|
+
padding-left: 13px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Main content */
|
|
64
|
+
.body {
|
|
65
|
+
flex: 1;
|
|
66
|
+
padding: 24px;
|
|
67
|
+
overflow-y: auto;
|
|
68
|
+
background: #1a1a2e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.body h2 {
|
|
72
|
+
margin-bottom: 16px;
|
|
73
|
+
font-size: 18px;
|
|
74
|
+
color: #ffffff;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.loading {
|
|
78
|
+
color: #888;
|
|
79
|
+
padding: 40px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Overview cards */
|
|
83
|
+
.overview-grid {
|
|
84
|
+
display: grid;
|
|
85
|
+
grid-template-columns: repeat(4, 1fr);
|
|
86
|
+
gap: 16px;
|
|
87
|
+
margin-bottom: 16px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.card {
|
|
91
|
+
background: #16213e;
|
|
92
|
+
border: 1px solid #0f3460;
|
|
93
|
+
border-radius: 6px;
|
|
94
|
+
padding: 20px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.card .label {
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
color: #888;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.5px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.card .value {
|
|
105
|
+
font-size: 28px;
|
|
106
|
+
font-weight: 700;
|
|
107
|
+
margin-top: 4px;
|
|
108
|
+
color: #ffffff;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Tables */
|
|
112
|
+
table {
|
|
113
|
+
width: 100%;
|
|
114
|
+
border-collapse: collapse;
|
|
115
|
+
background: #16213e;
|
|
116
|
+
border: 1px solid #0f3460;
|
|
117
|
+
border-radius: 6px;
|
|
118
|
+
overflow: hidden;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
th, td {
|
|
122
|
+
text-align: left;
|
|
123
|
+
padding: 10px 12px;
|
|
124
|
+
border-bottom: 1px solid #0f3460;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
th {
|
|
128
|
+
background: #0f3460;
|
|
129
|
+
font-size: 12px;
|
|
130
|
+
text-transform: uppercase;
|
|
131
|
+
letter-spacing: 0.5px;
|
|
132
|
+
color: #a0a0b8;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
tr:last-child td {
|
|
137
|
+
border-bottom: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Status badges */
|
|
141
|
+
.status {
|
|
142
|
+
display: inline-block;
|
|
143
|
+
padding: 2px 8px;
|
|
144
|
+
border-radius: 3px;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.status-running {
|
|
150
|
+
background: #0a3d62;
|
|
151
|
+
color: #45aaf2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.status-done {
|
|
155
|
+
background: #1e4d2b;
|
|
156
|
+
color: #26de81;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.status-failed {
|
|
160
|
+
background: #4d1e1e;
|
|
161
|
+
color: #fc5c65;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.status-pending {
|
|
165
|
+
background: #3d3d0a;
|
|
166
|
+
color: #fed330;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.status-unknown {
|
|
170
|
+
background: #2d2d2d;
|
|
171
|
+
color: #a0a0a0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* System info */
|
|
175
|
+
.info-list {
|
|
176
|
+
list-style: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.info-list li {
|
|
180
|
+
padding: 10px 0;
|
|
181
|
+
border-bottom: 1px solid #0f3460;
|
|
182
|
+
display: flex;
|
|
183
|
+
gap: 12px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.info-list .info-key {
|
|
187
|
+
color: #888;
|
|
188
|
+
min-width: 140px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.info-list .info-val {
|
|
192
|
+
color: #e0e0e0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.empty {
|
|
196
|
+
padding: 24px;
|
|
197
|
+
color: #666;
|
|
198
|
+
text-align: center;
|
|
199
|
+
}
|