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 5bbd85ee..04dcd375 100644 --- a/libs/data-access/admin-api/src/lib/api/hooks.ts +++ b/libs/data-access/admin-api/src/lib/api/hooks.ts @@ -9,7 +9,13 @@ import { SupportedMethods, } from './client'; import { useAdminBaseUrl } from '../AdminBaseUrlProvider'; -import type { DeploymentId, Revision, ServiceName, Deployment } from './type'; +import type { + DeploymentId, + Revision, + ServiceName, + Deployment, + FilterItem, +} from './type'; type HookQueryOptions< Path extends keyof paths, @@ -232,11 +238,15 @@ export function useServiceOpenApi( } export function useListInvocations( - options?: HookQueryOptions<'/query/invocations', 'get'> + filters?: FilterItem[], + options?: HookQueryOptions<'/query/invocations', 'post'> ) { const baseUrl = useAdminBaseUrl(); - const queryOptions = adminApi('query', '/query/invocations', 'get', { + const queryOptions = adminApi('query', '/query/invocations', 'post', { baseUrl, + body: { + filters, + }, }); const results = useQuery({ 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 2ad3d6df..e2561daf 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 @@ -328,13 +328,13 @@ export interface paths { path?: never; cookie?: never; }; + get?: never; + put?: never; /** * List invocations * @description List invocations */ - get: operations['list_invocations']; - put?: never; - post?: never; + post: operations['list_invocations']; delete?: never; options?: never; head?: never; @@ -830,6 +830,50 @@ export interface components { */ services: components['schemas']['ServiceMetadata'][]; }; + ListInvocationsRequestBody: { + filters?: components['schemas']['FilterItem'][]; + }; + FilterItem: components['schemas']['FilterBaseItem'] & + ( + | components['schemas']['FilterNumberItem'] + | components['schemas']['FilterStringItem'] + | components['schemas']['FilterDateItem'] + | components['schemas']['FilterStringListItem'] + ); + FilterBaseItem: { + /** @enum {string} */ + type: 'STRING' | 'NUMBER' | 'DATE' | 'STRING_LIST'; + field: string; + }; + FilterNumberItem: { + /** @enum {string} */ + type: 'NUMBER'; + /** @enum {string} */ + operation: 'EQUALS' | 'NOT_EQUALS' | 'GREATER_THAN' | 'LESS_THAN'; + value?: number; + }; + FilterStringItem: { + /** @enum {string} */ + type: 'STRING'; + /** @enum {string} */ + operation: 'EQUALS' | 'NOT_EQUALS' | 'CONTAINS'; + value?: string; + }; + FilterStringListItem: { + /** @enum {string} */ + type: 'STRING_LIST'; + /** @enum {string} */ + operation: 'IN' | 'NOT_IN'; + value: string[]; + }; + FilterDateItem: { + /** @enum {string} */ + type: 'DATE'; + /** @enum {string} */ + operation: 'BEFORE' | 'AFTER'; + /** Format: date-time */ + value: string; + }; StateInterfaceResponse: { keys?: { name: string; @@ -2376,7 +2420,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ListInvocationsRequestBody']; + }; + }; responses: { 200: { headers: { 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 b8d728b1..79651779 100644 --- a/libs/data-access/admin-api/src/lib/api/output.json +++ b/libs/data-access/admin-api/src/lib/api/output.json @@ -1491,11 +1491,21 @@ } }, "/query/invocations": { - "get": { + "post": { "tags": ["query-invocations"], "summary": "List invocations", "description": "List invocations", "operationId": "list_invocations", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListInvocationsRequestBody" + } + } + }, + "required": true + }, "responses": { "200": { "description": "", @@ -2725,6 +2735,125 @@ } ] }, + "ListInvocationsRequestBody": { + "type": "object", + "properties": { + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterItem" + } + } + } + }, + "FilterItem": { + "allOf": [ + { + "$ref": "#/components/schemas/FilterBaseItem" + }, + { + "oneOf": [ + { + "$ref": "#/components/schemas/FilterNumberItem" + }, + { + "$ref": "#/components/schemas/FilterStringItem" + }, + { + "$ref": "#/components/schemas/FilterDateItem" + }, + { + "$ref": "#/components/schemas/FilterStringListItem" + } + ] + } + ] + }, + "FilterBaseItem": { + "type": "object", + "required": ["type", "field"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING", "NUMBER", "DATE", "STRING_LIST"] + }, + "field": { + "type": "string" + } + } + }, + "FilterNumberItem": { + "type": "object", + "required": ["type", "operation"], + "properties": { + "type": { + "type": "string", + "enum": ["NUMBER"] + }, + "operation": { + "type": "string", + "enum": ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN"] + }, + "value": { + "type": "number" + } + } + }, + "FilterStringItem": { + "type": "object", + "required": ["type", "operation"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING"] + }, + "operation": { + "type": "string", + "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS"] + }, + "value": { + "type": "string" + } + } + }, + "FilterStringListItem": { + "type": "object", + "required": ["type", "operation", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING_LIST"] + }, + "operation": { + "type": "string", + "enum": ["IN", "NOT_IN"] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "FilterDateItem": { + "type": "object", + "required": ["type", "operation", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["DATE"] + }, + "operation": { + "type": "string", + "enum": ["BEFORE", "AFTER"] + }, + "value": { + "type": "string", + "format": "date-time" + } + } + }, "StateInterfaceResponse": { "type": "object", "properties": { 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 74e90c4c..1a3b355b 100644 --- a/libs/data-access/admin-api/src/lib/api/query.json +++ b/libs/data-access/admin-api/src/lib/api/query.json @@ -191,11 +191,21 @@ } }, "/query/invocations": { - "get": { + "post": { "tags": ["query-invocations"], "summary": "List invocations", "description": "List invocations", "operationId": "list_invocations", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListInvocationsRequestBody" + } + } + }, + "required": true + }, "responses": { "200": { "description": "", @@ -778,6 +788,125 @@ }, "components": { "schemas": { + "ListInvocationsRequestBody": { + "type": "object", + "properties": { + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterItem" + } + } + } + }, + "FilterItem": { + "allOf": [ + { + "$ref": "#/components/schemas/FilterBaseItem" + }, + { + "oneOf": [ + { + "$ref": "#/components/schemas/FilterNumberItem" + }, + { + "$ref": "#/components/schemas/FilterStringItem" + }, + { + "$ref": "#/components/schemas/FilterDateItem" + }, + { + "$ref": "#/components/schemas/FilterStringListItem" + } + ] + } + ] + }, + "FilterBaseItem": { + "type": "object", + "required": ["type", "field"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING", "NUMBER", "DATE", "STRING_LIST"] + }, + "field": { + "type": "string" + } + } + }, + "FilterNumberItem": { + "type": "object", + "required": ["type", "operation"], + "properties": { + "type": { + "type": "string", + "enum": ["NUMBER"] + }, + "operation": { + "type": "string", + "enum": ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN"] + }, + "value": { + "type": "number" + } + } + }, + "FilterStringItem": { + "type": "object", + "required": ["type", "operation"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING"] + }, + "operation": { + "type": "string", + "enum": ["EQUALS", "NOT_EQUALS", "CONTAINS"] + }, + "value": { + "type": "string" + } + } + }, + "FilterStringListItem": { + "type": "object", + "required": ["type", "operation", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["STRING_LIST"] + }, + "operation": { + "type": "string", + "enum": ["IN", "NOT_IN"] + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "FilterDateItem": { + "type": "object", + "required": ["type", "operation", "value"], + "properties": { + "type": { + "type": "string", + "enum": ["DATE"] + }, + "operation": { + "type": "string", + "enum": ["BEFORE", "AFTER"] + }, + "value": { + "type": "string", + "format": "date-time" + } + } + }, "StateInterfaceResponse": { "type": "object", "properties": { diff --git a/libs/data-access/admin-api/src/lib/api/type.ts b/libs/data-access/admin-api/src/lib/api/type.ts index 2b9bf288..08dfc2f8 100644 --- a/libs/data-access/admin-api/src/lib/api/type.ts +++ b/libs/data-access/admin-api/src/lib/api/type.ts @@ -69,3 +69,9 @@ export type Invocation = components['schemas']['Invocation']; export type ServiceName = Service['name']; export type DeploymentId = Deployment['id']; export type ServiceType = Service['ty']; +export type FilterItem = components['schemas']['FilterItem']; +export type FilterDateItem = components['schemas']['FilterDateItem']; +export type FilterNumberItem = components['schemas']['FilterNumberItem']; +export type FilterStringListItem = + components['schemas']['FilterStringListItem']; +export type FilterStringItem = components['schemas']['FilterStringItem']; diff --git a/libs/data-access/middleware-service-worker/src/lib/worker.ts b/libs/data-access/middleware-service-worker/src/lib/worker.ts index c340fba2..90b87685 100644 --- a/libs/data-access/middleware-service-worker/src/lib/worker.ts +++ b/libs/data-access/middleware-service-worker/src/lib/worker.ts @@ -15,9 +15,12 @@ self.addEventListener('activate', (event) => { // You can clean up old caches here }); -self.addEventListener('fetch', (event) => { - const response = query(event.request); - if (response) { - event.respondWith(response); +self.addEventListener('fetch', async (event) => { + const url = new URL(event.request.url); + if (url.pathname.startsWith('/query')) { + const response = query(event.request); + if (response) { + event.respondWith(response); + } } }); diff --git a/libs/data-access/query/src/lib/convertFilters.ts b/libs/data-access/query/src/lib/convertFilters.ts new file mode 100644 index 00000000..350a3371 --- /dev/null +++ b/libs/data-access/query/src/lib/convertFilters.ts @@ -0,0 +1,214 @@ +import type { + FilterItem, + FilterDateItem, + FilterStringItem, + FilterNumberItem, + FilterStringListItem, +} from '@restate/data-access/admin-api/spec'; + +function convertFilterNumberToSqlClause( + filter: FilterNumberItem & Pick +) { + switch (filter.operation) { + case 'EQUALS': + return `${filter.field} = ${filter.value}`; + case 'NOT_EQUALS': + return `${filter.field} != ${filter.value}`; + case 'GREATER_THAN': + return `${filter.field} > ${filter.value}`; + case 'LESS_THAN': + return `${filter.field} < ${filter.value}`; + } +} + +function convertFilterStringToSqlClause( + filter: FilterStringItem & Pick +) { + switch (filter.operation) { + case 'EQUALS': + return `${filter.field} = '${filter.value}'`; + case 'NOT_EQUALS': + return `${filter.field} != '${filter.value}'`; + case 'CONTAINS': + return `${filter.field} LIKE '%${filter.value}%'`; + } +} + +function convertFilterDateToSqlClause( + filter: FilterDateItem & Pick +) { + switch (filter.operation) { + case 'AFTER': + return `${filter.field} > '${filter.value}'`; + case 'BEFORE': + return `${filter.field} < '${filter.value}'`; + } +} + +function convertFilterStringListToSqlClause( + filter: FilterStringListItem & Pick +) { + switch (filter.operation) { + case 'IN': + return `${filter.field} IN (${filter.value + .map((value) => `'${value}'`) + .join(', ')})`; + case 'NOT_IN': + return `${filter.field} NOT IN (${filter.value + .map((value) => `'${value}'`) + .join(', ')})`; + } +} + +function convertFilterToSqlClause(filter: FilterItem) { + switch (filter.type) { + case 'DATE': + return convertFilterDateToSqlClause(filter); + case 'STRING': + return convertFilterStringToSqlClause(filter); + case 'STRING_LIST': + return convertFilterStringListToSqlClause(filter); + case 'NUMBER': + return convertFilterNumberToSqlClause(filter); + } +} + +function getStatusFilterString(value?: string): FilterItem[] { + switch (value) { + case 'succeeded': + return [ + { + type: 'STRING', + field: 'status', + operation: 'EQUALS', + value: 'completed', + }, + { + type: 'STRING', + field: 'completion_result', + operation: 'EQUALS', + value: 'success', + }, + ]; + case 'failed': + return [ + { + type: 'STRING', + field: 'status', + operation: 'EQUALS', + value: 'completed', + }, + { + type: 'STRING', + field: 'completion_result', + operation: 'EQUALS', + value: 'failure', + }, + ]; + case 'killed': + return [ + { + type: 'STRING', + field: 'status', + operation: 'EQUALS', + value: 'completed', + }, + { + type: 'STRING', + field: 'completion_result', + operation: 'EQUALS', + value: 'failure', + }, + { + type: 'STRING', + field: 'completion_failure', + operation: 'CONTAINS', + value: 'killed', + }, + ]; + case 'cancelled': + return [ + { + type: 'STRING', + field: 'status', + operation: 'EQUALS', + value: 'completed', + }, + { + type: 'STRING', + field: 'completion_result', + operation: 'EQUALS', + value: 'failure', + }, + { + type: 'STRING', + field: 'completion_failure', + operation: 'CONTAINS', + value: '[409]', + }, + ]; + case 'retrying': + return [ + { + type: 'STRING_LIST', + field: 'status', + operation: 'IN', + value: ['running', 'backing-off'], + }, + { + type: 'NUMBER', + field: 'retry_count', + operation: 'GREATER_THAN', + value: 1, + }, + ]; + + default: + return [ + { + type: 'STRING', + field: 'status', + operation: 'EQUALS', + value, + }, + ]; + } +} + +export function convertFilters(filters: FilterItem[]) { + const statusFilter = filters.find((filter) => filter.field === 'status'); + + const mappedFilters = filters + .filter((filter) => filter.field !== 'status') + .map(convertFilterToSqlClause) + .filter(Boolean); + + if (statusFilter) { + if (statusFilter.type === 'STRING') { + mappedFilters.push( + getStatusFilterString(statusFilter.value) + .map(convertFilterToSqlClause) + .filter(Boolean) + .join(' AND ') + ); + } else if (statusFilter.type === 'STRING_LIST') { + mappedFilters.push( + `(${statusFilter.value + .map((value) => + getStatusFilterString(value) + .map(convertFilterToSqlClause) + .filter(Boolean) + .join(' AND ') + ) + .map((clause) => `(${clause})`) + .join(' OR ')})` + ); + } + } + + if (mappedFilters.length === 0) { + return ''; + } else { + return `WHERE ${mappedFilters.join(' AND ')}`; + } +} diff --git a/libs/data-access/query/src/lib/query.ts b/libs/data-access/query/src/lib/query.ts index 6ee9f35a..4aa95d45 100644 --- a/libs/data-access/query/src/lib/query.ts +++ b/libs/data-access/query/src/lib/query.ts @@ -2,6 +2,8 @@ import ky from 'ky'; import { convertInvocation } from './convertInvocation'; import { match } from 'path-to-regexp'; import { convertJournal } from './convertJournal'; +import type { FilterItem } from '@restate/data-access/admin-api/spec'; +import { convertFilters } from './convertFilters'; function queryFetcher( query: string, @@ -21,13 +23,21 @@ function queryFetcher( const INVOCATIONS_LIMIT = 500; -async function listInvocations(baseUrl: string, headers: Headers) { +async function listInvocations( + baseUrl: string, + headers: Headers, + filters: FilterItem[] +) { const totalCountPromise = queryFetcher( - 'SELECT COUNT(*) AS total_count FROM sys_invocation', + `SELECT COUNT(*) AS total_count FROM sys_invocation ${convertFilters( + filters + )}`, { baseUrl, headers } ).then(({ rows }) => rows?.at(0)?.total_count as number); const invocationsPromise = queryFetcher( - `SELECT * FROM sys_invocation ORDER BY modified_at DESC LIMIT ${INVOCATIONS_LIMIT}`, + `SELECT * FROM sys_invocation ${convertFilters( + filters + )} ORDER BY modified_at DESC LIMIT ${INVOCATIONS_LIMIT}`, { baseUrl, headers, @@ -194,13 +204,14 @@ async function getStateInterface( }); } -export function query(req: Request) { +export async function query(req: Request) { const { url, method, headers } = req; const urlObj = new URL(url); - if (url.endsWith('/query/invocations') && method.toUpperCase() === 'GET') { + if (url.endsWith('/query/invocations') && method.toUpperCase() === 'POST') { const baseUrl = `${urlObj.protocol}//${urlObj.host}`; - return listInvocations(baseUrl, headers); + const { filters = [] } = await req.json(); + return listInvocations(baseUrl, headers, filters); } const getInvocationJournalParams = match<{ invocationId: string }>( @@ -277,5 +288,5 @@ export function query(req: Request) { ); } - return null; + return new Response('Not implemented', { status: 501 }); } diff --git a/libs/features/invocations-route/src/lib/invocations.route.tsx b/libs/features/invocations-route/src/lib/invocations.route.tsx index 3dba24ed..6af7a96b 100644 --- a/libs/features/invocations-route/src/lib/invocations.route.tsx +++ b/libs/features/invocations-route/src/lib/invocations.route.tsx @@ -42,7 +42,7 @@ const COLUMN_WIDTH: Partial> = { function Component() { const { selectedColumns, setSelectedColumns, sortedColumnsList } = useColumns(); - const { refetch, queryKey, dataUpdatedAt, error } = useListInvocations({ + const { refetch, queryKey, dataUpdatedAt, error } = useListInvocations([], { refetchOnMount: false, refetchOnReconnect: false, initialData: { rows: [], total_count: 0 }, @@ -209,7 +209,7 @@ function Component() { function Footnote() { const [now, setNow] = useState(() => Date.now()); const durationSinceLastSnapshot = useDurationSinceLastSnapshot(); - const { data, isFetching } = useListInvocations({ + const { data, isFetching } = useListInvocations([], { refetchOnMount: false, refetchOnReconnect: false, initialData: { rows: [], total_count: 0 }, diff --git a/tsconfig.base.json b/tsconfig.base.json index bb5dcbb3..591dd974 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,11 +46,10 @@ "@restate/features/restate-context": [ "libs/features/restate-context/src/index.ts" ], - "@restate/features/version": ["libs/features/version/src/index.ts"], - "@restate/util/humantime": ["libs/util/humantime/src/index.ts"], "@restate/features/service-protocol": [ "libs/features/service-protocol/src/index.ts" ], + "@restate/features/version": ["libs/features/version/src/index.ts"], "@restate/ui/api": ["libs/ui/api/src/index.ts"], "@restate/ui/badge": ["libs/ui/badge/src/index.ts"], "@restate/ui/button": ["libs/ui/button/src/index.ts"], @@ -76,6 +75,7 @@ "@restate/ui/tooltip": ["libs/ui/tooltip/src/index.ts"], "@restate/util/errors": ["libs/util/errors/src/index.ts"], "@restate/util/feature-flag": ["libs/util/feature-flag/src/index.ts"], + "@restate/util/humantime": ["libs/util/humantime/src/index.ts"], "@restate/util/intl": ["libs/util/intl/src/index.ts"], "@restate/util/mock-api": ["libs/util/mock-api/src/index.ts"], "@restate/util/playwright": ["libs/util/playwright/src/index.ts"],