Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fast-shrimps-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudflare/local-explorer-ui": minor
"miniflare": minor
---

Add ability to search KV keys by prefix

The UI and list keys API now lets you search KV keys by prefix.

This is an experimental WIP feature.
2 changes: 1 addition & 1 deletion packages/local-explorer-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm openapi-ts && vite build && prettier --write src/routeTree.gen.ts",
"build": "openapi-ts && vite build",
"check:lint": "eslint src --max-warnings=0",
"check:type": "tsc --build",
"deploy": "echo 'no deploy'",
Expand Down
40 changes: 40 additions & 0 deletions packages/local-explorer-ui/src/components/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Button } from "@base-ui/react/button";
import { useState } from "react";

interface SearchFormProps {
onSearch: (prefix: string) => void;
disabled?: boolean;
}

export function SearchForm({ onSearch, disabled = false }: SearchFormProps) {
const [prefix, setPrefix] = useState("");

const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
onSearch(prefix);
};

return (
<form className="flex gap-2 items-center" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="search-prefix">
Search keys by prefix
</label>
<input
id="search-prefix"
className="flex-1 max-w-[400px] font-mono bg-bg text-text py-2 px-3 text-sm border border-border rounded-md focus:outline-none focus:border-primary focus:shadow-[0_0_0_3px_rgba(255,72,1,0.15)] disabled:bg-bg-secondary disabled:text-text-secondary"
placeholder="Search keys by prefix..."
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
disabled={disabled}
/>
<Button
type="submit"
className="btn shrink-0 inline-flex items-center justify-center py-2 px-4 text-sm font-medium rounded-md cursor-pointer transition-[background-color,transform] active:translate-y-px bg-bg-tertiary text-text border border-border hover:bg-border"
disabled={disabled}
focusableWhenDisabled
>
Search
</Button>
</form>
);
}
96 changes: 48 additions & 48 deletions packages/local-explorer-ui/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,70 +8,70 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as KvNamespaceIdRouteImport } from "./routes/kv/$namespaceId";
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as KvNamespaceIdRouteImport } from './routes/kv/$namespaceId'

const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const KvNamespaceIdRoute = KvNamespaceIdRouteImport.update({
id: "/kv/$namespaceId",
path: "/kv/$namespaceId",
getParentRoute: () => rootRouteImport,
} as any);
id: '/kv/$namespaceId',
path: '/kv/$namespaceId',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/kv/$namespaceId": typeof KvNamespaceIdRoute;
'/': typeof IndexRoute
'/kv/$namespaceId': typeof KvNamespaceIdRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/kv/$namespaceId": typeof KvNamespaceIdRoute;
'/': typeof IndexRoute
'/kv/$namespaceId': typeof KvNamespaceIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/kv/$namespaceId": typeof KvNamespaceIdRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/kv/$namespaceId': typeof KvNamespaceIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/kv/$namespaceId";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/kv/$namespaceId";
id: "__root__" | "/" | "/kv/$namespaceId";
fileRoutesById: FileRoutesById;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/kv/$namespaceId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/kv/$namespaceId'
id: '__root__' | '/' | '/kv/$namespaceId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
KvNamespaceIdRoute: typeof KvNamespaceIdRoute;
IndexRoute: typeof IndexRoute
KvNamespaceIdRoute: typeof KvNamespaceIdRoute
}

declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/kv/$namespaceId": {
id: "/kv/$namespaceId";
path: "/kv/$namespaceId";
fullPath: "/kv/$namespaceId";
preLoaderRoute: typeof KvNamespaceIdRouteImport;
parentRoute: typeof rootRouteImport;
};
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/kv/$namespaceId': {
id: '/kv/$namespaceId'
path: '/kv/$namespaceId'
fullPath: '/kv/$namespaceId'
preLoaderRoute: typeof KvNamespaceIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
KvNamespaceIdRoute: KvNamespaceIdRoute,
};
IndexRoute: IndexRoute,
KvNamespaceIdRoute: KvNamespaceIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
41 changes: 33 additions & 8 deletions packages/local-explorer-ui/src/routes/kv/$namespaceId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import KVIcon from "../../assets/icons/kv.svg?react";
import { AddKVForm } from "../../components/AddKVForm";
import { KVTable } from "../../components/KVTable";
import { SearchForm } from "../../components/SearchForm";
import type { KVEntry } from "../../api";

export const Route = createFileRoute("/kv/$namespaceId")({
Expand Down Expand Up @@ -60,6 +61,8 @@ function NamespaceView() {
const [overwriting, setOverwriting] = useState(false);
// Signal to clear AddKVForm after successful overwrite
const [clearAddForm, setClearAddForm] = useState(0);
// Search prefix filter
const [prefix, setPrefix] = useState<string | undefined>(undefined);

const fetchEntries = useCallback(
async (nextCursor?: string) => {
Expand All @@ -74,7 +77,7 @@ function NamespaceView() {

const keysResponse = await workersKvNamespaceListANamespace_SKeys({
path: { namespace_id: namespaceId },
query: { cursor: nextCursor, limit: 50 },
query: { cursor: nextCursor, limit: 50, prefix },
});
const keys = keysResponse.data?.result ?? [];

Expand Down Expand Up @@ -114,7 +117,7 @@ function NamespaceView() {
setLoadingMore(false);
}
},
[namespaceId]
[namespaceId, prefix]
);

useEffect(() => {
Expand All @@ -125,6 +128,7 @@ function NamespaceView() {
setDeleteTarget(null);
setOverwriteConfirm(null);
setError(null);
setPrefix(undefined);
}, [namespaceId]);

const handleLoadMore = () => {
Expand All @@ -133,6 +137,11 @@ function NamespaceView() {
}
};

const handleSearch = (searchPrefix: string) => {
// Set prefix to undefined if empty string to fetch all keys
setPrefix(searchPrefix || undefined);
};

const checkKeyExists = async (key: string): Promise<boolean> => {
try {
const response = await workersKvNamespaceReadKeyValuePair({
Expand Down Expand Up @@ -312,22 +321,38 @@ function NamespaceView() {
<span className="flex items-center gap-1.5">{namespaceId}</span>
</div>

<AddKVForm onAdd={handleAdd} clearSignal={clearAddForm} />

{error && (
<div className="text-danger p-4 bg-danger/8 border border-danger/20 rounded-md mb-4">
{error}
</div>
)}

<SearchForm
key={namespaceId}
onSearch={handleSearch}
disabled={loading}
/>

<hr className="border-border my-4" />

<AddKVForm onAdd={handleAdd} clearSignal={clearAddForm} />

{loading ? (
<div className="text-center p-12 text-text-secondary">Loading...</div>
) : entries.length === 0 ? (
<div className="text-center p-12 text-text-secondary space-y-2 flex flex-col items-center justify-center">
<h2 className="text-2xl font-medium">No keys in this namespace</h2>
<p className="text-sm font-light">
Add an entry using the form above.
</p>
{prefix ? (
<p className="text-sm font-light">{`No keys found matching prefix "${prefix}".`}</p>
) : (
<>
<h2 className="text-2xl font-medium">
No keys in this namespace
</h2>
<p className="text-sm font-light">
Add an entry using the form above.
</p>
</>
)}
</div>
) : (
<>
Expand Down
6 changes: 0 additions & 6 deletions packages/miniflare/scripts/openapi-filter-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,6 @@ const config = {
ignores: {
// Query/path parameters not implemented
parameters: [
// List keys - prefix filtering not implemented
{
path: "/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/keys",
method: "get",
name: "prefix",
},
// Put value - expiration options not implemented
{
path: "/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/{key_name}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ export type WorkersKvNamespaceListANamespaceSKeysData = {
* Limits the number of keys returned in the response. The cursor attribute may be used to iterate over the next batch of keys if there are more than the limit.
*/
limit?: number;
/**
* Filters returned keys by a name prefix. Exact matches and any key names that begin with the prefix will be returned.
*/
prefix?: string;
/**
* Opaque token indicating the position from which to continue when requesting the next set of records if the amount of list results was limited by the limit parameter. A valid value for the cursor can be obtained from the `cursors` object in the `result_info` structure.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export const zWorkersKvNamespaceListANamespaceSKeysData = z.object({
query: z
.object({
limit: z.number().gte(10).lte(1000).optional().default(1000),
prefix: z.string().optional(),
cursor: z.string().optional(),
})
.optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@
"type": "number"
}
},
{
"in": "query",
"name": "prefix",
"schema": {
"description": "Filters returned keys by a name prefix. Exact matches and any key names that begin with the prefix will be returned.",
"example": "My-Prefix",
"type": "string"
}
},
{
"in": "query",
"name": "cursor",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,14 @@ export async function listKVKeys(c: AppContext, query: ListKeysQuery) {
const namespace_id = c.req.param("namespace_id");
const cursor = query.cursor;
const limit = query.limit;
const prefix = query.prefix;

const kv = getKVBinding(c.env, namespace_id);
if (!kv) {
return errorResponse(404, 10000, "Namespace not found");
}

const listResult = await kv.list({ cursor, limit });
const listResult = await kv.list({ cursor, limit, prefix });
const resultCursor = "cursor" in listResult ? listResult.cursor ?? "" : "";

return c.json({
Expand Down
Loading
Loading