- 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.
144 lines
4.7 KiB
React
144 lines
4.7 KiB
React
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>
|
||
);
|
||
}
|