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:
@@ -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 />} />
|
||||
|
||||
@@ -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,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 }) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user