feat(server): implement KV store API and fingerprinting functionality

- Added a new KV store API with endpoints for querying namespaces and key-value pairs.
- Introduced fingerprinting functionality to hash and manage data fingerprints, allowing for efficient change detection.
- Enhanced existing scripts to utilize the new fingerprinting capabilities, enabling conditional execution based on data changes.
- Updated the web interface to include a dedicated KV page for managing key-value entries and displaying their details.
- Improved overall user experience with pagination and search capabilities in the KV management interface.
This commit is contained in:
2026-08-15 06:00:30 +07:00
parent c5015bb391
commit 951e6f8371
15 changed files with 823 additions and 18 deletions
+2
View File
@@ -12,6 +12,7 @@ import { WorkflowsPage } from "./pages/WorkflowsPage.jsx";
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { KvPage } from "./pages/KvPage.jsx";
import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx";
@@ -57,6 +58,7 @@ export function App() {
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/kv" element={<KvPage />} />
{user.role === "admin" ? (
<>
<Route path="/users" element={<UsersPage />} />
+22
View File
@@ -313,3 +313,25 @@ export function useDeleteSecret() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
});
}
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;
},
});
}
+2
View File
@@ -2,6 +2,7 @@ import { NavLink, useNavigate } from "react-router-dom";
import {
LuActivity,
LuCode,
LuDatabase,
LuGitBranch,
LuHouse,
LuKey,
@@ -19,6 +20,7 @@ const links = [
{ to: "/scripts", label: "Scripts", icon: LuCode },
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
{ to: "/events", label: "Events", icon: LuActivity },
{ to: "/kv", label: "KV", icon: LuDatabase },
];
export function Layout({ user, children }) {
+8 -2
View File
@@ -98,7 +98,7 @@ export function EventDetailPage() {
<th>Script</th>
<th>Status</th>
<th>Duration</th>
<th>Error</th>
<th>Detail</th>
<th></th>
</tr>
</thead>
@@ -119,7 +119,13 @@ export function EventDetailPage() {
<StatusBadge status={s.status} />
</td>
<td>{s.duration_ms != null ? `${s.duration_ms}ms` : "—"}</td>
<td className="text-error text-xs">{s.error || ""}</td>
<td
className={
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
}
>
{s.error || ""}
</td>
<td className="text-right">
<button
type="button"
+143
View File
@@ -0,0 +1,143 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useKv, useKvNamespaces } from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
const PAGE_SIZE = 50;
function previewValue(value) {
const json = JSON.stringify(value);
if (json == null) return "—";
if (json.length <= 120) return json;
return `${json.slice(0, 120)}…`;
}
export function KvPage() {
const [params, setParams] = useSearchParams();
const namespace = params.get("namespace") || "";
const q = params.get("q") || "";
const offset = Math.max(Number(params.get("offset")) || 0, 0);
const [expanded, setExpanded] = useState(null);
const { data: namespaces = [] } = useKvNamespaces();
const { data, isLoading } = useKv({
namespace: namespace || undefined,
q: q || undefined,
limit: PAGE_SIZE,
offset,
});
const items = data?.items ?? [];
const total = data?.total ?? 0;
const limit = data?.limit ?? PAGE_SIZE;
function update(key, value, resetOffset = true) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
if (resetOffset && key !== "offset") next.delete("offset");
setParams(next);
setExpanded(null);
}
function rowId(item) {
return `${item.namespace}\0${item.key}`;
}
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">KV</h1>
<div className="flex flex-col sm:flex-row gap-2">
<select
className="select select-sm w-full sm:max-w-xs"
value={namespace}
onChange={(e) => update("namespace", e.target.value)}
>
<option value="">all namespaces</option>
{namespaces.map((ns) => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
<input
className="input input-sm w-full sm:max-w-sm"
placeholder="search key or value"
value={q}
onChange={(e) => update("q", e.target.value)}
/>
</div>
{isLoading ? (
<span className="loading loading-spinner" />
) : items.length === 0 ? (
<p className="text-sm opacity-60">No KV entries.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Namespace</th>
<th>Key</th>
<th>Value</th>
<th>Updated</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const id = rowId(item);
const open = expanded === id;
return (
<tr
key={id}
className="hover cursor-pointer"
onClick={() => setExpanded(open ? null : id)}
>
<td className="font-mono text-xs align-top">{item.namespace}</td>
<td className="font-mono text-xs align-top">{item.key}</td>
<td className="align-top">
{open ? (
<pre className="text-xs whitespace-pre-wrap break-all max-w-xl">
{JSON.stringify(item.value, null, 2)}
</pre>
) : (
<span className="font-mono text-xs">{previewValue(item.value)}</span>
)}
</td>
<td className="whitespace-nowrap align-top">{formatTime(item.updatedAt)}</td>
<td className="whitespace-nowrap align-top">
{item.expiresAt ? formatTime(item.expiresAt) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
className="btn btn-sm"
disabled={offset <= 0}
onClick={() => update("offset", String(Math.max(offset - limit, 0)), false)}
>
Prev
</button>
<button
type="button"
className="btn btn-sm"
disabled={offset + items.length >= total}
onClick={() => update("offset", String(offset + limit), false)}
>
Next
</button>
<span className="opacity-60">
{total === 0 ? "0" : `${offset + 1}–${offset + items.length}`} of {total}
</span>
</div>
</>
)}
</div>
);
}