Files
jerapah-flow/packages/web/src/pages/KvPage.jsx
T
nsrb 951e6f8371 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.
2026-08-15 06:00:30 +07:00

144 lines
4.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}