diff --git a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx index 8b9a55465..c070dde08 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx @@ -15,6 +15,7 @@ import { useReactTable, type VisibilityState, } from "@tanstack/react-table"; +import { useAtomValue } from "jotai"; import { Activity, AlertCircle, @@ -44,8 +45,13 @@ import { import { AnimatePresence, motion, type Variants } from "motion/react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import React, { useContext, useId, useMemo, useRef, useState } from "react"; +import React, { useCallback, useContext, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { + createLogMutationAtom, + deleteLogMutationAtom, + updateLogMutationAtom, +} from "@/atoms/logs/log-mutation.atoms"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { AlertDialog, @@ -89,7 +95,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { type Log, type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs"; +import type { CreateLogRequest, Log, UpdateLogRequest } from "@/contracts/types/log.types"; +import { type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs"; import { cn } from "@/lib/utils"; // Define animation variants for reuse @@ -334,13 +341,50 @@ export default function LogsManagePage() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const { - logs, - loading: logsLoading, - error: logsError, - refreshLogs, - deleteLog, - } = useLogs(searchSpaceId); + const { mutateAsync: deleteLogMutation } = useAtomValue(deleteLogMutationAtom); + const { mutateAsync: updateLogMutation } = useAtomValue(updateLogMutationAtom); + const { mutateAsync: createLogMutation } = useAtomValue(createLogMutationAtom); + + const createLog = useCallback( + async (data: CreateLogRequest) => { + try { + await createLogMutation(data); + return true; + } catch (error) { + console.error("Failed to create log:", error); + return false; + } + }, + [createLogMutation] + ); + + const updateLog = useCallback( + async (logId: number, data: UpdateLogRequest) => { + try { + await updateLogMutation({ logId, data }); + return true; + } catch (error) { + console.error("Failed to update log:", error); + return false; + } + }, + [updateLogMutation] + ); + + const deleteLog = useCallback( + async (id: number) => { + try { + await deleteLogMutation({ id }); + return true; + } catch (error) { + console.error("Failed to delete log:", error); + return false; + } + }, + [deleteLogMutation] + ); + + const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(searchSpaceId); const { summary, loading: summaryLoading, @@ -408,7 +452,7 @@ export default function LogsManagePage() { return; } - const deletePromises = selectedRows.map((row) => deleteLog(row.original.id)); + const deletePromises = selectedRows.map((row) => deleteLog(row.original.id)); // Already passes { id } via wrapper try { const results = await Promise.all(deletePromises); @@ -437,7 +481,7 @@ export default function LogsManagePage() { Promise.resolve(false)), - refreshLogs: refreshLogs || (() => Promise.resolve()), + refreshLogs: () => refreshLogs().then(() => void 0), }} > { + const searchSpaceId = get(activeSearchSpaceIdAtom); + return { + mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined), + enabled: !!searchSpaceId, + mutationFn: async (request: CreateLogRequest) => logsApiService.createLog(request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), + }); + }, + }; +}); + +/** + * Update Log Mutation + */ +export const updateLogMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + return { + mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined), + enabled: !!searchSpaceId, + mutationFn: async ({ logId, data }: { logId: number; data: UpdateLogRequest }) => + logsApiService.updateLog(logId, data), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: cacheKeys.logs.detail(variables.logId) }); + queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), + }); + }, + }; +}); + +/** + * Delete Log Mutation + */ +export const deleteLogMutationAtom = atomWithMutation((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + return { + mutationKey: cacheKeys.logs.list(searchSpaceId ?? undefined), + enabled: !!searchSpaceId, + mutationFn: async (request: DeleteLogRequest) => logsApiService.deleteLog(request), + onSuccess: (_data, request) => { + queryClient.invalidateQueries({ queryKey: cacheKeys.logs.list(searchSpaceId ?? undefined) }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), + }); + if (request?.id) + queryClient.invalidateQueries({ queryKey: cacheKeys.logs.detail(request.id) }); + }, + }; +}); diff --git a/surfsense_web/contracts/types/log.types.ts b/surfsense_web/contracts/types/log.types.ts new file mode 100644 index 000000000..b1e95bbf2 --- /dev/null +++ b/surfsense_web/contracts/types/log.types.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { paginationQueryParams } from "."; + +/** + * ENUMS + */ +export const logLevelEnum = z.enum(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]); + +export const logStatusEnum = z.enum(["IN_PROGRESS", "SUCCESS", "FAILED"]); + +/** + * Base log schema + */ +export const log = z.object({ + id: z.number(), + level: logLevelEnum, + status: logStatusEnum, + message: z.string(), + source: z.string().nullable().optional(), + log_metadata: z.record(z.string(), z.any()).nullable().optional(), + created_at: z.string(), + search_space_id: z.number(), +}); + +export const logBase = log.omit({ id: true, created_at: true }); + +/** + * Create log + */ +export const createLogRequest = logBase.extend({ search_space_id: z.number() }); +export const createLogResponse = log; + +/** + * Update log + */ +export const updateLogRequest = logBase.partial(); +export const updateLogResponse = log; + +/** + * Delete log + */ +export const deleteLogRequest = z.object({ id: z.number() }); +export const deleteLogResponse = z.object({ + message: z.string().default("Log deleted successfully"), +}); + +/** + * Get logs (list) + */ +export const logFilters = z.object({ + search_space_id: z.number().optional(), + level: logLevelEnum.optional(), + status: logStatusEnum.optional(), + source: z.string().optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), +}); + +export const getLogsRequest = z.object({ + queryParams: paginationQueryParams + .extend({ + search_space_id: z.number().optional(), + level: logLevelEnum.optional(), + status: logStatusEnum.optional(), + source: z.string().optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), + }) + .nullish(), +}); +export const getLogsResponse = z.array(log); + +/** + * Get single log + */ +export const getLogRequest = z.object({ id: z.number() }); +export const getLogResponse = log; + +/** + * Log summary (used for summary dashboard) + */ +export const logActiveTask = z.object({ + id: z.number(), + task_name: z.string(), + message: z.string(), + started_at: z.string(), + source: z.string().nullable().optional(), +}); +export const logFailure = z.object({ + id: z.number(), + task_name: z.string(), + message: z.string(), + failed_at: z.string(), + source: z.string().nullable().optional(), + error_details: z.string().nullable().optional(), +}); +export const logSummary = z.object({ + total_logs: z.number(), + time_window_hours: z.number(), + by_status: z.record(z.string(), z.number()), + by_level: z.record(z.string(), z.number()), + by_source: z.record(z.string(), z.number()), + active_tasks: z.array(logActiveTask), + recent_failures: z.array(logFailure), +}); +export const getLogSummaryRequest = z.object({ + search_space_id: z.number(), + hours: z.number().optional(), +}); +export const getLogSummaryResponse = logSummary; + +/** + * Typescript types + */ +export type Log = z.infer; +export type LogLevelEnum = z.infer; +export type LogStatusEnum = z.infer; +export type LogFilters = z.infer; +export type CreateLogRequest = z.infer; +export type CreateLogResponse = z.infer; +export type UpdateLogRequest = z.infer; +export type UpdateLogResponse = z.infer; +export type DeleteLogRequest = z.infer; +export type DeleteLogResponse = z.infer; +export type GetLogsRequest = z.infer; +export type GetLogsResponse = z.infer; +export type GetLogRequest = z.infer; +export type GetLogResponse = z.infer; +export type LogSummary = z.infer; +export type LogFailure = z.infer; +export type LogActiveTask = z.infer; +export type GetLogSummaryRequest = z.infer; +export type GetLogSummaryResponse = z.infer; diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index cfd161de0..127f1d98c 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { logsApiService } from "@/lib/apis/logs-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; export type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL"; export type LogStatus = "IN_PROGRESS" | "SUCCESS" | "FAILED"; @@ -50,267 +51,89 @@ export interface LogSummary { } export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) { - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - // Memoize filters to prevent infinite re-renders const memoizedFilters = useMemo(() => filters, [JSON.stringify(filters)]); const buildQueryParams = useCallback( (customFilters: LogFilters = {}) => { - const params = new URLSearchParams(); + const params: Record = {}; const allFilters = { ...memoizedFilters, ...customFilters }; if (allFilters.search_space_id) { - params.append("search_space_id", allFilters.search_space_id.toString()); + params["search_space_id"] = allFilters.search_space_id.toString(); } if (allFilters.level) { - params.append("level", allFilters.level); + params["level"] = allFilters.level; } if (allFilters.status) { - params.append("status", allFilters.status); + params["status"] = allFilters.status; } if (allFilters.source) { - params.append("source", allFilters.source); + params["source"] = allFilters.source; } if (allFilters.start_date) { - params.append("start_date", allFilters.start_date); + params["start_date"] = allFilters.start_date; } if (allFilters.end_date) { - params.append("end_date", allFilters.end_date); + params["end_date"] = allFilters.end_date; } - return params.toString(); + return params; }, [memoizedFilters] ); - const fetchLogs = useCallback( - async (customFilters: LogFilters = {}, options: { skip?: number; limit?: number } = {}) => { - try { - setLoading(true); - - const params = new URLSearchParams(buildQueryParams(customFilters)); - if (options.skip !== undefined) params.append("skip", options.skip.toString()); - if (options.limit !== undefined) params.append("limit", options.limit.toString()); - - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs?${params}`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch logs"); - } - - const data = await response.json(); - setLogs(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch logs"); - console.error("Error fetching logs:", err); - throw err; - } finally { - setLoading(false); - } - }, - [buildQueryParams] - ); - - // Initial fetch - useEffect(() => { - const initialFilters = searchSpaceId - ? { ...memoizedFilters, search_space_id: searchSpaceId } - : memoizedFilters; - fetchLogs(initialFilters); - }, [searchSpaceId, fetchLogs, memoizedFilters]); - - // Function to refresh the logs list - const refreshLogs = useCallback( - async (customFilters: LogFilters = {}) => { - const finalFilters = searchSpaceId - ? { ...customFilters, search_space_id: searchSpaceId } - : customFilters; - return await fetchLogs(finalFilters); - }, - [searchSpaceId, fetchLogs] - ); - - // Function to create a new log - // Use silent: true to suppress toast notifications (for internal/background operations) - const createLog = useCallback( - async (logData: Omit, options?: { silent?: boolean }) => { - const { silent = false } = options || {}; - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(logData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create log"); - } - - const newLog = await response.json(); - setLogs((prevLogs) => [newLog, ...prevLogs]); - // Only show toast if not silent - if (!silent) { - toast.success("Log created successfully"); - } - return newLog; - } catch (err: any) { - // Only show error toast if not silent - if (!silent) { - toast.error(err.message || "Failed to create log"); - } - console.error("Error creating log:", err); - throw err; - } - }, - [] - ); - - // Function to update a log - const updateLog = useCallback( - async ( - logId: number, - updateData: Partial> - ) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify(updateData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update log"); - } - - const updatedLog = await response.json(); - setLogs((prevLogs) => prevLogs.map((log) => (log.id === logId ? updatedLog : log))); - toast.success("Log updated successfully"); - return updatedLog; - } catch (err: any) { - toast.error(err.message || "Failed to update log"); - console.error("Error updating log:", err); - throw err; - } - }, - [] - ); - - // Function to delete a log - const deleteLog = useCallback(async (logId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to delete log"); - } - - setLogs((prevLogs) => prevLogs.filter((log) => log.id !== logId)); - toast.success("Log deleted successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to delete log"); - console.error("Error deleting log:", err); - return false; - } - }, []); - - // Function to get a single log - const getLog = useCallback(async (logId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch log"); - } - - return await response.json(); - } catch (err: any) { - toast.error(err.message || "Failed to fetch log"); - console.error("Error fetching log:", err); - throw err; - } - }, []); + const { + data: logs, + isLoading: loading, + error, + refetch, + } = useQuery({ + queryKey: cacheKeys.logs.withQueryParams({ + search_space_id: searchSpaceId, + skip: 0, + limit: 5, + ...buildQueryParams(filters ?? {}), + }), + queryFn: () => + logsApiService.getLogs({ + queryParams: { + search_space_id: searchSpaceId, + skip: 0, + limit: 5, + ...buildQueryParams(filters ?? {}), + }, + }), + enabled: !!searchSpaceId, + staleTime: 3 * 60 * 1000, + }); return { - logs, + logs: logs ?? [], loading, error, - refreshLogs, - createLog, - updateLog, - deleteLog, - getLog, - fetchLogs, + refreshLogs: refetch, }; } // Separate hook for log summary export function useLogsSummary(searchSpaceId: number, hours: number = 24) { - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { + data: summary, + isLoading: loading, + error, + refetch, + } = useQuery({ + queryKey: cacheKeys.logs.summary(searchSpaceId), + queryFn: () => + logsApiService.getLogSummary({ + search_space_id: searchSpaceId, + hours: hours, + }), + enabled: !!searchSpaceId, + staleTime: 3 * 60 * 1000, + }); - const fetchSummary = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/search-space/${searchSpaceId}/summary?hours=${hours}`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch logs summary"); - } - - const data = await response.json(); - setSummary(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch logs summary"); - console.error("Error fetching logs summary:", err); - throw err; - } finally { - setLoading(false); - } - }, [searchSpaceId, hours]); - - useEffect(() => { - fetchSummary(); - }, [fetchSummary]); - - const refreshSummary = useCallback(() => { - return fetchSummary(); - }, [fetchSummary]); - - return { summary, loading, error, refreshSummary }; + return { summary, loading, error, refreshSummary: refetch }; } diff --git a/surfsense_web/lib/apis/logs-api.service.ts b/surfsense_web/lib/apis/logs-api.service.ts new file mode 100644 index 000000000..115f50497 --- /dev/null +++ b/surfsense_web/lib/apis/logs-api.service.ts @@ -0,0 +1,128 @@ +import { + type CreateLogRequest, + createLogRequest, + createLogResponse, + type DeleteLogRequest, + deleteLogRequest, + deleteLogResponse, + type GetLogRequest, + type GetLogSummaryRequest, + type GetLogsRequest, + getLogRequest, + getLogResponse, + getLogSummaryRequest, + getLogSummaryResponse, + getLogsRequest, + getLogsResponse, + type Log, + log, + type UpdateLogRequest, + updateLogRequest, + updateLogResponse, +} from "@/contracts/types/log.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class LogsApiService { + /** + * Get a list of logs with optional filtering and pagination + */ + getLogs = async (request: GetLogsRequest) => { + const parsedRequest = getLogsRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + // Transform query params to be string values + const transformedQueryParams = parsedRequest.data.queryParams + ? Object.fromEntries( + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + // Handle array values (document_type) + if (Array.isArray(v)) { + return [k, v.join(",")]; + } + return [k, String(v)]; + }) + ) + : undefined; + + const queryParams = transformedQueryParams + ? new URLSearchParams(transformedQueryParams).toString() + : ""; + return baseApiService.get(`/api/v1/logs?${queryParams}`, getLogsResponse); + }; + + /** + * Get a single log by ID + */ + getLog = async (request: GetLogRequest) => { + const parsedRequest = getLogRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + return baseApiService.get(`/api/v1/logs/${request.id}`, getLogResponse); + }; + + /** + * Create a log entry + */ + createLog = async (request: CreateLogRequest) => { + const parsedRequest = createLogRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + return baseApiService.post(`/api/v1/logs`, createLogResponse, { + body: parsedRequest.data, + }); + }; + + /** + * Update a log entry + */ + updateLog = async (logId: number, request: UpdateLogRequest) => { + const parsedRequest = updateLogRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + return baseApiService.put(`/api/v1/logs/${logId}`, updateLogResponse, { + body: parsedRequest.data, + }); + }; + + /** + * Delete a log entry + */ + deleteLog = async (request: DeleteLogRequest) => { + const parsedRequest = deleteLogRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + return baseApiService.delete(`/api/v1/logs/${parsedRequest.data.id}`, deleteLogResponse); + }; + + /** + * Get summary for logs by search space + */ + getLogSummary = async (request: GetLogSummaryRequest) => { + const parsedRequest = getLogSummaryRequest.safeParse(request); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + const { search_space_id, hours } = parsedRequest.data; + const url = `/api/v1/logs/search-space/${search_space_id}/summary${hours ? `?hours=${hours}` : ""}`; + return baseApiService.get(url, getLogSummaryResponse); + }; +} + +export const logsApiService = new LogsApiService(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 8e0f1431e..0ba1dc535 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -1,4 +1,5 @@ import type { GetDocumentsRequest } from "@/contracts/types/document.types"; +import type { GetLogsRequest } from "@/contracts/types/log.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; export const cacheKeys = { @@ -18,6 +19,13 @@ export const cacheKeys = { typeCounts: (searchSpaceId?: string) => ["documents", "type-counts", searchSpaceId] as const, byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const, }, + logs: { + list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const, + detail: (logId: number | string) => ["logs", "detail", logId] as const, + summary: (searchSpaceId?: number | string) => ["logs", "summary", searchSpaceId] as const, + withQueryParams: (queries: GetLogsRequest["queryParams"]) => + ["logs", "with-query-params", ...(queries ? Object.values(queries) : [])] as const, + }, newLLMConfigs: { all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const, byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,