Add backups with S3, daily cost push, automation templates and a browser tool
- Daily backups (database + config) into <data_dir>/backups, keep N, optional upload to any S3-compatible bucket (SigV4, bucket pruning); settings panel with run-now, list and download - Daily cost summary push at 20:00 (opt-in per user, preview button) - Automations from templates (dependency check, security review, test health, daily summary, TODO report, docs freshness) via POST /jobs/create - browser tool: headless Chromium over CDP – click, type, keys, waits, JavaScript, navigation, screenshots, console errors; requests to private network addresses are blocked (sub-resources and redirects included) - Secrets masked in the config view: S3 secret key, metrics token - Replace the remaining text arrows/refresh glyphs with SVG icons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
873447ed17
commit
01c1484033
25 files changed
+1720
-47
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { post } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
|
||||
import { Icon } from "./Icon";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { FolderPicker } from "./NewSession";
|
||||
|
||||
/** Create an automation from a template: pick it, choose the project, adjust, create. */
|
||||
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const t = useT();
|
||||
const de = getLang() === "de";
|
||||
const [tpl, setTpl] = useState<JobTemplate>();
|
||||
const [name, setName] = useState("");
|
||||
const [schedule, setSchedule] = useState("");
|
||||
const [agent, setAgent] = useState("build");
|
||||
const [mode, setMode] = useState("auto");
|
||||
const [model, setModel] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||
useEffect(() => {
|
||||
if (!model && config?.default_model) setModel(config.default_model);
|
||||
}, [config, model]);
|
||||
|
||||
const choose = (x: JobTemplate) => {
|
||||
setTpl(x);
|
||||
setName(de ? x.name.de : x.name.en);
|
||||
setSchedule(x.schedule);
|
||||
setAgent(x.agent);
|
||||
setPrompt(x.prompt);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
|
||||
{!tpl ? (
|
||||
<>
|
||||
<h3>{t("jobs.fromTemplate")}</h3>
|
||||
<div className="job-tpl-grid">
|
||||
{JOB_TEMPLATES.map((x) => (
|
||||
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
|
||||
<Icon name={x.icon} />
|
||||
<span>
|
||||
<b>{de ? x.name.de : x.name.en}</b>
|
||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>
|
||||
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
|
||||
</h3>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplName")}</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplProject")}</span>
|
||||
<FolderPicker path={cwd} onPick={setCwd} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>{t("jobs.tplSchedule")}</span>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{SCHEDULES.map((s) => (
|
||||
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
|
||||
{de ? s.de : s.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
|
||||
<small className="hint">{t("jobs.tplCronHint")}</small>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplMode")}</span>
|
||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
||||
<option value="build">build</option>
|
||||
<option value="plan">plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.permissionMode")}</span>
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="auto">{t("permissionMode.auto")}</option>
|
||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("composer.model")}</span>
|
||||
<ModelSelect value={model} onChange={setModel} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t("jobs.tplPrompt")}</span>
|
||||
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
</label>
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
|
||||
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
|
||||
{t("jobs.tplCreate")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { navigate } from "../router";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { Spinner } from "./Activity";
|
||||
import { JobTemplateDialog } from "./JobTemplateDialog";
|
||||
|
||||
interface JobInfo {
|
||||
id: string;
|
||||
@@ -28,6 +29,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
const [path, setPath] = useState("");
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
|
||||
const [templates, setTemplates] = useState(false);
|
||||
|
||||
const reload = () =>
|
||||
get<JobInfo[]>("/jobs")
|
||||
@@ -64,6 +66,12 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="page">
|
||||
<div className="page-inner" style={{ maxWidth: 980 }}>
|
||||
<div className="notice">{t("jobs.intro")}</div>
|
||||
<div className="row" style={{ margin: "12px 0" }}>
|
||||
<button className="primary" onClick={() => setTemplates(true)}>
|
||||
<Icon name="plus" /> {t("jobs.fromTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
|
||||
{!jobs && <div className="empty"><Spinner /></div>}
|
||||
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
||||
{jobs?.map((j) => (
|
||||
|
||||
@@ -580,7 +580,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
}
|
||||
|
||||
/** Browse the user's folders and pick one. */
|
||||
function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
|
||||
export function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
|
||||
const t = useT();
|
||||
const [browse, setBrowse] = useState(path);
|
||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||
|
||||
@@ -137,9 +137,9 @@ export function Palette() {
|
||||
))}
|
||||
</div>
|
||||
<div className="palette-foot">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> {t("palette.move")}</span>
|
||||
<span><kbd><Icon name="arrow-up" className="tiny" /></kbd><kbd><Icon name="arrow-down" className="tiny" /></kbd> {t("palette.move")}</span>
|
||||
<span><kbd>Enter</kbd> {t("palette.open")}</span>
|
||||
<span><kbd>Alt+↑</kbd><kbd>Alt+↓</kbd> {t("palette.prevNext")}</span>
|
||||
<span><kbd>Alt+<Icon name="arrow-up" className="tiny" /></kbd><kbd>Alt+<Icon name="arrow-down" className="tiny" /></kbd> {t("palette.prevNext")}</span>
|
||||
<span><kbd>Esc</kbd> {t("palette.close")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +141,44 @@ function VersionInfo() {
|
||||
return <div className="hint mono">{info ? `CodeCraft ${info.version}${info.commit ? ` (${info.commit})` : ""}` : "…"}</div>;
|
||||
}
|
||||
|
||||
/** Daily cost summary at 20:00 (per user, all devices). */
|
||||
function DigestToggle() {
|
||||
const t = useT();
|
||||
const [on, setOn] = useState<boolean>();
|
||||
useEffect(() => {
|
||||
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {});
|
||||
}, []);
|
||||
if (on === undefined) return null;
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, flexBasis: "100%", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setOn(v);
|
||||
put("/push/digest", { enabled: v }).catch((err) => (setOn(!v), toast(err.message)));
|
||||
}}
|
||||
/>
|
||||
{t("settings.digest")}
|
||||
</label>
|
||||
{on && (
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() =>
|
||||
post<{ sent: number; errors: string[] }>("/push/digest")
|
||||
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
|
||||
.catch((e) => toast(e.message))
|
||||
}
|
||||
>
|
||||
{t("settings.digestNow")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Web Push on this device: enable/disable and a test notification. */
|
||||
function PushControl() {
|
||||
const t = useT();
|
||||
@@ -189,6 +227,7 @@ function PushControl() {
|
||||
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
|
||||
{t("settings.pushDisable")}
|
||||
</button>
|
||||
<DigestToggle />
|
||||
</>
|
||||
) : (
|
||||
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
import { Backups } from "./Backups";
|
||||
|
||||
interface Entry {
|
||||
ts: number;
|
||||
@@ -17,13 +19,7 @@ export function Audit() {
|
||||
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("audit.backup")}</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
|
||||
<a className="button primary" href="/api/backup">
|
||||
{t("audit.download")}
|
||||
</a>
|
||||
</div>
|
||||
<Backups />
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("audit.title")}
|
||||
@@ -36,8 +32,8 @@ export function Audit() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="small" onClick={reload}>
|
||||
↻
|
||||
<button className="small" onClick={reload} aria-label={t("common.refresh")}>
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, patch, post } from "../../api";
|
||||
import { toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { formatSize } from "../FileFigure";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
const MASK = "••••••••";
|
||||
|
||||
interface Overview {
|
||||
files: { name: string; size: number; modified: number }[];
|
||||
dir: string;
|
||||
last?: { at: number; file: string; size: number; error?: string; s3?: string } | null;
|
||||
enabled: boolean;
|
||||
hour: number;
|
||||
keep: number;
|
||||
s3: boolean;
|
||||
}
|
||||
|
||||
interface S3 {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
keep: number;
|
||||
}
|
||||
|
||||
/** Admin: automatic daily backups (local + S3) and manual download. */
|
||||
export function Backups() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<Overview>("/backups");
|
||||
const [form, setForm] = useState<{ enabled: boolean; hour: number; keep: number; s3: S3 }>();
|
||||
const [secret, setSecret] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get<{ config: { backup?: { enabled?: boolean; hour?: number; keep?: number; s3?: Partial<S3> } } }>("/config")
|
||||
.then((r) => {
|
||||
const b = r.config.backup ?? {};
|
||||
const s3 = b.s3 ?? {};
|
||||
setForm({
|
||||
enabled: b.enabled ?? true,
|
||||
hour: b.hour ?? 3,
|
||||
keep: b.keep ?? 30,
|
||||
s3: { endpoint: s3.endpoint ?? "", region: s3.region ?? "", bucket: s3.bucket ?? "", prefix: s3.prefix ?? "", access_key: s3.access_key ?? "", secret_key: s3.secret_key ?? "", keep: s3.keep ?? 0 },
|
||||
});
|
||||
})
|
||||
.catch((e) => toast(e.message));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
const patches = [
|
||||
{ path: ["backup", "enabled"], value: form.enabled },
|
||||
{ path: ["backup", "hour"], value: Math.min(23, Math.max(0, Number(form.hour) || 0)) },
|
||||
{ path: ["backup", "keep"], value: Math.max(1, Number(form.keep) || 30) },
|
||||
{ path: ["backup", "s3", "endpoint"], value: form.s3.endpoint.trim() },
|
||||
{ path: ["backup", "s3", "region"], value: form.s3.region.trim() },
|
||||
{ path: ["backup", "s3", "bucket"], value: form.s3.bucket.trim() },
|
||||
{ path: ["backup", "s3", "prefix"], value: form.s3.prefix.trim() },
|
||||
{ path: ["backup", "s3", "access_key"], value: form.s3.access_key.trim() },
|
||||
{ path: ["backup", "s3", "keep"], value: Math.max(0, Number(form.s3.keep) || 0) },
|
||||
];
|
||||
// Empty field = keep the stored secret.
|
||||
if (secret) patches.push({ path: ["backup", "s3", "secret_key"], value: secret });
|
||||
try {
|
||||
await patch("/config", { patches });
|
||||
setSecret("");
|
||||
toast(t("settings.saved"), "success");
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ file: string; s3?: string }>("/backups");
|
||||
if (r.s3 && r.s3 !== "ok") toast(`${t("backup.s3Failed")}: ${r.s3}`);
|
||||
else toast(t("backup.created", { file: r.file }), "success");
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
|
||||
form && (
|
||||
<label className="field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={String(form.s3[k] ?? "")}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setForm({ ...form, s3: { ...form.s3, [k]: type === "number" ? Number(e.target.value) : e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="archive" /> {t("audit.backup")}
|
||||
<span className="spacer" />
|
||||
<a className="button small" href="/api/backup">
|
||||
<Icon name="download" /> {t("audit.download")}
|
||||
</a>
|
||||
<button className="small primary" onClick={runNow} disabled={busy}>
|
||||
{busy ? t("backup.running") : t("backup.now")}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("audit.backupHint")}
|
||||
</div>
|
||||
{form && (
|
||||
<>
|
||||
<div className="row" style={{ gap: 12, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
||||
{t("backup.daily")}
|
||||
</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
{t("backup.at")}
|
||||
<input type="number" min={0} max={23} style={{ width: 64 }} value={form.hour} onChange={(e) => setForm({ ...form, hour: Number(e.target.value) })} />
|
||||
{t("backup.oclock")}
|
||||
</label>
|
||||
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
{t("backup.keep")}
|
||||
<input type="number" min={1} style={{ width: 70 }} value={form.keep} onChange={(e) => setForm({ ...form, keep: Number(e.target.value) })} />
|
||||
</label>
|
||||
</div>
|
||||
{data && <div className="hint mono" style={{ marginTop: 6 }}>{data.dir}</div>}
|
||||
<details className="backup-s3" open={!!form.s3.endpoint}>
|
||||
<summary>{t("backup.s3")}</summary>
|
||||
<div className="hint" style={{ margin: "6px 0 10px" }}>
|
||||
{t("backup.s3Hint")}
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
{s3("endpoint", t("backup.endpoint"), "https://s3.eu-central-1.amazonaws.com")}
|
||||
{s3("region", t("backup.region"), "eu-central-1")}
|
||||
{s3("bucket", t("backup.bucket"), "my-backups")}
|
||||
{s3("prefix", t("backup.prefix"), "codecraft/")}
|
||||
{s3("access_key", t("backup.accessKey"), "AKIA… or ${S3_ACCESS_KEY}")}
|
||||
<label className="field">
|
||||
<span>{t("backup.secretKey")}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={secret}
|
||||
autoComplete="new-password"
|
||||
placeholder={form.s3.secret_key === MASK ? t("backup.secretStored") : form.s3.secret_key || "${S3_SECRET_KEY}"}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{s3("keep", t("backup.s3Keep"), "0", "number")}
|
||||
</div>
|
||||
</details>
|
||||
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end" }}>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.last && (
|
||||
<div className={`notice ${data.last.error || (data.last.s3 && data.last.s3 !== "ok") ? "err" : "ok"}`} style={{ marginTop: 10 }}>
|
||||
{t("backup.last")}: {fmtDate(data.last.at)} · {data.last.error ?? `${data.last.file} (${formatSize(data.last.size)})`}
|
||||
{data.last.s3 && ` · S3: ${data.last.s3 === "ok" ? t("backup.s3Ok") : data.last.s3}`}
|
||||
</div>
|
||||
)}
|
||||
{data && data.files.length > 0 && (
|
||||
<div className="backup-list">
|
||||
{data.files.map((f) => (
|
||||
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
|
||||
<Icon name="file" />
|
||||
<span className="grow">{f.name}</span>
|
||||
<span className="hint">{formatSize(f.size)}</span>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user