161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
import { htmlPage } from "./page/html";
|
|
import { clampInt, json } from "./http";
|
|
import type { StartWebUiOptions, WebServerHandle } from "./types";
|
|
import { buildState, buildTechnicalInfo, sanitizeSettings } from "./services/state";
|
|
import { readHeartbeatSettings, updateHeartbeatSettings } from "./services/settings";
|
|
import { createQuickJob, deleteJob } from "./services/jobs";
|
|
import { readLogs } from "./services/logs";
|
|
|
|
export function startWebUi(opts: StartWebUiOptions): WebServerHandle {
|
|
const server = Bun.serve({
|
|
hostname: opts.host,
|
|
port: opts.port,
|
|
fetch: async (req) => {
|
|
const url = new URL(req.url);
|
|
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
return new Response(htmlPage(), {
|
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/api/health") {
|
|
return json({ ok: true, now: Date.now() });
|
|
}
|
|
|
|
if (url.pathname === "/api/state") {
|
|
return json(await buildState(opts.getSnapshot()));
|
|
}
|
|
|
|
if (url.pathname === "/api/settings") {
|
|
return json(sanitizeSettings(opts.getSnapshot().settings));
|
|
}
|
|
|
|
if (url.pathname === "/api/settings/heartbeat" && req.method === "POST") {
|
|
try {
|
|
const body = await req.json();
|
|
const payload = body as {
|
|
enabled?: unknown;
|
|
interval?: unknown;
|
|
prompt?: unknown;
|
|
excludeWindows?: unknown;
|
|
};
|
|
const patch: {
|
|
enabled?: boolean;
|
|
interval?: number;
|
|
prompt?: string;
|
|
excludeWindows?: Array<{ days?: number[]; start: string; end: string }>;
|
|
} = {};
|
|
|
|
if ("enabled" in payload) patch.enabled = Boolean(payload.enabled);
|
|
if ("interval" in payload) {
|
|
const iv = Number(payload.interval);
|
|
if (!Number.isFinite(iv)) throw new Error("interval must be numeric");
|
|
patch.interval = iv;
|
|
}
|
|
if ("prompt" in payload) patch.prompt = String(payload.prompt ?? "");
|
|
if ("excludeWindows" in payload) {
|
|
if (!Array.isArray(payload.excludeWindows)) {
|
|
throw new Error("excludeWindows must be an array");
|
|
}
|
|
patch.excludeWindows = payload.excludeWindows
|
|
.filter((entry) => entry && typeof entry === "object")
|
|
.map((entry) => {
|
|
const row = entry as Record<string, unknown>;
|
|
const start = String(row.start ?? "").trim();
|
|
const end = String(row.end ?? "").trim();
|
|
const days = Array.isArray(row.days)
|
|
? row.days
|
|
.map((d) => Number(d))
|
|
.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6)
|
|
: undefined;
|
|
return {
|
|
start,
|
|
end,
|
|
...(days && days.length > 0 ? { days } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
if (
|
|
!("enabled" in patch) &&
|
|
!("interval" in patch) &&
|
|
!("prompt" in patch) &&
|
|
!("excludeWindows" in patch)
|
|
) {
|
|
throw new Error("no heartbeat fields provided");
|
|
}
|
|
|
|
const next = await updateHeartbeatSettings(patch);
|
|
if (opts.onHeartbeatEnabledChanged && "enabled" in patch) {
|
|
await opts.onHeartbeatEnabledChanged(Boolean(patch.enabled));
|
|
}
|
|
if (opts.onHeartbeatSettingsChanged) {
|
|
await opts.onHeartbeatSettingsChanged(patch);
|
|
}
|
|
return json({ ok: true, heartbeat: next });
|
|
} catch (err) {
|
|
return json({ ok: false, error: String(err) });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/settings/heartbeat" && req.method === "GET") {
|
|
try {
|
|
return json({ ok: true, heartbeat: await readHeartbeatSettings() });
|
|
} catch (err) {
|
|
return json({ ok: false, error: String(err) });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/technical-info") {
|
|
return json(await buildTechnicalInfo(opts.getSnapshot()));
|
|
}
|
|
|
|
if (url.pathname === "/api/jobs/quick" && req.method === "POST") {
|
|
try {
|
|
const body = await req.json();
|
|
const result = await createQuickJob(body as { time?: unknown; prompt?: unknown });
|
|
if (opts.onJobsChanged) await opts.onJobsChanged();
|
|
return json({ ok: true, ...result });
|
|
} catch (err) {
|
|
return json({ ok: false, error: String(err) });
|
|
}
|
|
}
|
|
|
|
if (url.pathname.startsWith("/api/jobs/") && req.method === "DELETE") {
|
|
try {
|
|
const encodedName = url.pathname.slice("/api/jobs/".length);
|
|
const name = decodeURIComponent(encodedName);
|
|
await deleteJob(name);
|
|
if (opts.onJobsChanged) await opts.onJobsChanged();
|
|
return json({ ok: true });
|
|
} catch (err) {
|
|
return json({ ok: false, error: String(err) });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/jobs") {
|
|
const jobs = opts.getSnapshot().jobs.map((j) => ({
|
|
name: j.name,
|
|
schedule: j.schedule,
|
|
promptPreview: j.prompt.slice(0, 160),
|
|
}));
|
|
return json({ jobs });
|
|
}
|
|
|
|
if (url.pathname === "/api/logs") {
|
|
const tail = clampInt(url.searchParams.get("tail") ?? "", 200, 20, 2000);
|
|
return json(await readLogs(tail));
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
});
|
|
|
|
return {
|
|
stop: () => server.stop(),
|
|
host: opts.host,
|
|
port: server.port ?? opts.port,
|
|
};
|
|
}
|