prodboard 0.2.2 → 0.3.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/CHANGELOG.md +28 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +7 -6
- package/src/agents/types.ts +6 -4
- package/src/commands/install.ts +4 -3
- package/src/commands/schedules.ts +1 -0
- package/src/mcp.ts +59 -1
- package/src/queries/runs.ts +9 -2
- package/src/scheduler.ts +9 -2
- package/src/webui/components/board.tsx +19 -11
- package/src/webui/components/layout.tsx +24 -12
- package/src/webui/components/status-badge.tsx +14 -13
- package/src/webui/routes/auth.tsx +33 -9
- package/src/webui/routes/issues.tsx +188 -51
- package/src/webui/routes/runs.tsx +95 -51
- package/src/webui/routes/schedules.tsx +112 -61
- package/src/webui/static/style.ts +33 -45
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# prodboard
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#17](https://github.com/G4brym/prodboard/pull/17) [`20f3860`](https://github.com/G4brym/prodboard/commit/20f38608b1a5a9d986bcf1d04d4dfb579fa7ec18) Thanks [@G4brym](https://github.com/G4brym)! - Add `trigger_schedule` MCP tool to manually trigger a schedule run
|
|
8
|
+
|
|
9
|
+
Adds a new MCP tool that allows agents and users to trigger a schedule to run immediately without waiting for the cron interval. The run is started asynchronously and returns the run ID so callers can check status via `list_runs`. Disabled schedules are rejected, and the concurrent run limit is enforced.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#20](https://github.com/G4brym/prodboard/pull/20) [`53c5d4d`](https://github.com/G4brym/prodboard/commit/53c5d4d1ee7331e50f4b4cc5fb1aa8e37836967e) Thanks [@G4brym](https://github.com/G4brym)! - Fix schedules with identical cron patterns — all matching schedules now fire
|
|
14
|
+
|
|
15
|
+
Snapshot the running-run count once before the tick loop instead of re-querying
|
|
16
|
+
inside the loop. This prevents a run created for schedule A from counting against
|
|
17
|
+
schedule B's concurrency check when both share the same cron expression.
|
|
18
|
+
|
|
19
|
+
## 0.2.3
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- [#13](https://github.com/G4brym/prodboard/pull/13) [`5465723`](https://github.com/G4brym/prodboard/commit/54657238021a20fb0d5a4bb6e13a1b8eab026c31) Thanks [@G4brym](https://github.com/G4brym)! - Fix systemd service missing PATH environment variable
|
|
24
|
+
|
|
25
|
+
The generated systemd service file only set `HOME` but not `PATH`, causing the daemon to run with a minimal default PATH. This meant tools like `claude` and `gh` installed in user-local directories (e.g. `~/.local/bin`) were not found, resulting in scheduled runs failing with exit code 127.
|
|
26
|
+
|
|
27
|
+
The fix captures the current `PATH` at install time and includes it in the systemd service file.
|
|
28
|
+
|
|
29
|
+
- [`af1ff32`](https://github.com/G4brym/prodboard/commit/af1ff3291993e0720a4044220b84f8b048fef26e) Thanks [@G4brym](https://github.com/G4brym)! - Fix webui failing to load when prodboard is installed globally by adding `@jsxImportSource hono/jsx` pragma to all TSX files.
|
|
30
|
+
|
|
3
31
|
## 0.2.2
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -197,6 +197,7 @@ These are the tools Claude Code sees when connected to the board:
|
|
|
197
197
|
| `create_schedule` | Set up a new cron job |
|
|
198
198
|
| `update_schedule` | Modify a schedule |
|
|
199
199
|
| `delete_schedule` | Remove a schedule |
|
|
200
|
+
| `trigger_schedule` | Trigger a schedule to run immediately |
|
|
200
201
|
| `list_runs` | Check run history and results |
|
|
201
202
|
|
|
202
203
|
MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
|
package/package.json
CHANGED
package/src/agents/claude.ts
CHANGED
|
@@ -86,13 +86,14 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
if (event.type === "result") {
|
|
89
|
-
if (event.
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
if (event.usage) {
|
|
90
|
+
tokens_in = (event.usage.input_tokens ?? 0)
|
|
91
|
+
+ (event.usage.cache_read_input_tokens ?? 0)
|
|
92
|
+
+ (event.usage.cache_creation_input_tokens ?? 0);
|
|
93
|
+
tokens_out = event.usage.output_tokens ?? 0;
|
|
94
|
+
}
|
|
95
|
+
if (event.total_cost_usd) cost_usd = event.total_cost_usd;
|
|
92
96
|
}
|
|
93
|
-
if (event.tokens_in) tokens_in = event.tokens_in;
|
|
94
|
-
if (event.tokens_out) tokens_out = event.tokens_out;
|
|
95
|
-
if (event.cost_usd) cost_usd = event.cost_usd;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
return {
|
package/src/agents/types.ts
CHANGED
|
@@ -25,10 +25,12 @@ export interface StreamEvent {
|
|
|
25
25
|
session_id?: string;
|
|
26
26
|
tool?: string;
|
|
27
27
|
tool_input?: any;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
total_cost_usd?: number;
|
|
29
|
+
usage?: {
|
|
30
|
+
input_tokens?: number;
|
|
31
|
+
output_tokens?: number;
|
|
32
|
+
cache_read_input_tokens?: number;
|
|
33
|
+
cache_creation_input_tokens?: number;
|
|
32
34
|
};
|
|
33
35
|
[key: string]: any;
|
|
34
36
|
}
|
package/src/commands/install.ts
CHANGED
|
@@ -43,7 +43,8 @@ export async function runSystemctl(...args: string[]): Promise<{ exitCode: numbe
|
|
|
43
43
|
return { exitCode, stdout, stderr };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function generateServiceFile(bunPath: string, prodboardPath: string, home: string): string {
|
|
46
|
+
export function generateServiceFile(bunPath: string, prodboardPath: string, home: string, envPath?: string): string {
|
|
47
|
+
const pathLine = envPath ? `\nEnvironment="PATH=${envPath}"` : "";
|
|
47
48
|
return `[Unit]
|
|
48
49
|
Description=prodboard scheduler daemon
|
|
49
50
|
After=network.target
|
|
@@ -53,7 +54,7 @@ Type=simple
|
|
|
53
54
|
ExecStart=${bunPath} run ${prodboardPath} daemon
|
|
54
55
|
Restart=on-failure
|
|
55
56
|
RestartSec=10
|
|
56
|
-
Environment="HOME=${home}"
|
|
57
|
+
Environment="HOME=${home}"${pathLine}
|
|
57
58
|
|
|
58
59
|
[Install]
|
|
59
60
|
WantedBy=default.target
|
|
@@ -83,7 +84,7 @@ export async function install(args: string[]): Promise<void> {
|
|
|
83
84
|
const prodboardPath = Bun.which("prodboard") ?? `${bunPath} x prodboard`;
|
|
84
85
|
const home = os.homedir();
|
|
85
86
|
|
|
86
|
-
const serviceContent = generateServiceFile(bunPath, prodboardPath, home);
|
|
87
|
+
const serviceContent = generateServiceFile(bunPath, prodboardPath, home, process.env.PATH);
|
|
87
88
|
|
|
88
89
|
fs.mkdirSync(SERVICE_DIR, { recursive: true });
|
|
89
90
|
fs.writeFileSync(SERVICE_PATH, serviceContent);
|
|
@@ -205,6 +205,7 @@ export async function scheduleLogs(args: string[], dbOverride?: Database): Promi
|
|
|
205
205
|
schedule_id: (flags.schedule ?? flags.s) as string | undefined,
|
|
206
206
|
status: flags.status as string | undefined,
|
|
207
207
|
limit: safeParseInt(flags.limit ?? flags.n),
|
|
208
|
+
include_output: isJson,
|
|
208
209
|
});
|
|
209
210
|
|
|
210
211
|
if (isJson) {
|
package/src/mcp.ts
CHANGED
|
@@ -200,16 +200,28 @@ const TOOLS = [
|
|
|
200
200
|
},
|
|
201
201
|
{
|
|
202
202
|
name: "list_runs",
|
|
203
|
-
description: "List run history for schedules.",
|
|
203
|
+
description: "List run history for schedules. By default excludes large fields (stdout_tail, prompt_used) for compact output.",
|
|
204
204
|
inputSchema: {
|
|
205
205
|
type: "object" as const,
|
|
206
206
|
properties: {
|
|
207
207
|
schedule_id: { type: "string" as const, description: "Filter by schedule ID" },
|
|
208
208
|
status: { type: "string" as const, description: "Filter by run status" },
|
|
209
209
|
limit: { type: "number" as const, description: "Max runs to return" },
|
|
210
|
+
include_output: { type: "boolean" as const, description: "Include stdout_tail and prompt_used fields (default: false)" },
|
|
210
211
|
},
|
|
211
212
|
},
|
|
212
213
|
},
|
|
214
|
+
{
|
|
215
|
+
name: "trigger_schedule",
|
|
216
|
+
description: "Manually trigger a schedule to run immediately. The run is started asynchronously — returns the run ID so you can check status later via list_runs.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: "object" as const,
|
|
219
|
+
properties: {
|
|
220
|
+
id: { type: "string" as const, description: "Schedule ID or unique prefix" },
|
|
221
|
+
},
|
|
222
|
+
required: ["id"],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
213
225
|
];
|
|
214
226
|
|
|
215
227
|
const RESOURCES = [
|
|
@@ -429,7 +441,50 @@ export async function handleListRuns(db: Database, params: any) {
|
|
|
429
441
|
schedule_id: params?.schedule_id,
|
|
430
442
|
status: params?.status,
|
|
431
443
|
limit: params?.limit,
|
|
444
|
+
include_output: params?.include_output,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function handleTriggerSchedule(db: Database, config: Config, params: any) {
|
|
449
|
+
const sq = await getScheduleQueries();
|
|
450
|
+
if (!sq) throw new Error("Schedule module not available");
|
|
451
|
+
const rq = await getRunQueries();
|
|
452
|
+
if (!rq) throw new Error("Run module not available");
|
|
453
|
+
|
|
454
|
+
const schedule = sq.getScheduleByPrefix(db, params.id);
|
|
455
|
+
|
|
456
|
+
if (!schedule.enabled) {
|
|
457
|
+
throw new Error(`Schedule "${schedule.name}" is disabled. Enable it first via update_schedule.`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Respect concurrent run limit
|
|
461
|
+
const runningRuns = rq.getRunningRuns(db);
|
|
462
|
+
if (runningRuns.length >= config.daemon.maxConcurrentRuns) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Concurrent run limit reached (${runningRuns.length}/${config.daemon.maxConcurrentRuns}). Wait for a running schedule to finish or increase maxConcurrentRuns.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const run = rq.createRun(db, {
|
|
469
|
+
schedule_id: schedule.id,
|
|
470
|
+
prompt_used: schedule.prompt,
|
|
471
|
+
agent: config.daemon.agent,
|
|
432
472
|
});
|
|
473
|
+
|
|
474
|
+
// Fire-and-forget: start execution asynchronously
|
|
475
|
+
// Note: ExecutionManager created without tmux/worktree managers — MCP-triggered runs
|
|
476
|
+
// use direct execution only. Daemon-triggered runs get full tmux/worktree support.
|
|
477
|
+
const { ExecutionManager } = await import("./scheduler.ts");
|
|
478
|
+
const em = new ExecutionManager(db, config);
|
|
479
|
+
em.executeRun(schedule, run).catch(() => {});
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
run_id: run.id,
|
|
483
|
+
schedule_id: schedule.id,
|
|
484
|
+
schedule_name: schedule.name,
|
|
485
|
+
status: "started",
|
|
486
|
+
message: "Run started asynchronously. Use list_runs to check status.",
|
|
487
|
+
};
|
|
433
488
|
}
|
|
434
489
|
|
|
435
490
|
export async function startMcpServer(): Promise<void> {
|
|
@@ -497,6 +552,9 @@ export async function startMcpServer(): Promise<void> {
|
|
|
497
552
|
case "list_runs":
|
|
498
553
|
result = await handleListRuns(db, params ?? {});
|
|
499
554
|
break;
|
|
555
|
+
case "trigger_schedule":
|
|
556
|
+
result = await handleTriggerSchedule(db, config, params ?? {});
|
|
557
|
+
break;
|
|
500
558
|
default:
|
|
501
559
|
return {
|
|
502
560
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
package/src/queries/runs.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function updateRun(
|
|
|
50
50
|
|
|
51
51
|
export function listRuns(
|
|
52
52
|
db: Database,
|
|
53
|
-
opts?: { schedule_id?: string; status?: string; limit?: number }
|
|
53
|
+
opts?: { schedule_id?: string; status?: string; limit?: number; include_output?: boolean }
|
|
54
54
|
): Run[] {
|
|
55
55
|
const conditions: string[] = [];
|
|
56
56
|
const params: any[] = [];
|
|
@@ -67,8 +67,15 @@ export function listRuns(
|
|
|
67
67
|
const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
|
|
68
68
|
const limit = opts?.limit ?? 50;
|
|
69
69
|
|
|
70
|
+
const columns = opts?.include_output
|
|
71
|
+
? "r.*"
|
|
72
|
+
: `r.id, r.schedule_id, r.status, r.pid, r.started_at, r.finished_at,
|
|
73
|
+
r.exit_code, r.stderr_tail, r.session_id, r.worktree_path,
|
|
74
|
+
r.tokens_in, r.tokens_out, r.cost_usd, r.tools_used,
|
|
75
|
+
r.issues_touched, r.tmux_session, r.agent`;
|
|
76
|
+
|
|
70
77
|
return db.query(`
|
|
71
|
-
SELECT
|
|
78
|
+
SELECT ${columns}, s.name as schedule_name
|
|
72
79
|
FROM runs r
|
|
73
80
|
LEFT JOIN schedules s ON r.schedule_id = s.id
|
|
74
81
|
${where}
|
package/src/scheduler.ts
CHANGED
|
@@ -282,6 +282,13 @@ export class CronLoop {
|
|
|
282
282
|
|
|
283
283
|
const schedules = listSchedules(this.db);
|
|
284
284
|
|
|
285
|
+
// Snapshot running count once before creating any new runs this tick.
|
|
286
|
+
// Without this, createRun() for schedule A inserts a 'running' row that
|
|
287
|
+
// getRunningRuns() would count when evaluating schedule B, causing
|
|
288
|
+
// schedules with identical cron patterns to block each other.
|
|
289
|
+
const runningCount = getRunningRuns(this.db).length;
|
|
290
|
+
let newRunsThisTick = 0;
|
|
291
|
+
|
|
285
292
|
for (const schedule of schedules) {
|
|
286
293
|
try {
|
|
287
294
|
if (!shouldFire(schedule.cron, now)) continue;
|
|
@@ -289,8 +296,7 @@ export class CronLoop {
|
|
|
289
296
|
const lastFiredMinute = this.lastFired.get(schedule.id);
|
|
290
297
|
if (lastFiredMinute === minuteTs) continue;
|
|
291
298
|
|
|
292
|
-
|
|
293
|
-
if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
|
|
299
|
+
if (runningCount + newRunsThisTick >= this.config.daemon.maxConcurrentRuns) break;
|
|
294
300
|
|
|
295
301
|
const run = createRun(this.db, {
|
|
296
302
|
schedule_id: schedule.id,
|
|
@@ -299,6 +305,7 @@ export class CronLoop {
|
|
|
299
305
|
});
|
|
300
306
|
|
|
301
307
|
this.lastFired.set(schedule.id, minuteTs);
|
|
308
|
+
newRunsThisTick++;
|
|
302
309
|
|
|
303
310
|
this.executionManager.executeRun(schedule, run).catch(() => {});
|
|
304
311
|
} catch (err) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import type { FC } from "hono/jsx";
|
|
2
3
|
import type { Issue } from "../../types.ts";
|
|
3
4
|
import { StatusBadge } from "./status-badge.tsx";
|
|
@@ -13,18 +14,25 @@ export const Board: FC<{ issues: Issue[]; statuses: string[] }> = ({ issues, sta
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
return (
|
|
16
|
-
<div
|
|
17
|
+
<div id="board" data-statuses={JSON.stringify(statuses.filter((s) => s !== "archived"))} class="flex gap-4 overflow-x-auto pb-4">
|
|
17
18
|
{statuses.filter((s) => s !== "archived").map((status) => (
|
|
18
|
-
<div class="
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
<div class="flex-1 min-w-[220px]" key={status}>
|
|
20
|
+
<div class="flex items-center gap-2 mb-3 px-1">
|
|
21
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{status}</h3>
|
|
22
|
+
<span class="text-xs text-muted-foreground/60">{(grouped[status] || []).length}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="space-y-2">
|
|
25
|
+
{(grouped[status] || []).map((issue) => (
|
|
26
|
+
<a
|
|
27
|
+
href={`/issues/${issue.id}`}
|
|
28
|
+
class="block rounded-lg border border-border bg-card p-3 hover:bg-accent transition-colors group"
|
|
29
|
+
key={issue.id}
|
|
30
|
+
>
|
|
31
|
+
<div class="text-sm font-medium text-card-foreground group-hover:text-foreground">{issue.title}</div>
|
|
32
|
+
<div class="text-xs text-muted-foreground mt-1 font-mono">{issue.id.slice(0, 8)}</div>
|
|
33
|
+
</a>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
28
36
|
</div>
|
|
29
37
|
))}
|
|
30
38
|
</div>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import type { FC } from "hono/jsx";
|
|
2
|
-
import {
|
|
3
|
+
import { raw } from "hono/html";
|
|
4
|
+
import { TAILWIND_SCRIPT, THEME, STYLES } from "../static/style.ts";
|
|
3
5
|
|
|
4
6
|
export const Layout: FC<{ title?: string; children: any }> = ({ title, children }) => {
|
|
5
7
|
return (
|
|
@@ -8,20 +10,30 @@ export const Layout: FC<{ title?: string; children: any }> = ({ title, children
|
|
|
8
10
|
<meta charset="utf-8" />
|
|
9
11
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
10
12
|
<title>{title ? `${title} - prodboard` : "prodboard"}</title>
|
|
13
|
+
{raw(TAILWIND_SCRIPT)}
|
|
14
|
+
{raw(THEME)}
|
|
11
15
|
<style>{STYLES}</style>
|
|
12
16
|
</head>
|
|
13
|
-
<body>
|
|
14
|
-
<
|
|
15
|
-
<div class="
|
|
16
|
-
<a href="/" class="
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<a href="/
|
|
21
|
-
|
|
17
|
+
<body class="bg-background text-foreground min-h-screen">
|
|
18
|
+
<header class="border-b border-border">
|
|
19
|
+
<div class="mx-auto max-w-7xl flex items-center justify-between px-6 h-14">
|
|
20
|
+
<a href="/" class="text-sm font-bold tracking-tight text-foreground hover:text-foreground/80">
|
|
21
|
+
prodboard
|
|
22
|
+
</a>
|
|
23
|
+
<nav class="flex items-center gap-1">
|
|
24
|
+
<a href="/issues" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
|
|
25
|
+
Issues
|
|
26
|
+
</a>
|
|
27
|
+
<a href="/schedules" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
|
|
28
|
+
Schedules
|
|
29
|
+
</a>
|
|
30
|
+
<a href="/runs" class="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors">
|
|
31
|
+
Runs
|
|
32
|
+
</a>
|
|
33
|
+
</nav>
|
|
22
34
|
</div>
|
|
23
|
-
</
|
|
24
|
-
<main>{children}</main>
|
|
35
|
+
</header>
|
|
36
|
+
<main class="mx-auto max-w-7xl px-6 py-6">{children}</main>
|
|
25
37
|
</body>
|
|
26
38
|
</html>
|
|
27
39
|
);
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import type { FC } from "hono/jsx";
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
"todo": "
|
|
5
|
-
"in-progress": "
|
|
6
|
-
"review": "
|
|
7
|
-
"done": "
|
|
8
|
-
"archived": "
|
|
9
|
-
"running": "
|
|
10
|
-
"success": "
|
|
11
|
-
"failed": "
|
|
12
|
-
"timeout": "
|
|
13
|
-
"cancelled": "
|
|
4
|
+
const STATUS_STYLES: Record<string, string> = {
|
|
5
|
+
"todo": "bg-zinc-700/50 text-zinc-300 border-zinc-600",
|
|
6
|
+
"in-progress": "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
|
7
|
+
"review": "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
|
8
|
+
"done": "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
|
9
|
+
"archived": "bg-zinc-700/30 text-zinc-500 border-zinc-600/50",
|
|
10
|
+
"running": "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
|
11
|
+
"success": "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
|
12
|
+
"failed": "bg-red-500/15 text-red-400 border-red-500/30",
|
|
13
|
+
"timeout": "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
|
14
|
+
"cancelled": "bg-zinc-700/30 text-zinc-500 border-zinc-600/50",
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export const StatusBadge: FC<{ status: string }> = ({ status }) => {
|
|
17
|
-
const
|
|
18
|
+
const styles = STATUS_STYLES[status] ?? "bg-zinc-700/50 text-zinc-300 border-zinc-600";
|
|
18
19
|
return (
|
|
19
|
-
<span class=
|
|
20
|
+
<span class={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border ${styles}`}>
|
|
20
21
|
{status}
|
|
21
22
|
</span>
|
|
22
23
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import crypto from "crypto";
|
|
3
4
|
import { Layout } from "../components/layout.tsx";
|
|
@@ -22,16 +23,39 @@ export function authRoutes(_db: Database, _config: Config, authSalt: string) {
|
|
|
22
23
|
const error = c.req.query("error");
|
|
23
24
|
return c.html(
|
|
24
25
|
<Layout title="Login">
|
|
25
|
-
<div class="
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
<div class="flex items-center justify-center min-h-[calc(100vh-10rem)]">
|
|
27
|
+
<div class="w-full max-w-sm">
|
|
28
|
+
<div class="rounded-lg border border-border bg-card p-6">
|
|
29
|
+
<div class="mb-6">
|
|
30
|
+
<h1 class="text-lg font-semibold text-card-foreground">Login</h1>
|
|
31
|
+
<p class="text-sm text-muted-foreground mt-1">Enter your password to access prodboard.</p>
|
|
32
|
+
</div>
|
|
33
|
+
{error && (
|
|
34
|
+
<div class="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
|
35
|
+
Invalid password
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
<form method="post" action="/login">
|
|
39
|
+
<div class="mb-4">
|
|
40
|
+
<label for="password" class="block text-sm font-medium text-foreground mb-1.5">Password</label>
|
|
41
|
+
<input
|
|
42
|
+
type="password"
|
|
43
|
+
name="password"
|
|
44
|
+
id="password"
|
|
45
|
+
required
|
|
46
|
+
autofocus
|
|
47
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
<button
|
|
51
|
+
type="submit"
|
|
52
|
+
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
53
|
+
>
|
|
54
|
+
Sign in
|
|
55
|
+
</button>
|
|
56
|
+
</form>
|
|
32
57
|
</div>
|
|
33
|
-
|
|
34
|
-
</form>
|
|
58
|
+
</div>
|
|
35
59
|
</div>
|
|
36
60
|
</Layout>
|
|
37
61
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import type { Database } from "bun:sqlite";
|
|
3
4
|
import type { Config } from "../../types.ts";
|
|
@@ -17,33 +18,142 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
17
18
|
const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
|
|
18
19
|
return c.html(
|
|
19
20
|
<Layout title="Issues">
|
|
20
|
-
<div
|
|
21
|
-
<
|
|
22
|
-
|
|
21
|
+
<div class="flex items-center justify-between mb-6">
|
|
22
|
+
<div>
|
|
23
|
+
<h1 class="text-xl font-semibold">Issues</h1>
|
|
24
|
+
<p id="board-total" class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
|
|
25
|
+
</div>
|
|
26
|
+
<button
|
|
27
|
+
onclick="document.getElementById('new-issue-form').classList.toggle('hidden')"
|
|
28
|
+
class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
29
|
+
>
|
|
30
|
+
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
|
31
|
+
New Issue
|
|
32
|
+
</button>
|
|
23
33
|
</div>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
|
|
35
|
+
<div id="new-issue-form" class="hidden mb-6">
|
|
36
|
+
<div class="rounded-lg border border-border bg-card p-5">
|
|
37
|
+
<h2 class="text-base font-semibold text-card-foreground mb-4">Create Issue</h2>
|
|
38
|
+
<form method="post" action="/issues">
|
|
39
|
+
<div class="grid gap-4">
|
|
40
|
+
<div>
|
|
41
|
+
<label for="title" class="block text-sm font-medium text-foreground mb-1.5">Title</label>
|
|
42
|
+
<input type="text" name="title" id="title" required
|
|
43
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
44
|
+
placeholder="Issue title"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<label for="description" class="block text-sm font-medium text-foreground mb-1.5">Description</label>
|
|
49
|
+
<textarea name="description" id="description" rows={3}
|
|
50
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
|
|
51
|
+
placeholder="Optional description"
|
|
52
|
+
></textarea>
|
|
53
|
+
</div>
|
|
54
|
+
<div>
|
|
55
|
+
<label for="status" class="block text-sm font-medium text-foreground mb-1.5">Status</label>
|
|
56
|
+
<select name="status" id="status"
|
|
57
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
58
|
+
>
|
|
59
|
+
{config.general.statuses.map((s) => (
|
|
60
|
+
<option value={s} selected={s === config.general.defaultStatus}>{s}</option>
|
|
61
|
+
))}
|
|
62
|
+
</select>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="flex justify-end gap-2">
|
|
65
|
+
<button type="button"
|
|
66
|
+
onclick="document.getElementById('new-issue-form').classList.add('hidden')"
|
|
67
|
+
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors"
|
|
68
|
+
>Cancel</button>
|
|
69
|
+
<button type="submit"
|
|
70
|
+
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
71
|
+
>Create</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
45
76
|
</div>
|
|
77
|
+
|
|
46
78
|
<Board issues={issues} statuses={config.general.statuses} />
|
|
79
|
+
|
|
80
|
+
<p id="board-updated" class="text-xs text-muted-foreground/50 mt-2 text-right"></p>
|
|
81
|
+
|
|
82
|
+
<script dangerouslySetInnerHTML={{ __html: `
|
|
83
|
+
(function() {
|
|
84
|
+
var INTERVAL = 30000;
|
|
85
|
+
var board = document.getElementById('board');
|
|
86
|
+
var updatedEl = document.getElementById('board-updated');
|
|
87
|
+
var statuses = JSON.parse(board.dataset.statuses);
|
|
88
|
+
var lastUpdate = Date.now();
|
|
89
|
+
|
|
90
|
+
var STATUS_STYLES = {
|
|
91
|
+
'todo': 'bg-zinc-700/50 text-zinc-300 border-zinc-600',
|
|
92
|
+
'in-progress': 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
|
93
|
+
'review': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
94
|
+
'done': 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
|
95
|
+
'human-approval': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
96
|
+
'running': 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
|
97
|
+
};
|
|
98
|
+
var DEFAULT_STYLE = 'bg-zinc-700/50 text-zinc-300 border-zinc-600';
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s) {
|
|
101
|
+
var d = document.createElement('div');
|
|
102
|
+
d.textContent = s;
|
|
103
|
+
return d.innerHTML;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderBoard(issues) {
|
|
107
|
+
var grouped = {};
|
|
108
|
+
statuses.forEach(function(s) { grouped[s] = []; });
|
|
109
|
+
issues.forEach(function(issue) {
|
|
110
|
+
if (grouped[issue.status]) grouped[issue.status].push(issue);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
var html = '';
|
|
114
|
+
statuses.forEach(function(status) {
|
|
115
|
+
var items = grouped[status] || [];
|
|
116
|
+
html += '<div class="flex-1 min-w-[220px]">';
|
|
117
|
+
html += '<div class="flex items-center gap-2 mb-3 px-1">';
|
|
118
|
+
html += '<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">' + escapeHtml(status) + '</h3>';
|
|
119
|
+
html += '<span class="text-xs text-muted-foreground/60">' + items.length + '</span>';
|
|
120
|
+
html += '</div>';
|
|
121
|
+
html += '<div class="space-y-2">';
|
|
122
|
+
items.forEach(function(issue) {
|
|
123
|
+
html += '<a href="/issues/' + escapeHtml(issue.id) + '" class="block rounded-lg border border-border bg-card p-3 hover:bg-accent transition-colors group">';
|
|
124
|
+
html += '<div class="text-sm font-medium text-card-foreground group-hover:text-foreground">' + escapeHtml(issue.title) + '</div>';
|
|
125
|
+
html += '<div class="text-xs text-muted-foreground mt-1 font-mono">' + escapeHtml(issue.id.slice(0, 8)) + '</div>';
|
|
126
|
+
html += '</a>';
|
|
127
|
+
});
|
|
128
|
+
html += '</div></div>';
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
board.innerHTML = html;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateTimestamp() {
|
|
135
|
+
var secs = Math.round((Date.now() - lastUpdate) / 1000);
|
|
136
|
+
updatedEl.textContent = 'Updated ' + secs + 's ago';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function poll() {
|
|
140
|
+
fetch('/api/issues')
|
|
141
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function(issues) {
|
|
143
|
+
if (!issues) return;
|
|
144
|
+
renderBoard(issues);
|
|
145
|
+
var countEl = document.getElementById('board-total');
|
|
146
|
+
if (countEl) countEl.textContent = issues.length + ' total';
|
|
147
|
+
lastUpdate = Date.now();
|
|
148
|
+
updateTimestamp();
|
|
149
|
+
})
|
|
150
|
+
.catch(function() {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setInterval(poll, INTERVAL);
|
|
154
|
+
setInterval(updateTimestamp, 5000);
|
|
155
|
+
})();
|
|
156
|
+
` }} />
|
|
47
157
|
</Layout>
|
|
48
158
|
);
|
|
49
159
|
});
|
|
@@ -68,46 +178,73 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
68
178
|
const comments = listComments(db, issue.id);
|
|
69
179
|
return c.html(
|
|
70
180
|
<Layout title={issue.title}>
|
|
71
|
-
<div class="
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<div class="
|
|
77
|
-
|
|
181
|
+
<div class="mb-4">
|
|
182
|
+
<a href="/issues" class="text-sm text-muted-foreground hover:text-foreground transition-colors">← Back to issues</a>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="rounded-lg border border-border bg-card p-5 mb-4">
|
|
186
|
+
<div class="flex items-start justify-between gap-4">
|
|
187
|
+
<div class="flex items-center gap-3">
|
|
188
|
+
<StatusBadge status={issue.status} />
|
|
189
|
+
<h1 class="text-lg font-semibold text-card-foreground">{issue.title}</h1>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="mt-2 flex items-center gap-3 text-xs text-muted-foreground font-mono">
|
|
193
|
+
<span>{issue.id}</span>
|
|
194
|
+
<span>·</span>
|
|
195
|
+
<span>Created {issue.created_at}</span>
|
|
196
|
+
<span>·</span>
|
|
197
|
+
<span>Updated {issue.updated_at}</span>
|
|
78
198
|
</div>
|
|
79
|
-
{issue.description &&
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
199
|
+
{issue.description && (
|
|
200
|
+
<div class="mt-4 rounded-md bg-muted p-4 text-sm text-foreground whitespace-pre-wrap">{issue.description}</div>
|
|
201
|
+
)}
|
|
202
|
+
<div class="mt-4 flex items-center gap-2 pt-4 border-t border-border">
|
|
203
|
+
<form method="post" action={`/issues/${issue.id}/move`} class="flex items-center gap-2">
|
|
204
|
+
<select name="status"
|
|
205
|
+
class="rounded-md border border-input bg-background px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
206
|
+
>
|
|
83
207
|
{config.general.statuses.map((s) => (
|
|
84
208
|
<option value={s} selected={s === issue.status}>{s}</option>
|
|
85
209
|
))}
|
|
86
210
|
</select>
|
|
87
|
-
<button type="submit"
|
|
211
|
+
<button type="submit"
|
|
212
|
+
class="rounded-md bg-secondary px-2.5 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-secondary/80 transition-colors"
|
|
213
|
+
>Move</button>
|
|
88
214
|
</form>
|
|
89
215
|
<form method="post" action={`/issues/${issue.id}/delete`} onsubmit="return confirm('Delete this issue?')">
|
|
90
|
-
<button type="submit"
|
|
216
|
+
<button type="submit"
|
|
217
|
+
class="rounded-md bg-destructive/15 border border-destructive/30 px-2.5 py-1.5 text-sm font-medium text-red-400 hover:bg-destructive/25 transition-colors"
|
|
218
|
+
>Delete</button>
|
|
91
219
|
</form>
|
|
92
220
|
</div>
|
|
93
221
|
</div>
|
|
94
222
|
|
|
95
|
-
<div class="
|
|
96
|
-
<h2>Comments ({comments.length})</h2>
|
|
97
|
-
{comments.
|
|
98
|
-
<div class="
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
223
|
+
<div class="rounded-lg border border-border bg-card p-5">
|
|
224
|
+
<h2 class="text-sm font-semibold text-card-foreground mb-4">Comments <span class="text-muted-foreground font-normal">({comments.length})</span></h2>
|
|
225
|
+
{comments.length > 0 && (
|
|
226
|
+
<div class="space-y-3 mb-4">
|
|
227
|
+
{comments.map((comment) => (
|
|
228
|
+
<div class="border-l-2 border-border pl-3 py-1" key={comment.id}>
|
|
229
|
+
<div class="flex items-center gap-2 text-xs">
|
|
230
|
+
<span class="font-semibold text-foreground">{comment.author}</span>
|
|
231
|
+
<span class="text-muted-foreground">{comment.created_at}</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="text-sm text-foreground/90 mt-0.5">{comment.body}</div>
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
104
236
|
</div>
|
|
105
|
-
)
|
|
106
|
-
<form method="post" action={`/issues/${issue.id}/comment`}
|
|
107
|
-
<
|
|
108
|
-
|
|
237
|
+
)}
|
|
238
|
+
<form method="post" action={`/issues/${issue.id}/comment`}>
|
|
239
|
+
<textarea name="body" required rows={2}
|
|
240
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
|
|
241
|
+
placeholder="Add a comment..."
|
|
242
|
+
></textarea>
|
|
243
|
+
<div class="flex justify-end mt-2">
|
|
244
|
+
<button type="submit"
|
|
245
|
+
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
246
|
+
>Comment</button>
|
|
109
247
|
</div>
|
|
110
|
-
<button type="submit" class="btn btn-primary btn-sm">Comment</button>
|
|
111
248
|
</form>
|
|
112
249
|
</div>
|
|
113
250
|
</Layout>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import type { Database } from "bun:sqlite";
|
|
3
4
|
import type { Config } from "../../types.ts";
|
|
4
5
|
import { Layout } from "../components/layout.tsx";
|
|
5
6
|
import { StatusBadge } from "../components/status-badge.tsx";
|
|
6
7
|
import { listRuns, getRunningRuns } from "../../queries/runs.ts";
|
|
8
|
+
import { listIssues } from "../../queries/issues.ts";
|
|
7
9
|
import type { Run } from "../../types.ts";
|
|
8
10
|
|
|
9
11
|
export function runRoutes(db: Database, _config: Config) {
|
|
@@ -13,31 +15,46 @@ export function runRoutes(db: Database, _config: Config) {
|
|
|
13
15
|
const runs = listRuns(db, { limit: 50 });
|
|
14
16
|
return c.html(
|
|
15
17
|
<Layout title="Runs">
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<td><a href={`/runs/${run.id}`}>{run.id.slice(0, 8)}</a></td>
|
|
32
|
-
<td>{run.schedule_name ?? run.schedule_id.slice(0, 8)}</td>
|
|
33
|
-
<td>{run.agent}</td>
|
|
34
|
-
<td><StatusBadge status={run.status} /></td>
|
|
35
|
-
<td>{run.started_at}</td>
|
|
36
|
-
<td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td>
|
|
18
|
+
<div class="mb-6">
|
|
19
|
+
<h1 class="text-xl font-semibold">Runs</h1>
|
|
20
|
+
<p class="text-sm text-muted-foreground mt-0.5">Recent execution history</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="rounded-lg border border-border overflow-hidden">
|
|
24
|
+
<table class="w-full">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr class="border-b border-border bg-muted/50">
|
|
27
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">ID</th>
|
|
28
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Schedule</th>
|
|
29
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent</th>
|
|
30
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
|
|
31
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Started</th>
|
|
32
|
+
<th class="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Cost</th>
|
|
37
33
|
</tr>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
</thead>
|
|
35
|
+
<tbody class="divide-y divide-border">
|
|
36
|
+
{runs.map((run) => (
|
|
37
|
+
<tr class="hover:bg-muted/30 transition-colors" key={run.id}>
|
|
38
|
+
<td class="px-4 py-3">
|
|
39
|
+
<a href={`/runs/${run.id}`} class="text-sm font-mono text-blue-400 hover:text-blue-300 hover:underline">
|
|
40
|
+
{run.id.slice(0, 8)}
|
|
41
|
+
</a>
|
|
42
|
+
</td>
|
|
43
|
+
<td class="px-4 py-3 text-sm text-foreground">{run.schedule_name ?? run.schedule_id.slice(0, 8)}</td>
|
|
44
|
+
<td class="px-4 py-3 text-sm text-muted-foreground">{run.agent}</td>
|
|
45
|
+
<td class="px-4 py-3"><StatusBadge status={run.status} /></td>
|
|
46
|
+
<td class="px-4 py-3 text-sm text-muted-foreground">{run.started_at}</td>
|
|
47
|
+
<td class="px-4 py-3 text-sm text-right font-mono text-muted-foreground">
|
|
48
|
+
{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}
|
|
49
|
+
</td>
|
|
50
|
+
</tr>
|
|
51
|
+
))}
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
{runs.length === 0 && (
|
|
55
|
+
<div class="px-4 py-8 text-center text-sm text-muted-foreground">No runs yet.</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
41
58
|
</Layout>
|
|
42
59
|
);
|
|
43
60
|
});
|
|
@@ -48,38 +65,60 @@ export function runRoutes(db: Database, _config: Config) {
|
|
|
48
65
|
const run = runs.find((r) => r.id === id || r.id.startsWith(id));
|
|
49
66
|
if (!run) return c.text("Run not found", 404);
|
|
50
67
|
|
|
68
|
+
const details = [
|
|
69
|
+
{ label: "Exit Code", value: run.exit_code ?? "-" },
|
|
70
|
+
{ label: "Tokens In", value: run.tokens_in != null ? run.tokens_in.toLocaleString() : "-" },
|
|
71
|
+
{ label: "Tokens Out", value: run.tokens_out != null ? run.tokens_out.toLocaleString() : "-" },
|
|
72
|
+
{ label: "Cost", value: run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-" },
|
|
73
|
+
{ label: "Session ID", value: run.session_id ?? "-" },
|
|
74
|
+
{ label: "tmux Session", value: run.tmux_session ?? "-" },
|
|
75
|
+
{ label: "Tools Used", value: run.tools_used ?? "-" },
|
|
76
|
+
{ label: "Issues Touched", value: run.issues_touched ?? "-" },
|
|
77
|
+
];
|
|
78
|
+
|
|
51
79
|
return c.html(
|
|
52
80
|
<Layout title={`Run ${run.id.slice(0, 8)}`}>
|
|
53
|
-
<div class="
|
|
54
|
-
<
|
|
81
|
+
<div class="mb-4">
|
|
82
|
+
<a href="/runs" class="text-sm text-muted-foreground hover:text-foreground transition-colors">← Back to runs</a>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="rounded-lg border border-border bg-card p-5 mb-4">
|
|
86
|
+
<div class="flex items-center gap-3">
|
|
55
87
|
<StatusBadge status={run.status} />
|
|
56
|
-
Run {run.id.slice(0, 8)}
|
|
57
|
-
</
|
|
58
|
-
<div class="
|
|
59
|
-
Schedule: {run.schedule_name ?? run.schedule_id.slice(0, 8)}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
<h1 class="text-lg font-semibold text-card-foreground font-mono">Run {run.id.slice(0, 8)}</h1>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
91
|
+
<span>Schedule: {run.schedule_name ?? run.schedule_id.slice(0, 8)}</span>
|
|
92
|
+
<span>·</span>
|
|
93
|
+
<span>Agent: {run.agent}</span>
|
|
94
|
+
<span>·</span>
|
|
95
|
+
<span>Started: {run.started_at}</span>
|
|
96
|
+
{run.finished_at && (
|
|
97
|
+
<>
|
|
98
|
+
<span>·</span>
|
|
99
|
+
<span>Finished: {run.finished_at}</span>
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
63
102
|
</div>
|
|
64
|
-
<table>
|
|
65
|
-
<tbody>
|
|
66
|
-
<tr><td><strong>Exit Code</strong></td><td>{run.exit_code ?? "-"}</td></tr>
|
|
67
|
-
<tr><td><strong>Tokens In</strong></td><td>{run.tokens_in ?? "-"}</td></tr>
|
|
68
|
-
<tr><td><strong>Tokens Out</strong></td><td>{run.tokens_out ?? "-"}</td></tr>
|
|
69
|
-
<tr><td><strong>Cost</strong></td><td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td></tr>
|
|
70
|
-
<tr><td><strong>Session ID</strong></td><td>{run.session_id ?? "-"}</td></tr>
|
|
71
|
-
<tr><td><strong>tmux Session</strong></td><td>{run.tmux_session ?? "-"}</td></tr>
|
|
72
|
-
<tr><td><strong>Tools Used</strong></td><td>{run.tools_used ?? "-"}</td></tr>
|
|
73
|
-
<tr><td><strong>Issues Touched</strong></td><td>{run.issues_touched ?? "-"}</td></tr>
|
|
74
|
-
</tbody>
|
|
75
|
-
</table>
|
|
76
|
-
{run.stderr_tail && (
|
|
77
|
-
<div>
|
|
78
|
-
<h3 style="margin-top:1rem">Stderr</h3>
|
|
79
|
-
<pre class="description">{run.stderr_tail}</pre>
|
|
80
|
-
</div>
|
|
81
|
-
)}
|
|
82
103
|
</div>
|
|
104
|
+
|
|
105
|
+
<div class="rounded-lg border border-border bg-card overflow-hidden mb-4">
|
|
106
|
+
<div class="divide-y divide-border">
|
|
107
|
+
{details.map((d) => (
|
|
108
|
+
<div class="flex items-center px-4 py-2.5 hover:bg-muted/30 transition-colors" key={d.label}>
|
|
109
|
+
<span class="text-sm font-medium text-muted-foreground w-40 shrink-0">{d.label}</span>
|
|
110
|
+
<span class="text-sm text-foreground font-mono">{d.value}</span>
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{run.stderr_tail && (
|
|
117
|
+
<div class="rounded-lg border border-border bg-card p-5">
|
|
118
|
+
<h3 class="text-sm font-semibold text-card-foreground mb-3">Stderr</h3>
|
|
119
|
+
<pre class="rounded-md bg-muted p-4 text-sm text-foreground overflow-x-auto font-mono whitespace-pre-wrap">{run.stderr_tail}</pre>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
83
122
|
</Layout>
|
|
84
123
|
);
|
|
85
124
|
});
|
|
@@ -99,5 +138,10 @@ export function apiRoutes(db: Database, _config: Config) {
|
|
|
99
138
|
});
|
|
100
139
|
});
|
|
101
140
|
|
|
141
|
+
app.get("/issues", (c) => {
|
|
142
|
+
const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
|
|
143
|
+
return c.json(issues);
|
|
144
|
+
});
|
|
145
|
+
|
|
102
146
|
return app;
|
|
103
147
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import type { Database } from "bun:sqlite";
|
|
3
4
|
import type { Config } from "../../types.ts";
|
|
@@ -17,69 +18,119 @@ export function scheduleRoutes(db: Database, _config: Config) {
|
|
|
17
18
|
const schedules = listSchedules(db, { includeDisabled: true });
|
|
18
19
|
return c.html(
|
|
19
20
|
<Layout title="Schedules">
|
|
20
|
-
<div
|
|
21
|
-
<
|
|
22
|
-
|
|
21
|
+
<div class="flex items-center justify-between mb-6">
|
|
22
|
+
<div>
|
|
23
|
+
<h1 class="text-xl font-semibold">Schedules</h1>
|
|
24
|
+
<p class="text-sm text-muted-foreground mt-0.5">{schedules.length} total</p>
|
|
25
|
+
</div>
|
|
26
|
+
<button
|
|
27
|
+
onclick="document.getElementById('new-schedule-form').classList.toggle('hidden')"
|
|
28
|
+
class="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
29
|
+
>
|
|
30
|
+
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
|
31
|
+
New Schedule
|
|
32
|
+
</button>
|
|
23
33
|
</div>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
|
|
35
|
+
<div id="new-schedule-form" class="hidden mb-6">
|
|
36
|
+
<div class="rounded-lg border border-border bg-card p-5">
|
|
37
|
+
<h2 class="text-base font-semibold text-card-foreground mb-4">Create Schedule</h2>
|
|
38
|
+
<form method="post" action="/schedules">
|
|
39
|
+
<div class="grid gap-4">
|
|
40
|
+
<div>
|
|
41
|
+
<label for="name" class="block text-sm font-medium text-foreground mb-1.5">Name</label>
|
|
42
|
+
<input type="text" name="name" id="name" required
|
|
43
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
44
|
+
placeholder="Schedule name"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<label for="cron" class="block text-sm font-medium text-foreground mb-1.5">Cron Expression</label>
|
|
49
|
+
<input type="text" name="cron" id="cron" required
|
|
50
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
51
|
+
placeholder="*/30 * * * *"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<div>
|
|
55
|
+
<label for="prompt" class="block text-sm font-medium text-foreground mb-1.5">Prompt</label>
|
|
56
|
+
<textarea name="prompt" id="prompt" required rows={3}
|
|
57
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background resize-y"
|
|
58
|
+
placeholder="What should the agent do?"
|
|
59
|
+
></textarea>
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<label for="workdir" class="block text-sm font-medium text-foreground mb-1.5">Working Directory</label>
|
|
63
|
+
<input type="text" name="workdir" id="workdir"
|
|
64
|
+
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
|
65
|
+
placeholder="."
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="flex justify-end gap-2">
|
|
69
|
+
<button type="button"
|
|
70
|
+
onclick="document.getElementById('new-schedule-form').classList.add('hidden')"
|
|
71
|
+
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent transition-colors"
|
|
72
|
+
>Cancel</button>
|
|
73
|
+
<button type="submit"
|
|
74
|
+
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
75
|
+
>Create</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class="rounded-lg border border-border overflow-hidden">
|
|
83
|
+
<table class="w-full">
|
|
84
|
+
<thead>
|
|
85
|
+
<tr class="border-b border-border bg-muted/50">
|
|
86
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</th>
|
|
87
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Cron</th>
|
|
88
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
|
|
89
|
+
<th class="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Last Run</th>
|
|
90
|
+
<th class="text-right px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">Actions</th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody class="divide-y divide-border">
|
|
94
|
+
{schedules.map((s) => {
|
|
95
|
+
const lastRun = getLastRun(db, s.id);
|
|
96
|
+
return (
|
|
97
|
+
<tr class="hover:bg-muted/30 transition-colors" key={s.id}>
|
|
98
|
+
<td class="px-4 py-3 text-sm font-medium text-foreground">{s.name}</td>
|
|
99
|
+
<td class="px-4 py-3 text-sm text-muted-foreground font-mono">{s.cron}</td>
|
|
100
|
+
<td class="px-4 py-3">
|
|
101
|
+
{s.enabled
|
|
102
|
+
? <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border bg-emerald-500/15 text-emerald-400 border-emerald-500/30">enabled</span>
|
|
103
|
+
: <span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md border bg-zinc-700/30 text-zinc-500 border-zinc-600/50">disabled</span>
|
|
104
|
+
}
|
|
105
|
+
</td>
|
|
106
|
+
<td class="px-4 py-3 text-sm">
|
|
107
|
+
{lastRun ? <StatusBadge status={lastRun.status} /> : <span class="text-muted-foreground">Never</span>}
|
|
108
|
+
</td>
|
|
109
|
+
<td class="px-4 py-3 text-right">
|
|
110
|
+
<div class="flex items-center justify-end gap-1.5">
|
|
111
|
+
<form method="post" action={`/schedules/${s.id}/toggle`}>
|
|
112
|
+
<button type="submit"
|
|
113
|
+
class="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
|
114
|
+
>
|
|
115
|
+
{s.enabled ? "Disable" : "Enable"}
|
|
116
|
+
</button>
|
|
117
|
+
</form>
|
|
118
|
+
<form method="post" action={`/schedules/${s.id}/delete`} onsubmit="return confirm('Delete this schedule?')">
|
|
119
|
+
<button type="submit"
|
|
120
|
+
class="rounded-md bg-destructive/15 border border-destructive/30 px-2.5 py-1 text-xs font-medium text-red-400 hover:bg-destructive/25 transition-colors"
|
|
121
|
+
>Delete</button>
|
|
122
|
+
</form>
|
|
123
|
+
</div>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</tbody>
|
|
129
|
+
</table>
|
|
130
|
+
{schedules.length === 0 && (
|
|
131
|
+
<div class="px-4 py-8 text-center text-sm text-muted-foreground">No schedules yet.</div>
|
|
132
|
+
)}
|
|
45
133
|
</div>
|
|
46
|
-
<table>
|
|
47
|
-
<thead>
|
|
48
|
-
<tr>
|
|
49
|
-
<th>Name</th>
|
|
50
|
-
<th>Cron</th>
|
|
51
|
-
<th>Enabled</th>
|
|
52
|
-
<th>Last Run</th>
|
|
53
|
-
<th>Actions</th>
|
|
54
|
-
</tr>
|
|
55
|
-
</thead>
|
|
56
|
-
<tbody>
|
|
57
|
-
{schedules.map((s) => {
|
|
58
|
-
const lastRun = getLastRun(db, s.id);
|
|
59
|
-
return (
|
|
60
|
-
<tr key={s.id}>
|
|
61
|
-
<td>{s.name}</td>
|
|
62
|
-
<td><code>{s.cron}</code></td>
|
|
63
|
-
<td>{s.enabled ? "Yes" : "No"}</td>
|
|
64
|
-
<td>
|
|
65
|
-
{lastRun ? <StatusBadge status={lastRun.status} /> : "Never"}
|
|
66
|
-
</td>
|
|
67
|
-
<td>
|
|
68
|
-
<form method="post" action={`/schedules/${s.id}/toggle`} style="display:inline">
|
|
69
|
-
<button type="submit" class="btn btn-sm btn-primary">
|
|
70
|
-
{s.enabled ? "Disable" : "Enable"}
|
|
71
|
-
</button>
|
|
72
|
-
</form>
|
|
73
|
-
{" "}
|
|
74
|
-
<form method="post" action={`/schedules/${s.id}/delete`} style="display:inline" onsubmit="return confirm('Delete?')">
|
|
75
|
-
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
|
76
|
-
</form>
|
|
77
|
-
</td>
|
|
78
|
-
</tr>
|
|
79
|
-
);
|
|
80
|
-
})}
|
|
81
|
-
</tbody>
|
|
82
|
-
</table>
|
|
83
134
|
</Layout>
|
|
84
135
|
);
|
|
85
136
|
});
|
|
@@ -1,47 +1,35 @@
|
|
|
1
|
+
export const TAILWIND_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>`;
|
|
2
|
+
|
|
3
|
+
export const THEME = `
|
|
4
|
+
<style type="text/tailwindcss">
|
|
5
|
+
@theme {
|
|
6
|
+
--color-border: oklch(0.3 0 0);
|
|
7
|
+
--color-input: oklch(0.3 0 0);
|
|
8
|
+
--color-ring: oklch(0.55 0 0);
|
|
9
|
+
--color-background: oklch(0.13 0 0);
|
|
10
|
+
--color-foreground: oklch(0.93 0 0);
|
|
11
|
+
--color-card: oklch(0.16 0 0);
|
|
12
|
+
--color-card-foreground: oklch(0.93 0 0);
|
|
13
|
+
--color-muted: oklch(0.21 0 0);
|
|
14
|
+
--color-muted-foreground: oklch(0.55 0 0);
|
|
15
|
+
--color-accent: oklch(0.21 0 0);
|
|
16
|
+
--color-accent-foreground: oklch(0.93 0 0);
|
|
17
|
+
--color-destructive: oklch(0.55 0.2 25);
|
|
18
|
+
--color-primary: oklch(0.93 0 0);
|
|
19
|
+
--color-primary-foreground: oklch(0.13 0 0);
|
|
20
|
+
--color-secondary: oklch(0.21 0 0);
|
|
21
|
+
--color-secondary-foreground: oklch(0.93 0 0);
|
|
22
|
+
--radius-lg: 0.5rem;
|
|
23
|
+
--radius-md: calc(var(--radius-lg) - 2px);
|
|
24
|
+
--radius-sm: calc(var(--radius-lg) - 4px);
|
|
25
|
+
}
|
|
26
|
+
</style>
|
|
27
|
+
`;
|
|
28
|
+
|
|
1
29
|
export const STYLES = `
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.nav-links a { color: #cbd5e1; text-decoration: none; margin-left: 1.5rem; font-size: 0.9rem; }
|
|
8
|
-
.nav-links a:hover { color: #fff; }
|
|
9
|
-
main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1.5rem; }
|
|
10
|
-
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
|
|
11
|
-
h2 { margin-bottom: 0.75rem; font-size: 1.25rem; }
|
|
12
|
-
h3 { margin-bottom: 0.5rem; font-size: 1rem; text-transform: capitalize; }
|
|
13
|
-
.board { display: flex; gap: 1rem; overflow-x: auto; }
|
|
14
|
-
.board-column { flex: 1; min-width: 200px; background: #e9ecef; border-radius: 8px; padding: 0.75rem; }
|
|
15
|
-
.board-column h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: #495057; }
|
|
16
|
-
.count { font-weight: 400; color: #868e96; }
|
|
17
|
-
.card { display: block; background: #fff; border-radius: 6px; padding: 0.75rem; margin-top: 0.5rem; text-decoration: none; color: inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.06); transition: box-shadow 0.15s; }
|
|
18
|
-
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
|
|
19
|
-
.card-title { font-weight: 500; font-size: 0.9rem; }
|
|
20
|
-
.card-meta { font-size: 0.75rem; color: #868e96; margin-top: 0.25rem; }
|
|
21
|
-
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; color: #fff; font-size: 0.75rem; font-weight: 500; }
|
|
22
|
-
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
23
|
-
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #dee2e6; font-size: 0.9rem; }
|
|
24
|
-
th { background: #e9ecef; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
25
|
-
tr:hover td { background: #f1f3f5; }
|
|
26
|
-
.detail { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
|
|
27
|
-
.detail h1 { display: flex; align-items: center; gap: 0.75rem; }
|
|
28
|
-
.detail-meta { color: #868e96; font-size: 0.85rem; margin-bottom: 1rem; }
|
|
29
|
-
.description { white-space: pre-wrap; margin: 1rem 0; padding: 1rem; background: #f8f9fa; border-radius: 6px; }
|
|
30
|
-
.comment { border-left: 3px solid #dee2e6; padding: 0.5rem 0.75rem; margin: 0.75rem 0; }
|
|
31
|
-
.comment-author { font-weight: 600; font-size: 0.85rem; }
|
|
32
|
-
.comment-date { color: #868e96; font-size: 0.75rem; }
|
|
33
|
-
form { margin: 1rem 0; }
|
|
34
|
-
label { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
|
|
35
|
-
input[type="text"], input[type="password"], textarea, select { width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.9rem; font-family: inherit; }
|
|
36
|
-
textarea { min-height: 80px; resize: vertical; }
|
|
37
|
-
.form-row { margin-bottom: 0.75rem; }
|
|
38
|
-
button, .btn { display: inline-block; padding: 0.5rem 1rem; border: none; border-radius: 4px; font-size: 0.9rem; cursor: pointer; text-decoration: none; font-family: inherit; }
|
|
39
|
-
.btn-primary { background: #3b82f6; color: #fff; }
|
|
40
|
-
.btn-primary:hover { background: #2563eb; }
|
|
41
|
-
.btn-danger { background: #ef4444; color: #fff; }
|
|
42
|
-
.btn-danger:hover { background: #dc2626; }
|
|
43
|
-
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
|
44
|
-
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
|
45
|
-
.login-box { max-width: 360px; margin: 4rem auto; }
|
|
46
|
-
.flash { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; background: #fef3cd; color: #856404; }
|
|
30
|
+
body {
|
|
31
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
32
|
+
-webkit-font-smoothing: antialiased;
|
|
33
|
+
-moz-osx-font-smoothing: grayscale;
|
|
34
|
+
}
|
|
47
35
|
`;
|