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
+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>
);
}