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>
|
||||
);
|
||||
}
|
||||
@@ -463,6 +463,34 @@ export const de: Messages = {
|
||||
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
||||
"sidebar.folderName": "Ordnername",
|
||||
"sidebar.move": "Verschieben",
|
||||
"backup.now": "Jetzt sichern",
|
||||
"backup.running": "Sichere…",
|
||||
"backup.created": "Backup {file} erstellt.",
|
||||
"backup.s3Failed": "Backup erstellt, aber der S3-Upload ist fehlgeschlagen",
|
||||
"backup.daily": "Automatisch täglich sichern",
|
||||
"backup.at": "um",
|
||||
"backup.oclock": "Uhr",
|
||||
"backup.keep": "behalten:",
|
||||
"backup.s3": "In einen S3-Bucket kopieren (optional)",
|
||||
"backup.s3Hint": "Jeder S3-kompatible Speicher funktioniert (AWS, MinIO, Garage, Backblaze B2, Hetzner …). Schlüssel dürfen ${ENV}-Verweise sein. Das Backup enthält die Config mit API-Keys – nur einen privaten Bucket verwenden.",
|
||||
"backup.endpoint": "Endpunkt",
|
||||
"backup.region": "Region",
|
||||
"backup.bucket": "Bucket",
|
||||
"backup.prefix": "Präfix",
|
||||
"backup.accessKey": "Access Key",
|
||||
"backup.secretKey": "Secret Key",
|
||||
"backup.secretStored": "gespeichert – leer lassen zum Behalten",
|
||||
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
|
||||
"backup.last": "Letztes Backup",
|
||||
"backup.s3Ok": "hochgeladen",
|
||||
"jobs.fromTemplate": "Neue Automation aus Vorlage",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Projektordner",
|
||||
"jobs.tplSchedule": "Wann",
|
||||
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
|
||||
"jobs.tplMode": "Modus",
|
||||
"jobs.tplPrompt": "Aufgabe für den Agenten",
|
||||
"jobs.tplCreate": "Automation anlegen",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.up": "Hoch",
|
||||
@@ -570,6 +598,8 @@ export const de: Messages = {
|
||||
"settings.notifications": "Benachrichtigungen",
|
||||
"settings.notificationsEnable": "Aktivieren",
|
||||
"settings.notificationsOn": "Aktiv",
|
||||
"settings.digest": "Tägliche Kostenübersicht um 20:00",
|
||||
"settings.digestNow": "Heutige Übersicht jetzt senden",
|
||||
"settings.pushTest": "Test senden",
|
||||
"settings.pushTestSent": "Testbenachrichtigung gesendet",
|
||||
"settings.pushTestNone": "Kein Gerät angemeldet",
|
||||
@@ -702,6 +732,7 @@ export const de: Messages = {
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.refresh": "Aktualisieren",
|
||||
"nav.logout": "Abmelden",
|
||||
"login.username": "Benutzername",
|
||||
"login.password": "Passwort",
|
||||
|
||||
@@ -462,6 +462,34 @@ export const en = {
|
||||
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
||||
"sidebar.folderName": "Folder name",
|
||||
"sidebar.move": "Move",
|
||||
"backup.now": "Back up now",
|
||||
"backup.running": "Backing up…",
|
||||
"backup.created": "Backup {file} created.",
|
||||
"backup.s3Failed": "Backup created, but the S3 upload failed",
|
||||
"backup.daily": "Automatic daily backup",
|
||||
"backup.at": "at",
|
||||
"backup.oclock": "o'clock",
|
||||
"backup.keep": "keep",
|
||||
"backup.s3": "Copy to an S3 bucket (optional)",
|
||||
"backup.s3Hint": "Any S3-compatible storage works (AWS, MinIO, Garage, Backblaze B2, Hetzner …). Keys may be ${ENV} references. The backup contains the config with API keys – use a private bucket.",
|
||||
"backup.endpoint": "Endpoint",
|
||||
"backup.region": "Region",
|
||||
"backup.bucket": "Bucket",
|
||||
"backup.prefix": "Prefix",
|
||||
"backup.accessKey": "Access key",
|
||||
"backup.secretKey": "Secret key",
|
||||
"backup.secretStored": "stored – leave empty to keep",
|
||||
"backup.s3Keep": "Keep in bucket (0 = all)",
|
||||
"backup.last": "Last backup",
|
||||
"backup.s3Ok": "uploaded",
|
||||
"jobs.fromTemplate": "New automation from a template",
|
||||
"jobs.tplName": "Name",
|
||||
"jobs.tplProject": "Project folder",
|
||||
"jobs.tplSchedule": "When",
|
||||
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
|
||||
"jobs.tplMode": "Mode",
|
||||
"jobs.tplPrompt": "Task for the agent",
|
||||
"jobs.tplCreate": "Create automation",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.up": "Up",
|
||||
@@ -569,6 +597,8 @@ export const en = {
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.notificationsEnable": "Enable",
|
||||
"settings.notificationsOn": "Enabled",
|
||||
"settings.digest": "Daily cost summary at 20:00",
|
||||
"settings.digestNow": "Send today's summary now",
|
||||
"settings.pushTest": "Send test",
|
||||
"settings.pushTestSent": "Test notification sent",
|
||||
"settings.pushTestNone": "No device subscribed",
|
||||
@@ -701,6 +731,7 @@ export const en = {
|
||||
|
||||
"common.ok": "OK",
|
||||
"common.cancel": "Cancel",
|
||||
"common.refresh": "Refresh",
|
||||
"nav.logout": "Sign out",
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Ready-made automations (prompts in English: they are read by the agent). */
|
||||
export interface JobTemplate {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: { en: string; de: string };
|
||||
desc: { en: string; de: string };
|
||||
schedule: string;
|
||||
agent: "build" | "plan";
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export const JOB_TEMPLATES: JobTemplate[] = [
|
||||
{
|
||||
id: "deps",
|
||||
icon: "refresh",
|
||||
name: { en: "Dependency check", de: "Abhängigkeiten prüfen" },
|
||||
desc: { en: "Weekly: outdated packages and security advisories", de: "Wöchentlich: veraltete Pakete und Sicherheitsmeldungen" },
|
||||
schedule: "0 7 * * 1",
|
||||
agent: "build",
|
||||
prompt:
|
||||
"Check this project's dependencies (all package manifests you find). List outdated packages with current and latest version, and any known security advisories (use the package manager's audit command if there is one). Do not change any files. End with a short, prioritised report: what should be updated first and why.",
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
icon: "shield",
|
||||
name: { en: "Security review of recent changes", de: "Sicherheits-Review der letzten Änderungen" },
|
||||
desc: { en: "Nightly: reviews the commits of the last 24 hours", de: "Nachts: prüft die Commits der letzten 24 Stunden" },
|
||||
schedule: "0 3 * * *",
|
||||
agent: "plan",
|
||||
prompt:
|
||||
"Review the git commits of the last 24 hours (git log --since=\"24 hours ago\" -p) for security problems: injection, missing authorisation checks, secrets in code, unsafe deserialisation, path traversal, weak crypto. If there were no commits, say so and stop. Report each finding with file, line, severity and a suggested fix.",
|
||||
},
|
||||
{
|
||||
id: "tests",
|
||||
icon: "check",
|
||||
name: { en: "Test suite health", de: "Testsuite-Zustand" },
|
||||
desc: { en: "Daily: runs the tests and analyses failures", de: "Täglich: führt die Tests aus und analysiert Fehler" },
|
||||
schedule: "0 6 * * *",
|
||||
agent: "build",
|
||||
prompt:
|
||||
"Run the project's test suite. If everything passes, report the number of tests and the duration. If tests fail, find the cause of each failure (read the code, check recent commits) but do not change any files. Report failing tests, the likely cause and a proposed fix. Mention flaky tests if a re-run behaves differently.",
|
||||
},
|
||||
{
|
||||
id: "standup",
|
||||
icon: "scroll",
|
||||
name: { en: "Daily summary", de: "Tageszusammenfassung" },
|
||||
desc: { en: "Weekday mornings: what changed yesterday", de: "Werktags morgens: was sich gestern geändert hat" },
|
||||
schedule: "0 8 * * 1-5",
|
||||
agent: "plan",
|
||||
prompt:
|
||||
"Summarise what happened in this project since the previous working day: commits (grouped by topic), changed areas of the code, open branches. Point out anything that looks unfinished or risky. Keep it short – a stand-up style summary.",
|
||||
},
|
||||
{
|
||||
id: "todos",
|
||||
icon: "file-text",
|
||||
name: { en: "TODO/FIXME report", de: "TODO/FIXME-Bericht" },
|
||||
desc: { en: "Fridays: collects open TODOs and FIXMEs", de: "Freitags: sammelt offene TODOs und FIXMEs" },
|
||||
schedule: "0 16 * * 5",
|
||||
agent: "plan",
|
||||
prompt:
|
||||
"Find all TODO, FIXME, HACK and XXX comments in the project (skip dependencies and generated files). Group them by area, mark the ones that look urgent and the ones that are probably already done. Do not change files.",
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
icon: "file-doc",
|
||||
name: { en: "Documentation freshness", de: "Doku-Aktualität" },
|
||||
desc: { en: "Monthly: README and docs against the code", de: "Monatlich: README und Doku gegen den Code" },
|
||||
schedule: "0 9 1 * *",
|
||||
agent: "plan",
|
||||
prompt:
|
||||
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Quick schedule choices for the dialog. */
|
||||
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
|
||||
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
|
||||
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
|
||||
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
|
||||
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
|
||||
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
|
||||
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
|
||||
];
|
||||
@@ -3346,6 +3346,9 @@ button.danger-solid {
|
||||
color: var(--fg2);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.palette kbd .tiny {
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.palette-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3360,3 +3363,96 @@ button.danger-solid {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- backups ------------------------------------------------------------------- */
|
||||
.backup-s3 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.backup-s3 summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
}
|
||||
.backup-list {
|
||||
margin-top: 10px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.backup-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--fg1);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.backup-file:hover {
|
||||
background: var(--bg2);
|
||||
}
|
||||
.backup-file .grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* --- automation templates ------------------------------------------------------ */
|
||||
.job-tpl {
|
||||
width: min(720px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 88vh;
|
||||
}
|
||||
.job-tpl h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.job-tpl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.job-tpl-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.job-tpl-card:hover {
|
||||
background: var(--bg3);
|
||||
}
|
||||
.job-tpl-card span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.job-tpl-card small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.job-tpl textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
.chip-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"15 colour themes with light/dark/auto, push notifications on your phone",
|
||||
"Ctrl+K command palette, prompt templates with placeholders, pin/archive sessions and sort them into folders",
|
||||
"Share text and links from other apps to CodeCraft (installed app on Android)",
|
||||
"Automations from templates, a browser tool for agents (click, type, check pages), daily backups with optional S3 copy",
|
||||
],
|
||||
de: [
|
||||
"Terminal-Client `codecraft`: lokal arbeiten wie mit Claude Code – Einstellungen → Konto → Terminal-Client",
|
||||
@@ -18,6 +19,7 @@ export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"15 Farbthemes mit Hell/Dunkel/Auto, Push-Benachrichtigungen aufs Handy",
|
||||
"Ctrl+K-Befehlspalette, Prompt-Vorlagen mit Platzhaltern, Sessions anpinnen/archivieren und in Ordner sortieren",
|
||||
"Texte und Links aus anderen Apps an CodeCraft teilen (installierte App unter Android)",
|
||||
"Automationen aus Vorlagen, Browser-Werkzeug für Agenten (klicken, tippen, Seiten prüfen), tägliche Backups mit optionaler S3-Kopie",
|
||||
],
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user