Add incoming webhooks, fix stale service worker and push re-registration
- Webhooks for automations: per-job secret, Gitea/GitHub HMAC signatures, payload appended to the instructions; settings on the automations page - Service worker gets a new file name per build (proxies that cache *.js served a stale worker, which broke updates and Android installation); /sw.js is a kill switch that removes old workers - Push: devices re-register their subscription with the server on start and before a test; the test reports why a push service rejected a message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dffcb501a
commit
10d4b64289
16 files changed
+267
-18
No files matched your search
@@ -15,6 +15,7 @@ interface JobInfo {
|
||||
last_status?: string | null;
|
||||
next_run?: number | null;
|
||||
error?: string;
|
||||
webhook?: boolean;
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
const [jobs, setJobs] = useState<JobInfo[]>();
|
||||
const [path, setPath] = useState("");
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
|
||||
|
||||
const reload = () =>
|
||||
get<JobInfo[]>("/jobs")
|
||||
@@ -108,6 +110,44 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<pre>{j.spec.prompt}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="job-hook">
|
||||
<span>
|
||||
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
|
||||
</span>
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() =>
|
||||
act(j.id, async () => {
|
||||
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
|
||||
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
|
||||
})
|
||||
}
|
||||
>
|
||||
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
|
||||
</button>
|
||||
{j.webhook && (
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() =>
|
||||
act(j.id, async () => {
|
||||
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
|
||||
setHook({ id: j.id, url: r.url, secret: r.secret });
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("jobs.webhookRenew")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hook?.id === j.id && (
|
||||
<div className="notice ok">
|
||||
<div>{t("jobs.webhookShown")}</div>
|
||||
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
|
||||
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
|
||||
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
|
||||
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: 8, marginTop: 10 }}>
|
||||
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
|
||||
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
|
||||
import { currentSubscription, disablePush, enablePush, pushSupported, syncPush, testPush } from "../push";
|
||||
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
|
||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
@@ -149,7 +149,11 @@ function PushControl() {
|
||||
if (!pushSupported()) return setState("unsupported");
|
||||
if (Notification.permission === "denied") return setState("denied");
|
||||
currentSubscription()
|
||||
.then((s) => setState(s ? "on" : "off"))
|
||||
.then(async (s) => {
|
||||
setState(s ? "on" : "off");
|
||||
// A subscription the server lost (or never got) is registered again.
|
||||
if (s) await syncPush().catch(() => undefined);
|
||||
})
|
||||
.catch(() => setState("off"));
|
||||
}, []);
|
||||
const run = (f: () => Promise<unknown>) => async () => {
|
||||
@@ -174,8 +178,9 @@ function PushControl() {
|
||||
className="small"
|
||||
disabled={busy}
|
||||
onClick={run(async () => {
|
||||
const n = await testPush();
|
||||
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
|
||||
const r = await testPush();
|
||||
if (r.sent > 0) toast(t("settings.pushTestSent"), "success");
|
||||
else toast(r.errors?.length ? `${t("settings.pushTestFailed")}: ${r.errors.join("; ")}` : t("settings.pushTestNone"));
|
||||
})}
|
||||
>
|
||||
{t("settings.pushTest")}
|
||||
|
||||
Reference in new issue
Block a user