refactor: split oversized modules and start server src/stores layout

Consolidate format helpers under lib/format, split React Query hooks by domain, extract AuthPicker/ConfigRefHint and HTTP trigger routing, demote file-private exports, and move KV/secrets/variables/profiles/http-auths stores under src/stores with root re-exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-22 06:16:38 +07:00
co-authored by Cursor
parent 368a2ca365
commit db884808f2
56 changed files with 2690 additions and 2578 deletions
+1 -761
View File
@@ -1,761 +1 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, opsApi } from "./client.js";
export function useBootstrap() {
return useQuery({
queryKey: ["bootstrap"],
queryFn: async () => (await api.get("/auth/bootstrap")).data,
});
}
export function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: async () => {
try {
return (await api.get("/auth/me")).data;
} catch (err) {
if (err.response?.status === 401) return { user: null };
throw err;
}
},
retry: false,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
onSuccess: (data) => {
qc.setQueryData(["me"], data);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
},
});
}
export function useRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
onSuccess: (data) => {
qc.setQueryData(["me"], data);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
},
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await api.post("/auth/logout")).data,
onSuccess: () => {
qc.setQueryData(["me"], null);
qc.clear();
},
});
}
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: async () => (await api.get("/dashboard")).data,
refetchInterval: (query) =>
query.state.data?.running?.length ? 4000 : 15000,
});
}
export function useScripts() {
return useQuery({
queryKey: ["scripts"],
queryFn: async () => (await api.get("/scripts")).data.scripts,
});
}
export function useScript(name, enabled = true) {
return useQuery({
queryKey: ["scripts", name],
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
enabled: Boolean(name) && enabled,
});
}
export function useSaveScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, content }) =>
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useCreatePlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, content, description }) =>
(await api.post("/plugins/create", { id, content, description })).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useForkScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, id, description }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
id,
description,
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useInstallPlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useDeleteScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name) =>
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDryRunScript() {
return useMutation({
mutationFn: async ({ name, content, data, context, config, owner }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content,
data,
context,
config,
owner,
})
).data,
});
}
export function useWorkflows(owner) {
return useQuery({
queryKey: ["workflows", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/workflows", { params })).data.workflows;
},
});
}
export function useWorkflow(owner, file, enabled = true) {
return useQuery({
queryKey: ["workflows", owner, file],
queryFn: async () =>
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
.data,
enabled: Boolean(owner && file) && enabled,
});
}
export function useOwners() {
return useQuery({
queryKey: ["owners"],
queryFn: async () => (await api.get("/owners")).data.owners,
});
}
export function useSaveWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, content, saveAnyway }) =>
(
await api.put(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
});
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
},
});
}
export function useSetWorkflowEnabled() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, enabled }) =>
(
await api.patch(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
{ enabled },
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDeleteWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file }) =>
(
await api.delete(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDuplicateWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, destOwner, destFile }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
{
...(destOwner ? { owner: destOwner } : {}),
...(destFile ? { file: destFile } : {}),
},
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useRunWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, data }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
data !== undefined ? { data } : {},
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["runs"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useReregisterWorkflows() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await api.post("/workflows/reregister")).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useRuns(filters = {}) {
const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters;
return useQuery({
queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }],
queryFn: async () => {
const params = {};
if (owner) params.owner = owner;
if (workflow) params.workflow = workflow;
if (status) params.status = status;
if (trigger) params.trigger = trigger;
if (after) params.after = after;
if (before) params.before = before;
if (limit != null) params.limit = limit;
if (offset != null) params.offset = offset;
if (sort) params.sort = sort;
if (order) params.order = order;
return (await api.get("/runs", { params })).data;
},
});
}
export function useConsecutiveFailures(limit) {
return useQuery({
queryKey: ["consecutive-failures", limit ?? "all"],
queryFn: async () => {
const params = {};
if (limit) params.limit = limit;
return (await api.get("/consecutive-failures", { params })).data;
},
});
}
export function useRun(id) {
return useQuery({
queryKey: ["runs", id],
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
enabled: Boolean(id),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" || status === "queued" ? 1500 : false;
},
});
}
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: async () => (await api.get("/users")).data.users,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/users", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...body }) =>
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useSecrets(owner) {
return useQuery({
queryKey: ["secrets", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/secrets", { params })).data.secrets;
},
});
}
export function useUpsertSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/secrets", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
export function useDeleteSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
export function useVariables(owner, options = {}) {
return useQuery({
queryKey: ["variables", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/variables", { params })).data.variables;
},
...options,
});
}
export function useUpsertVariable() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/variables", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
});
}
export function useDeleteVariable() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
});
}
export function useProfiles(owner, options = {}) {
return useQuery({
queryKey: ["profiles", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/profiles", { params })).data.profiles;
},
...options,
});
}
export function useProfileUsage(id, enabled = true) {
return useQuery({
queryKey: ["profiles", "usage", id],
queryFn: async () =>
(await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages,
enabled: Boolean(id) && enabled,
});
}
export function useUpsertProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/profiles", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
export function useDeleteProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, force }) =>
(
await api.delete(`/profiles/${encodeURIComponent(id)}`, {
params: force ? { force: "1" } : {},
})
).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
export function useKvNamespaces() {
return useQuery({
queryKey: ["kv", "namespaces"],
queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces,
});
}
export function useKv(filters = {}) {
const { namespace, q, limit, offset } = filters;
return useQuery({
queryKey: ["kv", { namespace, q, limit, offset }],
queryFn: async () => {
const params = {};
if (namespace) params.namespace = namespace;
if (q) params.q = q;
if (limit != null) params.limit = limit;
if (offset != null) params.offset = offset;
return (await api.get("/kv", { params })).data;
},
});
}
export function useHttpPages() {
return useQuery({
queryKey: ["http-pages"],
queryFn: async () => (await api.get("/http-pages")).data.pages,
});
}
export function useUpsertHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useDeleteHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useHttpAuths() {
return useQuery({
queryKey: ["http-auths"],
queryFn: async () => (await api.get("/http-auths")).data.auths,
});
}
export function useUpsertHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
export function useDeleteHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
/** Fetch plaintext literals only (not encrypted secrets). */
export async function fetchHttpAuthLiterals(id) {
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
}
export function useOpsStatus(enabled = true) {
return useQuery({
queryKey: ["ops-status"],
queryFn: async () => (await opsApi.get("/status")).data,
enabled,
retry: false,
refetchInterval: enabled ? 3000 : false,
});
}
export function useOpsPause() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/pause")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsResume() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/resume")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsReload() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/reload")).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ops-status"] });
qc.invalidateQueries({ queryKey: ["workflows"] });
},
});
}
export function useOpsRestart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ force = false } = {}) =>
(await opsApi.post("/restart", { force })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsScale() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ workers, force = false }) =>
(await opsApi.post("/scale", { workers, force })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsHttpStart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/http/start")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsHttpStop() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/http/stop")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsProcessRestart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ pmId }) =>
(await opsApi.post("/restart", { pmId: Number(pmId) })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useWorkflowTrash() {
return useQuery({
queryKey: ["workflows", "trash"],
queryFn: async () => (await api.get("/workflows/trash")).data.items,
});
}
export function useRestoreWorkflowTrash() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function usePurgeWorkflowTrash() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
},
});
}
export function useWorkflowRevisions(owner, file, enabled = true) {
return useQuery({
queryKey: ["workflows", owner, file, "revisions"],
queryFn: async () =>
(
await api.get(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`,
)
).data,
enabled: Boolean(owner && file) && enabled,
});
}
export function useWorkflowRevision(owner, file, revision) {
return useQuery({
queryKey: ["workflows", owner, file, "revisions", revision],
queryFn: async () =>
(
await api.get(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`,
)
).data,
enabled: Boolean(owner && file && revision != null),
});
}
export function useRevertWorkflowRevision() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, revision, saveAnyway }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`,
saveAnyway ? { saveAnyway: true } : {},
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
qc.invalidateQueries({
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
});
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useCreateWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, content, file, saveAnyway }) =>
(
await api.post(`/workflows/${encodeURIComponent(owner)}`, {
content,
...(file ? { file } : {}),
...(saveAnyway ? { saveAnyway: true } : {}),
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDownloadWorkflowBackup() {
return useMutation({
mutationFn: async () => {
const res = await api.get("/workflows/backup", { responseType: "blob" });
const disposition = res.headers["content-disposition"] ?? "";
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? "jerapah-flow-backup.zip";
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
return { ok: true };
},
});
}
export function useRestoreWorkflowBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ file, mode }) => {
const buffer = await file.arrayBuffer();
const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export * from "./hooks/index.js";
+91
View File
@@ -0,0 +1,91 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useBootstrap() {
return useQuery({
queryKey: ["bootstrap"],
queryFn: async () => (await api.get("/auth/bootstrap")).data,
});
}
export function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: async () => {
try {
return (await api.get("/auth/me")).data;
} catch (err) {
if (err.response?.status === 401) return { user: null };
throw err;
}
},
retry: false,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/auth/login", body)).data,
onSuccess: (data) => {
qc.setQueryData(["me"], data);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
},
});
}
export function useRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/auth/register", body)).data,
onSuccess: (data) => {
qc.setQueryData(["me"], data);
qc.invalidateQueries({ queryKey: ["bootstrap"] });
},
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await api.post("/auth/logout")).data,
onSuccess: () => {
qc.setQueryData(["me"], null);
qc.clear();
},
});
}
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: async () => (await api.get("/users")).data.users,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/users", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...body }) =>
(await api.patch(`/users/${encodeURIComponent(id)}`, body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/users/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
}
+58
View File
@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useHttpPages() {
return useQuery({
queryKey: ["http-pages"],
queryFn: async () => (await api.get("/http-pages")).data.pages,
});
}
export function useUpsertHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-pages", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useDeleteHttpPage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-pages/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-pages"] }),
});
}
export function useHttpAuths() {
return useQuery({
queryKey: ["http-auths"],
queryFn: async () => (await api.get("/http-auths")).data.auths,
});
}
export function useUpsertHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/http-auths", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
export function useDeleteHttpAuth() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/http-auths/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }),
});
}
/** Fetch plaintext literals only (not encrypted secrets). */
/** Fetch plaintext literals only (not encrypted secrets). */
export async function fetchHttpAuthLiterals(id) {
return (await api.get(`/http-auths/${encodeURIComponent(id)}/reveal`)).data;
}
+10
View File
@@ -0,0 +1,10 @@
export * from "./auth.js";
export * from "./scripts.js";
export * from "./workflows.js";
export * from "./runs.js";
export * from "./secrets.js";
export * from "./variables.js";
export * from "./profiles.js";
export * from "./kv.js";
export * from "./http.js";
export * from "./ops.js";
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../client.js";
export function useKvNamespaces() {
return useQuery({
queryKey: ["kv", "namespaces"],
queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces,
});
}
export function useKv(filters = {}) {
const { namespace, q, limit, offset } = filters;
return useQuery({
queryKey: ["kv", { namespace, q, limit, offset }],
queryFn: async () => {
const params = {};
if (namespace) params.namespace = namespace;
if (q) params.q = q;
if (limit != null) params.limit = limit;
if (offset != null) params.offset = offset;
return (await api.get("/kv", { params })).data;
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { opsApi } from "../client.js";
export function useOpsStatus(enabled = true) {
return useQuery({
queryKey: ["ops-status"],
queryFn: async () => (await opsApi.get("/status")).data,
enabled,
retry: false,
refetchInterval: enabled ? 3000 : false,
});
}
export function useOpsPause() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/pause")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsResume() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/resume")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsReload() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/reload")).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ops-status"] });
qc.invalidateQueries({ queryKey: ["workflows"] });
},
});
}
export function useOpsRestart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ force = false } = {}) =>
(await opsApi.post("/restart", { force })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsScale() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ workers, force = false }) =>
(await opsApi.post("/scale", { workers, force })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsHttpStart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/http/start")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsHttpStop() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await opsApi.post("/http/stop")).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
export function useOpsProcessRestart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ pmId }) =>
(await opsApi.post("/restart", { pmId: Number(pmId) })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
});
}
+44
View File
@@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useProfiles(owner, options = {}) {
return useQuery({
queryKey: ["profiles", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/profiles", { params })).data.profiles;
},
...options,
});
}
export function useProfileUsage(id, enabled = true) {
return useQuery({
queryKey: ["profiles", "usage", id],
queryFn: async () =>
(await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages,
enabled: Boolean(id) && enabled,
});
}
export function useUpsertProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/profiles", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
export function useDeleteProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, force }) =>
(
await api.delete(`/profiles/${encodeURIComponent(id)}`, {
params: force ? { force: "1" } : {},
})
).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }),
});
}
+56
View File
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../client.js";
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: async () => (await api.get("/dashboard")).data,
refetchInterval: (query) =>
query.state.data?.running?.length ? 4000 : 15000,
});
}
export function useRuns(filters = {}) {
const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters;
return useQuery({
queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }],
queryFn: async () => {
const params = {};
if (owner) params.owner = owner;
if (workflow) params.workflow = workflow;
if (status) params.status = status;
if (trigger) params.trigger = trigger;
if (after) params.after = after;
if (before) params.before = before;
if (limit != null) params.limit = limit;
if (offset != null) params.offset = offset;
if (sort) params.sort = sort;
if (order) params.order = order;
return (await api.get("/runs", { params })).data;
},
});
}
export function useConsecutiveFailures(limit) {
return useQuery({
queryKey: ["consecutive-failures", limit ?? "all"],
queryFn: async () => {
const params = {};
if (limit) params.limit = limit;
return (await api.get("/consecutive-failures", { params })).data;
},
});
}
export function useRun(id) {
return useQuery({
queryKey: ["runs", id],
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
enabled: Boolean(id),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" || status === "queued" ? 1500 : false;
},
});
}
+99
View File
@@ -0,0 +1,99 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useScripts() {
return useQuery({
queryKey: ["scripts"],
queryFn: async () => (await api.get("/scripts")).data.scripts,
});
}
export function useScript(name, enabled = true) {
return useQuery({
queryKey: ["scripts", name],
queryFn: async () => (await api.get(`/scripts/${encodeURIComponent(name)}`)).data,
enabled: Boolean(name) && enabled,
});
}
export function useSaveScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, content }) =>
(await api.put(`/scripts/${encodeURIComponent(name)}`, { content })).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useCreatePlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, content, description }) =>
(await api.post("/plugins/create", { id, content, description })).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useForkScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, id, description }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
id,
description,
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useInstallPlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useDeleteScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name) =>
(await api.delete(`/scripts/${encodeURIComponent(name)}`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDryRunScript() {
return useMutation({
mutationFn: async ({ name, content, data, context, config, owner }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
content,
data,
context,
config,
owner,
})
).data,
});
}
+30
View File
@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useSecrets(owner) {
return useQuery({
queryKey: ["secrets", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/secrets", { params })).data.secrets;
},
});
}
export function useUpsertSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/secrets", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
export function useDeleteSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useVariables(owner, options = {}) {
return useQuery({
queryKey: ["variables", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/variables", { params })).data.variables;
},
...options,
});
}
export function useUpsertVariable() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.put("/variables", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
});
}
export function useDeleteVariable() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
});
}
+273
View File
@@ -0,0 +1,273 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../client.js";
export function useWorkflows(owner) {
return useQuery({
queryKey: ["workflows", owner ?? "all"],
queryFn: async () => {
const params = owner ? { owner } : {};
return (await api.get("/workflows", { params })).data.workflows;
},
});
}
export function useWorkflow(owner, file, enabled = true) {
return useQuery({
queryKey: ["workflows", owner, file],
queryFn: async () =>
(await api.get(`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`))
.data,
enabled: Boolean(owner && file) && enabled,
});
}
export function useOwners() {
return useQuery({
queryKey: ["owners"],
queryFn: async () => (await api.get("/owners")).data.owners,
});
}
export function useSaveWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, content, saveAnyway }) =>
(
await api.put(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
{ content, ...(saveAnyway ? { saveAnyway: true } : {}) },
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
});
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
},
});
}
export function useSetWorkflowEnabled() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, enabled }) =>
(
await api.patch(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
{ enabled },
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDeleteWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file }) =>
(
await api.delete(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}`,
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDuplicateWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, destOwner, destFile }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/duplicate`,
{
...(destOwner ? { owner: destOwner } : {}),
...(destFile ? { file: destFile } : {}),
},
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useRunWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, data }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/run`,
data !== undefined ? { data } : {},
)
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["runs"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useReregisterWorkflows() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await api.post("/workflows/reregister")).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useWorkflowTrash() {
return useQuery({
queryKey: ["workflows", "trash"],
queryFn: async () => (await api.get("/workflows/trash")).data.items,
});
}
export function useRestoreWorkflowTrash() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.post(`/workflows/trash/${encodeURIComponent(id)}/restore`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function usePurgeWorkflowTrash() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id) =>
(await api.delete(`/workflows/trash/${encodeURIComponent(id)}`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows", "trash"] });
},
});
}
export function useWorkflowRevisions(owner, file, enabled = true) {
return useQuery({
queryKey: ["workflows", owner, file, "revisions"],
queryFn: async () =>
(
await api.get(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions`,
)
).data,
enabled: Boolean(owner && file) && enabled,
});
}
export function useWorkflowRevision(owner, file, revision) {
return useQuery({
queryKey: ["workflows", owner, file, "revisions", revision],
queryFn: async () =>
(
await api.get(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}`,
)
).data,
enabled: Boolean(owner && file && revision != null),
});
}
export function useRevertWorkflowRevision() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, file, revision, saveAnyway }) =>
(
await api.post(
`/workflows/${encodeURIComponent(owner)}/${encodeURIComponent(file)}/revisions/${revision}/revert`,
saveAnyway ? { saveAnyway: true } : {},
)
).data,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["workflows", vars.owner, vars.file] });
qc.invalidateQueries({
queryKey: ["workflows", vars.owner, vars.file, "revisions"],
});
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useCreateWorkflow() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ owner, content, file, saveAnyway }) =>
(
await api.post(`/workflows/${encodeURIComponent(owner)}`, {
content,
...(file ? { file } : {}),
...(saveAnyway ? { saveAnyway: true } : {}),
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDownloadWorkflowBackup() {
return useMutation({
mutationFn: async () => {
const res = await api.get("/workflows/backup", { responseType: "blob" });
const disposition = res.headers["content-disposition"] ?? "";
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? "jerapah-flow-backup.zip";
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
return { ok: true };
},
});
}
export function useRestoreWorkflowBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ file, mode }) => {
const buffer = await file.arrayBuffer();
const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return (await api.post("/workflows/backup/restore", { zipBase64, mode })).data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workflows"] });
qc.invalidateQueries({ queryKey: ["owners"] });
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
@@ -2,7 +2,7 @@ import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx";
import { useNotifications } from "../notifications.jsx";
import { formatBytes, formatCpu, formatDuration } from "../lib/format.js";
import { formatBytes, formatCpu, formatDuration } from "../lib/format";
const MAX_WORKERS = 32;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { LuX } from "react-icons/lu";
import { levelName } from "../lib/format.jsx";
import { levelName } from "../lib/format";
export function LogViewer({ logs = [], filters = [], onRemoveFilter, className = "" }) {
const [wordWrap, setWordWrap] = useState(true);
@@ -1,4 +1,4 @@
import { formatBytes, formatCpu } from "../lib/format.js";
import { formatBytes, formatCpu } from "../lib/format";
export function ProcessResourcesCard({ children }) {
const totals = children?.totals ?? { memory: 0, cpu: 0 };
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { LuChevronDown, LuX } from "react-icons/lu";
import { FormInput } from "../FormControls.jsx";
/**
* Searchable dropdown: pick an auth to add; chips with X to remove.
* YAML stores profile UUIDs; UI shows names.
*/
export function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef(null);
const inputRef = useRef(null);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
const available = useMemo(() => {
const q = query.trim().toLowerCase();
return auths
.filter((a) => !selectedSet.has(a.id))
.filter((a) => {
if (!q) return true;
return (
a.name.toLowerCase().includes(q) ||
String(a.type).toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
);
});
}, [auths, selectedSet, query]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
function addAuth(id) {
if (selectedSet.has(id)) return;
onChange([...selectedIds, id]);
setQuery("");
setOpen(false);
}
function removeAuth(id) {
onChange(selectedIds.filter((x) => x !== id));
}
return (
<div className="flex w-full flex-col gap-1" ref={rootRef}>
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => {
const auth = byId.get(id);
return (
<span
key={id}
className="badge badge-outline gap-1 h-7 px-2 font-normal"
title={id}
>
<span className="max-w-[10rem] truncate">
{auth ? (
<>
{auth.name}
<span className="opacity-60"> · {auth.type}</span>
</>
) : (
<span className="opacity-60">missing</span>
)}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square -mr-1"
title="Remove"
aria-label={`Remove ${auth?.name ?? id}`}
disabled={disabled}
onClick={() => removeAuth(id)}
>
<LuX className="size-3.5" />
</button>
</span>
);
})}
</div>
) : null}
<div className="relative w-full">
<div className="join w-full">
<FormInput
ref={inputRef}
type="search"
className="join-item w-full min-w-0"
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
value={query}
disabled={disabled || auths.length === 0}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
if (e.key === "Enter") {
e.preventDefault();
if (available[0]) addAuth(available[0].id);
}
}}
/>
<button
type="button"
className="btn btn-sm join-item btn-square"
disabled={disabled || auths.length === 0}
aria-label="Open auth list"
onClick={() => {
setOpen((v) => !v);
if (!open) inputRef.current?.focus();
}}
>
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</div>
{open && !disabled && auths.length > 0 ? (
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{available.length === 0 ? (
<li className="disabled">
<span className="opacity-60">
{query.trim() ? "No matches" : "All profiles selected"}
</span>
</li>
) : (
available.map((a) => (
<li key={a.id}>
<button type="button" onClick={() => addAuth(a.id)}>
<span className="font-mono text-sm">{a.name}</span>
<span className="opacity-60 text-xs">{a.type}</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
{inlineCount > 0 ? (
<span className="text-xs opacity-60">
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
</span>
) : null}
</div>
);
}
@@ -1,153 +1,12 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { triggerDestinations } from "../../lib/workflow-doc.js";
import { prettyJson } from "../../lib/script.js";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { FieldLabel } from "./FieldHelp.jsx";
import { ConfigRefHint } from "./ConfigRefHint.jsx";
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
const VAR_PEEK_MAX = 48;
const CONFIG_REF_PREFIXES = [
{ prefix: "$SECRET_", label: "secret" },
{ prefix: "$CONTEXT_", label: "context" },
{ prefix: "$VAR_", label: "variable" },
];
function describeConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
return { label, name: trimmed.slice(prefix.length), kind: prefix };
}
}
return null;
}
function formatVarDisplay(value) {
if (typeof value === "string") return value === "" ? '""' : value;
return String(value);
}
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
if (!text) return "";
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
}
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name);
if (match) {
return { status: "found", value: match.value, type: match.type };
}
const otherOwners = [
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
];
if (otherOwners.length > 0) {
return { status: "other_owner", otherOwners };
}
return { status: "missing" };
}
function variablesDeepLink({ owner, name, missing }) {
if (missing) {
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
if (name) params.set("name", name);
const q = params.toString();
return q ? `/variables/new?${q}` : "/variables/new";
}
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
}
function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_";
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false);
useEffect(() => {
setRevealed(false);
}, [value, owner]);
if (!ref) return null;
if (!isVar) {
return (
<p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span>
</p>
);
}
const lookup = !owner
? { status: "missing" }
: isPending
? { status: "loading" }
: lookupVariable(variables, owner, ref.name);
const linkTo = variablesDeepLink({
owner: owner || "",
name: ref.name,
missing: lookup.status !== "found",
});
let statusNote = null;
if (lookup.status === "missing") {
statusNote = <span className="text-warning">(missing)</span>;
} else if (lookup.status === "other_owner") {
statusNote = (
<span className="text-warning">
(missing · other owner: {lookup.otherOwners.join(", ")})
</span>
);
}
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
const peek = truncatePeek(fullText);
const masked = lookup.status === "found" ? "******" : null;
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
<span>
from variable <span className="font-mono">{ref.name}</span>
{lookup.status === "found" ? ":" : null}
</span>
{lookup.status === "found" ? (
<>
<span className="font-mono" title={revealed ? fullText : undefined}>
{revealed ? peek : masked}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
aria-label={revealed ? "Hide value" : "Show value"}
title={revealed ? "Hide value" : "Show value"}
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
</button>
</>
) : null}
{statusNote}
{lookup.status === "loading" ? null : (
<Link
to={linkTo}
target="_blank"
rel="noopener noreferrer"
className="link link-hover inline-flex items-center gap-0.5"
>
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
<LuExternalLink className="size-3" aria-hidden />
</Link>
)}
</div>
);
}
function fieldSpec(meta, key) {
const spec = meta?.config?.[key];
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js";
const VAR_PEEK_MAX = 48;
const CONFIG_REF_PREFIXES = [
{ prefix: "$SECRET_", label: "secret" },
{ prefix: "$CONTEXT_", label: "context" },
{ prefix: "$VAR_", label: "variable" },
];
function describeConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
return { label, name: trimmed.slice(prefix.length), kind: prefix };
}
}
return null;
}
function formatVarDisplay(value) {
if (typeof value === "string") return value === "" ? '""' : value;
return String(value);
}
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
if (!text) return "";
if (text.length <= maxLen) return text;
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
}
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name);
if (match) {
return { status: "found", value: match.value, type: match.type };
}
const otherOwners = [
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
];
if (otherOwners.length > 0) {
return { status: "other_owner", otherOwners };
}
return { status: "missing" };
}
function variablesDeepLink({ owner, name, missing }) {
if (missing) {
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
if (name) params.set("name", name);
const q = params.toString();
return q ? `/variables/new?${q}` : "/variables/new";
}
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
}
export function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_";
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false);
useEffect(() => {
setRevealed(false);
}, [value, owner]);
if (!ref) return null;
if (!isVar) {
return (
<p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span>
</p>
);
}
const lookup = !owner
? { status: "missing" }
: isPending
? { status: "loading" }
: lookupVariable(variables, owner, ref.name);
const linkTo = variablesDeepLink({
owner: owner || "",
name: ref.name,
missing: lookup.status !== "found",
});
let statusNote = null;
if (lookup.status === "missing") {
statusNote = <span className="text-warning">(missing)</span>;
} else if (lookup.status === "other_owner") {
statusNote = (
<span className="text-warning">
(missing · other owner: {lookup.otherOwners.join(", ")})
</span>
);
}
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
const peek = truncatePeek(fullText);
const masked = lookup.status === "found" ? "******" : null;
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
<span>
from variable <span className="font-mono">{ref.name}</span>
{lookup.status === "found" ? ":" : null}
</span>
{lookup.status === "found" ? (
<>
<span className="font-mono" title={revealed ? fullText : undefined}>
{revealed ? peek : masked}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
aria-label={revealed ? "Hide value" : "Show value"}
title={revealed ? "Hide value" : "Show value"}
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
</button>
</>
) : null}
{statusNote}
{lookup.status === "loading" ? null : (
<Link
to={linkTo}
target="_blank"
rel="noopener noreferrer"
className="link link-hover inline-flex items-center gap-0.5"
>
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
<LuExternalLink className="size-3" aria-hidden />
</Link>
)}
</div>
);
}
@@ -7,7 +7,7 @@ import { errorMessage } from "../../api/client.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format.jsx";
import { StatusBadge } from "../../lib/format";
import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
@@ -390,7 +390,7 @@ function needsMode(needs) {
return "map";
}
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
function ScriptTryDialog({ script, config, meta, owner, onClose }) {
const existing = useScript(script);
const dryRun = useDryRunScript();
const defaults = useMemo(() => contextFromMeta(meta), [meta]);
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu";
import { useState } from "react";
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import cronstrue from "cronstrue";
import { namespacedPath, HTTP_METHODS, triggerDestinations } from "../../lib/workflow-doc.js";
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
import { FormInput, FormSelect } from "../FormControls.jsx";
import { AuthPicker } from "./AuthPicker.jsx";
export function TriggerCard({
trigger,
@@ -253,164 +254,6 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes
);
}
/**
* Searchable dropdown: pick an auth to add; chips with X to remove.
* YAML stores profile UUIDs; UI shows names.
*/
function AuthPicker({ auths, selectedIds, inlineCount, disabled, onChange }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef(null);
const inputRef = useRef(null);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const byId = useMemo(() => new Map(auths.map((a) => [a.id, a])), [auths]);
const available = useMemo(() => {
const q = query.trim().toLowerCase();
return auths
.filter((a) => !selectedSet.has(a.id))
.filter((a) => {
if (!q) return true;
return (
a.name.toLowerCase().includes(q) ||
String(a.type).toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
);
});
}, [auths, selectedSet, query]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
function addAuth(id) {
if (selectedSet.has(id)) return;
onChange([...selectedIds, id]);
setQuery("");
setOpen(false);
}
function removeAuth(id) {
onChange(selectedIds.filter((x) => x !== id));
}
return (
<div className="flex w-full flex-col gap-1" ref={rootRef}>
<span className="text-sm font-medium opacity-80">Auth (any of)</span>
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => {
const auth = byId.get(id);
return (
<span
key={id}
className="badge badge-outline gap-1 h-7 px-2 font-normal"
title={id}
>
<span className="max-w-[10rem] truncate">
{auth ? (
<>
{auth.name}
<span className="opacity-60"> · {auth.type}</span>
</>
) : (
<span className="opacity-60">missing</span>
)}
</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square -mr-1"
title="Remove"
aria-label={`Remove ${auth?.name ?? id}`}
disabled={disabled}
onClick={() => removeAuth(id)}
>
<LuX className="size-3.5" />
</button>
</span>
);
})}
</div>
) : null}
<div className="relative w-full">
<div className="join w-full">
<FormInput
ref={inputRef}
type="search"
className="join-item w-full min-w-0"
placeholder={auths.length === 0 ? "No auth profiles yet" : "Add auth…"}
value={query}
disabled={disabled || auths.length === 0}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
if (e.key === "Enter") {
e.preventDefault();
if (available[0]) addAuth(available[0].id);
}
}}
/>
<button
type="button"
className="btn btn-sm join-item btn-square"
disabled={disabled || auths.length === 0}
aria-label="Open auth list"
onClick={() => {
setOpen((v) => !v);
if (!open) inputRef.current?.focus();
}}
>
<LuChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</div>
{open && !disabled && auths.length > 0 ? (
<ul className="menu menu-sm absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{available.length === 0 ? (
<li className="disabled">
<span className="opacity-60">
{query.trim() ? "No matches" : "All profiles selected"}
</span>
</li>
) : (
available.map((a) => (
<li key={a.id}>
<button type="button" onClick={() => addAuth(a.id)}>
<span className="font-mono text-sm">{a.name}</span>
<span className="opacity-60 text-xs">{a.type}</span>
</button>
</li>
))
)}
</ul>
) : null}
</div>
{inlineCount > 0 ? (
<span className="text-xs opacity-60">
+ {inlineCount} inline auth{inlineCount === 1 ? "" : "s"} (edit in YAML)
</span>
) : null}
</div>
);
}
function CronFields({ trigger, disabled, onChange, alertDestinations }) {
const preset = matchCronPreset(trigger.schedule);
@@ -5,7 +5,7 @@ import {
useRevertWorkflowRevision,
useWorkflowRevisions,
} from "../../api/hooks.js";
import { formatTime } from "../../lib/format.jsx";
import { formatTime } from "../../lib/format";
import { useNotifications } from "../../notifications.jsx";
import {
SaveWorkflowWarningsDialog,
@@ -2,7 +2,7 @@ import { useState } from "react";
import { LuHistory } from "react-icons/lu";
import { errorMessage } from "../../api/client.js";
import { useRevertWorkflowRevision } from "../../api/hooks.js";
import { formatTime } from "../../lib/format.jsx";
import { formatTime } from "../../lib/format";
import { useNotifications } from "../../notifications.jsx";
import { ConfirmDialog } from "../ConfirmDialog.jsx";
import {
@@ -4,7 +4,7 @@ import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
import { api, errorMessage } from "../../api/client.js";
import { useRunWorkflow } from "../../api/hooks.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { StatusBadge } from "../../lib/format.jsx";
import { StatusBadge } from "../../lib/format";
import { prettyJson } from "../../lib/script.js";
import {
overlayWorkflowTestData,
@@ -10,7 +10,7 @@ import { TriggersTab } from "./TriggersTab.jsx";
import { YamlTab } from "./YamlTab.jsx";
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
export function useYamlPreview(content) {
function useYamlPreview(content) {
return useMemo(() => {
try {
const parsedYaml = parseYaml(content);
+2
View File
@@ -0,0 +1,2 @@
export { formatBytes, formatCpu, formatDuration } from "./numbers.js";
export { formatTime, StatusBadge, WorkflowStatusBadge, levelName } from "./badges.jsx";
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { AuthEditorModal } from "../components/AuthEditorModal.jsx";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
function CredDisplay({ field, fieldKey, authId, cache, onRevealed }) {
const [open, setOpen] = useState(false);
+1 -1
View File
@@ -4,7 +4,7 @@ import { LuArrowLeft, LuFilter } from "react-icons/lu";
import { useRun } from "../api/hooks.js";
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
import { LogViewer } from "../components/LogViewer.jsx";
import { formatTime, StatusBadge } from "../lib/format.jsx";
import { formatTime, StatusBadge } from "../lib/format";
function stepLabel(s) {
if (s.script === "set") return "set";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link, useSearchParams } from "react-router-dom";
import { useOwners, useRuns } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format.jsx";
import { formatTime, StatusBadge } from "../lib/format";
const PAGE_SIZES = [25, 50, 100];
const DEFAULT_LIMIT = 50;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { useConsecutiveFailures } from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
export function FailuresPage() {
const { data, isLoading, error } = useConsecutiveFailures();
+1 -1
View File
@@ -6,7 +6,7 @@ import {
LuTriangleAlert,
} from "react-icons/lu";
import { useDashboard } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format.jsx";
import { formatTime, StatusBadge } from "../lib/format";
export function HomePage() {
const { data, isLoading, error } = useDashboard();
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useKv, useKvNamespaces } from "../api/hooks.js";
import { FormSelect } from "../components/FormControls.jsx";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
const PAGE_SIZE = 50;
+1 -1
View File
@@ -8,7 +8,7 @@ import { Modal } from "../components/Modal.jsx";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
function previewConfig(config) {
if (!config || typeof config !== "object") return "";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
useUpsertHttpPage,
} from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
const emptyForm = {
name: "",
+1 -1
View File
@@ -12,7 +12,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx";
import { LogViewer } from "../components/LogViewer.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
import { StatusBadge } from "../lib/format.jsx";
import { StatusBadge } from "../lib/format";
import {
DEFAULT_INPUT_CONTEXT,
NEW_SCRIPT_TEMPLATE,
+1 -1
View File
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
export function SecretsPage() {
const navigate = useNavigate();
+1 -1
View File
@@ -7,7 +7,7 @@ import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
function displayValue(value) {
if (typeof value === "string") return value === "" ? '""' : value;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
useWorkflowTrash,
} from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { formatTime } from "../lib/format.jsx";
import { formatTime } from "../lib/format";
import { useNotifications } from "../notifications.jsx";
function formatAge(ms) {
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format";
export function WorkflowsPage() {
const navigate = useNavigate();