diff --git a/libs/data-access/admin-api/src/lib/api/hooks.ts b/libs/data-access/admin-api/src/lib/api/hooks.ts index 25120438..98e86ec9 100644 --- a/libs/data-access/admin-api/src/lib/api/hooks.ts +++ b/libs/data-access/admin-api/src/lib/api/hooks.ts @@ -333,6 +333,59 @@ export function useGetVirtualObjectQueue( }; } +export function useGetVirtualObjectState( + serviceName: string, + key: string, + options?: HookQueryOptions<'/query/services/{name}/keys/{key}/state', 'get'> +) { + const baseUrl = useAdminBaseUrl(); + const queryOptions = adminApi( + 'query', + '/query/services/{name}/keys/{key}/state', + 'get', + { + baseUrl, + parameters: { path: { key, name: serviceName } }, + } + ); + + const results = useQuery({ + ...queryOptions, + ...options, + }); + + return { + ...results, + queryKey: queryOptions.queryKey, + }; +} + +export function useGetVirtualObjectStateInterface( + serviceName: string, + options?: HookQueryOptions<'/query/services/{name}/state', 'get'> +) { + const baseUrl = useAdminBaseUrl(); + const queryOptions = adminApi( + 'query', + '/query/services/{name}/state', + 'get', + { + baseUrl, + parameters: { path: { name: serviceName } }, + } + ); + + const results = useQuery({ + ...queryOptions, + ...options, + }); + + return { + ...results, + queryKey: queryOptions.queryKey, + }; +} + export function useDeleteInvocation( invocation_id: string, options?: HookMutationOptions<'/invocations/{invocation_id}', 'delete'> diff --git a/libs/data-access/admin-api/src/lib/api/index.d.ts b/libs/data-access/admin-api/src/lib/api/index.d.ts index 76e9e98d..2ad3d6df 100644 --- a/libs/data-access/admin-api/src/lib/api/index.d.ts +++ b/libs/data-access/admin-api/src/lib/api/index.d.ts @@ -401,6 +401,46 @@ export interface paths { patch?: never; trace?: never; }; + '/query/services/{name}/state': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get state keys + * @description Get state keys + */ + get: operations['get_state_keys']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/query/services/{name}/keys/{key}/state': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get state keys + * @description Get state keys + */ + get: operations['get_state']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -790,6 +830,17 @@ export interface components { */ services: components['schemas']['ServiceMetadata'][]; }; + StateInterfaceResponse: { + keys?: { + name: string; + }[]; + }; + StateResponse: { + state?: { + name: string; + value: string; + }[]; + }; JournalEntry: components['schemas']['JournalBaseEntry'] & ( | components['schemas']['InputJournalEntryType'] @@ -2606,4 +2657,146 @@ export interface operations { }; }; }; + get_state_keys: { + parameters: { + query?: never; + header?: never; + path: { + /** @description service name */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['StateInterfaceResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; + get_state: { + parameters: { + query?: never; + header?: never; + path: { + /** @description service name */ + name: string; + /** @description key */ + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['StateResponse']; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorDescriptionResponse']; + }; + }; + }; + }; } diff --git a/libs/data-access/admin-api/src/lib/api/output.json b/libs/data-access/admin-api/src/lib/api/output.json index 0de5001e..b8d728b1 100644 --- a/libs/data-access/admin-api/src/lib/api/output.json +++ b/libs/data-access/admin-api/src/lib/api/output.json @@ -1883,6 +1883,197 @@ } } } + }, + "/query/services/{name}/state": { + "get": { + "tags": ["query-invocations"], + "summary": "Get state keys", + "description": "Get state keys", + "operationId": "get_state_keys", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "service name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StateInterfaceResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/query/services/{name}/keys/{key}/state": { + "get": { + "tags": ["query-invocations"], + "summary": "Get state keys", + "description": "Get state keys", + "operationId": "get_state", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "service name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", + "in": "path", + "description": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StateResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } } }, "components": { @@ -2534,6 +2725,43 @@ } ] }, + "StateInterfaceResponse": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "StateResponse": { + "type": "object", + "properties": { + "state": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "value"], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + }, "JournalEntry": { "allOf": [ { diff --git a/libs/data-access/admin-api/src/lib/api/query.json b/libs/data-access/admin-api/src/lib/api/query.json index 514a0ab3..74e90c4c 100644 --- a/libs/data-access/admin-api/src/lib/api/query.json +++ b/libs/data-access/admin-api/src/lib/api/query.json @@ -583,10 +583,238 @@ } } } + }, + "/query/services/{name}/state": { + "get": { + "tags": ["query-invocations"], + "summary": "Get state keys", + "description": "Get state keys", + "operationId": "get_state_keys", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "service name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StateInterfaceResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } + }, + "/query/services/{name}/keys/{key}/state": { + "get": { + "tags": ["query-invocations"], + "summary": "Get state keys", + "description": "Get state keys", + "operationId": "get_state", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "service name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", + "in": "path", + "description": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StateResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + }, + "503": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorDescriptionResponse" + } + } + } + } + } + } } }, "components": { "schemas": { + "StateInterfaceResponse": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "StateResponse": { + "type": "object", + "properties": { + "state": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "value"], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + }, "JournalEntry": { "allOf": [ { diff --git a/libs/data-access/middleware-service-worker/src/lib/query.ts b/libs/data-access/middleware-service-worker/src/lib/query.ts index f60a7792..07d8885e 100644 --- a/libs/data-access/middleware-service-worker/src/lib/query.ts +++ b/libs/data-access/middleware-service-worker/src/lib/query.ts @@ -140,6 +140,32 @@ async function getInbox( }); } +async function getState(service: string, key: string, baseUrl: string) { + const state: { name: string; value: string }[] = await query( + `SELECT key, value_utf8 FROM state WHERE service_name = '${service}' AND service_key = '${key}'`, + { baseUrl } + ).then(({ rows }) => + rows.map((row) => ({ name: row.key, value: row.value_utf8 })) + ); + + return new Response(JSON.stringify({ state }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +async function getStateInterface(service: string, baseUrl: string) { + const keys: { name: string }[] = await query( + `SELECT DISTINCT key FROM state WHERE service_name = '${service}' GROUP BY key`, + { baseUrl } + ).then(({ rows }) => rows.map((row) => ({ name: row.key }))); + + return new Response(JSON.stringify({ keys }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + export function queryMiddlerWare(req: Request) { const { url, method } = req; const urlObj = new URL(url); @@ -185,4 +211,30 @@ export function queryMiddlerWare(req: Request) { baseUrl ); } + + const getStateParams = + match<{ key: string; name: string }>( + '/query/services/:name/keys/:key/state' + )(urlObj.pathname) || + match<{ key: string; name: string }>('/query/services/:name/keys//state')( + urlObj.pathname + ); + + if (getStateParams && method.toUpperCase() === 'GET') { + const baseUrl = `${urlObj.protocol}//${urlObj.host}`; + return getState( + getStateParams.params.name, + getStateParams.params.key ?? '', + baseUrl + ); + } + + const getStateInterfaceParams = match<{ key: string; name: string }>( + '/query/services/:name/state' + )(urlObj.pathname); + + if (getStateInterfaceParams && method.toUpperCase() === 'GET') { + const baseUrl = `${urlObj.protocol}//${urlObj.host}`; + return getStateInterface(getStateInterfaceParams.params.name, baseUrl); + } } diff --git a/libs/features/invocation-route/src/lib/InvocationPanel.tsx b/libs/features/invocation-route/src/lib/InvocationPanel.tsx index 6f76e302..e4a443bb 100644 --- a/libs/features/invocation-route/src/lib/InvocationPanel.tsx +++ b/libs/features/invocation-route/src/lib/InvocationPanel.tsx @@ -9,6 +9,7 @@ import { useGetInvocation, useGetInvocationJournal, useGetVirtualObjectQueue, + useGetVirtualObjectState, } from '@restate/data-access/admin-api'; import { Icon, IconName } from '@restate/ui/icons'; import { ServiceHandlerSection } from './ServiceHandlerSection'; @@ -91,6 +92,13 @@ export function InvocationPanel() { enabled: false, } ); + const { queryKey: stateQuery } = useGetVirtualObjectState( + String(data?.target_service_name), + String(data?.target_service_key), + { + enabled: false, + } + ); const queryClient = useQueryClient(); @@ -125,6 +133,12 @@ export function InvocationPanel() { queryKey: journalQueryKey, exact: true, }); + if (data?.target_service_ty === 'virtual_object') { + queryClient.refetchQueries({ + queryKey: stateQuery, + exact: true, + }); + } }} isPending={isPending} > diff --git a/libs/features/invocation-route/src/lib/Journal.tsx b/libs/features/invocation-route/src/lib/Journal.tsx index d00e547a..321c265c 100644 --- a/libs/features/invocation-route/src/lib/Journal.tsx +++ b/libs/features/invocation-route/src/lib/Journal.tsx @@ -157,10 +157,10 @@ const defaultEntryStyles = tv({ base: '', slots: { entryItem: - 'flex items-center w-full gap-1 flex-wrap w-full min-w-0 mb-2 pl-2 bg-zinc-50 border-zinc-600/10 border rounded-none py-1 font-mono [font-size:90%] rounded -mt-px', + 'flex items-center w-full gap-1 flex-wrap w-full min-w-0 mb-2 pl-2 bg-zinc-50 border-zinc-600/10 border rounded-none py-1 font-mono [font-size:95%] rounded -mt-px', circle: 'w-3 h-3 rounded-full shrink-0 bg-zinc-100 border border-zinc-200 shadow-sm absolute left-0 top-[0.8625rem] -translate-y-1/2', - line: 'absolute group-first:top-[0.5625rem] group-last:bottom-[calc(100%-0.5625rem+1px)] border-l left-[0.35rem] top-0 bottom-0', + line: 'absolute group-first:top-[0.5625rem] group-last:bottom-[calc(100%-0.5625rem+1px)] border-l left-[calc(0.35rem+0.5px)] top-0 bottom-0', }, variants: { appended: { diff --git a/libs/features/invocation-route/src/lib/State.tsx b/libs/features/invocation-route/src/lib/State.tsx new file mode 100644 index 00000000..80733e2e --- /dev/null +++ b/libs/features/invocation-route/src/lib/State.tsx @@ -0,0 +1,35 @@ +import { TruncateWithTooltip } from '@restate/ui/tooltip'; +import { Value } from './Value'; + +export function State({ + state = [], +}: { + state?: { name: string; value: string }[]; +}) { + return ( + <> +
+
Key
+
Value
+
+
+ {state.map(({ name, value }) => ( + + ))} +
+ + ); +} + +function StateKey({ name, value }: { name: string; value: string }) { + return ( +
+
+ {name} +
+
+ +
+
+ ); +} diff --git a/libs/features/invocation-route/src/lib/VirtualObjectSection.tsx b/libs/features/invocation-route/src/lib/VirtualObjectSection.tsx index 044c5aa4..64c83f64 100644 --- a/libs/features/invocation-route/src/lib/VirtualObjectSection.tsx +++ b/libs/features/invocation-route/src/lib/VirtualObjectSection.tsx @@ -1,6 +1,7 @@ import { Invocation, useGetVirtualObjectQueue, + useGetVirtualObjectState, } from '@restate/data-access/admin-api'; import { Section, SectionContent, SectionTitle } from '@restate/ui/section'; import { tv } from 'tailwind-variants'; @@ -16,6 +17,10 @@ import { TooltipTrigger, } from '@restate/ui/tooltip'; import { formatNumber, formatOrdinals } from '@restate/util/intl'; +import { Popover, PopoverContent, PopoverTrigger } from '@restate/ui/popover'; +import { Button } from '@restate/ui/button'; +import { DropdownSection } from '@restate/ui/dropdown'; +import { State } from './State'; const styles = tv({ base: '' }); export function VirtualObjectSection({ @@ -41,6 +46,18 @@ export function VirtualObjectSection({ staleTime: 0, } ); + const { data: stateData } = useGetVirtualObjectState( + String(invocation?.target_service_name), + String(invocation?.target_service_key), + { + enabled: Boolean( + typeof invocation?.target_service_key === 'string' && + invocation && + invocation.target_service_ty === 'virtual_object' + ), + staleTime: 0, + } + ); if ( !data || @@ -56,14 +73,14 @@ export function VirtualObjectSection({ typeof position === 'number' && typeof size === 'number' && typeof head === 'string'; + const hasState = Boolean(stateData?.state && stateData?.state.length > 0); return (
{invocation.target_service_name} -
- - Key - +
+ Key + {!hasState &&
} + {hasState && ( + + + + + + + + + + + )}
{shouldShowQueue && ( diff --git a/libs/features/invocation-route/src/lib/entries/Call.tsx b/libs/features/invocation-route/src/lib/entries/Call.tsx index de0b10a8..a3712b13 100644 --- a/libs/features/invocation-route/src/lib/entries/Call.tsx +++ b/libs/features/invocation-route/src/lib/entries/Call.tsx @@ -47,7 +47,7 @@ export function Call({ {entry.headers && entry.headers.length > 0 && ( } /> diff --git a/libs/features/invocation-route/src/lib/entries/Input.tsx b/libs/features/invocation-route/src/lib/entries/Input.tsx index 41dce249..b53ee2d7 100644 --- a/libs/features/invocation-route/src/lib/entries/Input.tsx +++ b/libs/features/invocation-route/src/lib/entries/Input.tsx @@ -34,8 +34,8 @@ export function Input({ {entry.headers && ( } /> )} diff --git a/libs/features/invocation-route/src/lib/entries/OneWayCall.tsx b/libs/features/invocation-route/src/lib/entries/OneWayCall.tsx index 55cadcf2..e223b7b0 100644 --- a/libs/features/invocation-route/src/lib/entries/OneWayCall.tsx +++ b/libs/features/invocation-route/src/lib/entries/OneWayCall.tsx @@ -54,7 +54,7 @@ export function OneWayCall({ {entry.headers && entry.headers.length > 0 && ( } /> diff --git a/package.json b/package.json index e823d5fc..a4d11751 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@restate/web-ui", - "version": "0.0.12", + "version": "0.0.13", "license": "MIT", "scripts": {}, "private": true,