diff --git a/console/apps/webapp/src/Layouts/OxygenLayout/LeftNavigation.tsx b/console/apps/webapp/src/Layouts/OxygenLayout/LeftNavigation.tsx index 446044bf4..f577ac483 100644 --- a/console/apps/webapp/src/Layouts/OxygenLayout/LeftNavigation.tsx +++ b/console/apps/webapp/src/Layouts/OxygenLayout/LeftNavigation.tsx @@ -54,6 +54,7 @@ export function LeftNavigation({ collapsed={collapsed} activeItem={activeItem} onSelect={onNavigationClick} + width={280} > diff --git a/console/workspaces/libs/api-client/src/apis/index.ts b/console/workspaces/libs/api-client/src/apis/index.ts index 64b911778..a9d23602c 100644 --- a/console/workspaces/libs/api-client/src/apis/index.ts +++ b/console/workspaces/libs/api-client/src/apis/index.ts @@ -29,5 +29,6 @@ export * from './metrics'; export * from './monitors'; export * from './runtime-logs'; export * from './repositories'; +export * from './resource-configs'; export * from './llm-providers'; export * from './gateways'; diff --git a/console/workspaces/libs/api-client/src/apis/metrics.ts b/console/workspaces/libs/api-client/src/apis/metrics.ts index 3e50c67a6..f8c884b76 100644 --- a/console/workspaces/libs/api-client/src/apis/metrics.ts +++ b/console/workspaces/libs/api-client/src/apis/metrics.ts @@ -34,7 +34,14 @@ export async function getAgentMetrics( if (!agentName) { throw new Error("agentName is required"); } - + const bodyClone = cloneDeep(body); + const now = new Date(); + if (!bodyClone?.endTime) { + bodyClone.endTime = now.toISOString(); + } + if (!bodyClone?.startTime) { + bodyClone.startTime = new Date(now.getTime() - 1000 * 10).toISOString(); + } const token = getToken ? await getToken() : undefined; const res = await httpPOST( `${SERVICE_BASE}/orgs/${encodeURIComponent( @@ -42,7 +49,7 @@ export async function getAgentMetrics( )}/projects/${encodeURIComponent(projName)}/agents/${encodeURIComponent( agentName )}/metrics`, - cloneDeep(body), + bodyClone, { token } ); if (!res.ok) throw await res.json(); diff --git a/console/workspaces/libs/api-client/src/apis/resource-configs.ts b/console/workspaces/libs/api-client/src/apis/resource-configs.ts new file mode 100644 index 000000000..eb910ba19 --- /dev/null +++ b/console/workspaces/libs/api-client/src/apis/resource-configs.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { httpGET, httpPUT, SERVICE_BASE } from "../utils"; +import type { + AgentResourceConfigsResponse, + GetAgentResourceConfigsPathParams, + GetAgentResourceConfigsQuery, + UpdateAgentResourceConfigsPathParams, + UpdateAgentResourceConfigsQuery, + UpdateAgentResourceConfigsRequest, +} from "@agent-management-platform/types"; + +function buildBaseUrl(params: GetAgentResourceConfigsPathParams): string { + const orgName = params.orgName ?? "default"; + const projName = params.projName ?? "default"; + const agentName = params.agentName; + if (!agentName) { + throw new Error("agentName is required"); + } + return `${SERVICE_BASE}/orgs/${encodeURIComponent(orgName)}/projects/${encodeURIComponent(projName)}/agents/${encodeURIComponent(agentName)}/resource-configs`; +} + +export async function getAgentResourceConfigs( + params: GetAgentResourceConfigsPathParams, + query?: GetAgentResourceConfigsQuery, + getToken?: () => Promise +): Promise { + const baseUrl = buildBaseUrl(params); + const token = getToken ? await getToken() : undefined; + + const searchParams: Record = {}; + if (query?.environment !== undefined) { + searchParams.environment = query.environment; + } + + const res = await httpGET(baseUrl, { + token, + searchParams: Object.keys(searchParams).length > 0 ? searchParams : undefined, + }); + let body: unknown; + try { + body = await res.json(); + } catch { + body = await res.text().catch(() => "Failed to parse response"); + } + if (!res.ok) { + const err = new Error(typeof body === "string" ? body : "Request failed") as Error & { status?: number; statusText?: string; body?: unknown }; + err.status = res.status; + err.statusText = res.statusText; + err.body = body; + throw err; + } + return body as AgentResourceConfigsResponse; +} + +export async function updateAgentResourceConfigs( + params: UpdateAgentResourceConfigsPathParams, + body: UpdateAgentResourceConfigsRequest, + query?: UpdateAgentResourceConfigsQuery, + getToken?: () => Promise +): Promise { + const baseUrl = buildBaseUrl(params); + const token = getToken ? await getToken() : undefined; + + const searchParams: Record = {}; + if (query?.environment !== undefined) { + searchParams.environment = query.environment; + } + + const res = await httpPUT(baseUrl, body, { + token, + searchParams: Object.keys(searchParams).length > 0 ? searchParams : undefined, + }); + let responseBody: unknown; + try { + responseBody = await res.json(); + } catch { + responseBody = await res.text().catch(() => "Failed to parse response"); + } + if (!res.ok) { + const err = new Error(typeof responseBody === "string" ? responseBody : "Request failed") as Error & { status?: number; statusText?: string; body?: unknown }; + err.status = res.status; + err.statusText = res.statusText; + err.body = responseBody; + throw err; + } + return responseBody as AgentResourceConfigsResponse; +} diff --git a/console/workspaces/libs/api-client/src/hooks/index.ts b/console/workspaces/libs/api-client/src/hooks/index.ts index 166a314e0..01cbd96eb 100644 --- a/console/workspaces/libs/api-client/src/hooks/index.ts +++ b/console/workspaces/libs/api-client/src/hooks/index.ts @@ -29,6 +29,7 @@ export * from './metrics'; export * from './monitors'; export * from './runtime-logs'; export * from './repositories'; +export * from './resource-configs'; export * from './llm-providers'; export * from './gateways'; export * from './guardrails'; diff --git a/console/workspaces/libs/api-client/src/hooks/metrics.ts b/console/workspaces/libs/api-client/src/hooks/metrics.ts index 8628c24bb..4f9d2d54e 100644 --- a/console/workspaces/libs/api-client/src/hooks/metrics.ts +++ b/console/workspaces/libs/api-client/src/hooks/metrics.ts @@ -24,21 +24,21 @@ import { MetricsFilterRequest, MetricsResponse, } from "@agent-management-platform/types"; +import { SLOW_POLL_INTERVAL } from "../utils"; export function useGetAgentMetrics( params: GetAgentMetricsPathParams, body: MetricsFilterRequest, - options?: { enabled?: boolean } + options?: { enabled?: boolean, enableAutoRefresh?: boolean } ) { const { getToken } = useAuthHooks(); return useApiQuery({ queryKey: ["agent-metrics", params, body], queryFn: () => getAgentMetrics(params, body, getToken), + refetchInterval: options?.enableAutoRefresh ? SLOW_POLL_INTERVAL : undefined, enabled: (options?.enabled ?? true) && !!params.agentName && - !!body.environmentName && - !!body.startTime && - !!body.endTime, + !!body.environmentName }); } diff --git a/console/workspaces/libs/api-client/src/hooks/resource-configs.ts b/console/workspaces/libs/api-client/src/hooks/resource-configs.ts new file mode 100644 index 000000000..36a84872a --- /dev/null +++ b/console/workspaces/libs/api-client/src/hooks/resource-configs.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthHooks } from "@agent-management-platform/auth"; +import { useApiMutation, useApiQuery } from "./react-query-notifications"; +import { + getAgentResourceConfigs, + updateAgentResourceConfigs, +} from "../apis/resource-configs"; +import type { + AgentResourceConfigsResponse, + GetAgentResourceConfigsPathParams, + GetAgentResourceConfigsQuery, + UpdateAgentResourceConfigsPathParams, + UpdateAgentResourceConfigsQuery, + UpdateAgentResourceConfigsRequest, +} from "@agent-management-platform/types"; + +const QUERY_KEY = "resource-configs"; + +export function useGetAgentResourceConfigs( + params: GetAgentResourceConfigsPathParams, + query?: GetAgentResourceConfigsQuery +) { + const { getToken } = useAuthHooks(); + return useApiQuery({ + queryKey: [QUERY_KEY, params, query], + queryFn: () => getAgentResourceConfigs(params, query, getToken), + enabled: + !!params.orgName && !!params.projName && !!params.agentName, + }); +} + +export function useUpdateAgentResourceConfigs() { + const { getToken } = useAuthHooks(); + const queryClient = useQueryClient(); + return useApiMutation< + AgentResourceConfigsResponse, + unknown, + { + params: UpdateAgentResourceConfigsPathParams; + body: UpdateAgentResourceConfigsRequest; + query?: UpdateAgentResourceConfigsQuery; + } + >({ + action: { verb: "update", target: "agent resource configs" }, + mutationFn: ({ params, body, query }) => + updateAgentResourceConfigs(params, body, query, getToken), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + }, + }); +} diff --git a/console/workspaces/libs/api-client/src/utils/utils.ts b/console/workspaces/libs/api-client/src/utils/utils.ts index 5d33ef4c4..3e51c02ea 100644 --- a/console/workspaces/libs/api-client/src/utils/utils.ts +++ b/console/workspaces/libs/api-client/src/utils/utils.ts @@ -32,6 +32,7 @@ export function encodeRequired(value: string | undefined, label: string): string } export const OBS_SERVICE_BASE = '/api'; export const POLL_INTERVAL = 5000; +export const SLOW_POLL_INTERVAL = 15000; const DEFAULT_TIMEOUT = 1000; diff --git a/console/workspaces/libs/shared-component/src/components/DeploymentConfig.tsx b/console/workspaces/libs/shared-component/src/components/DeploymentConfig.tsx index 774ab4b72..28c3a9a93 100644 --- a/console/workspaces/libs/shared-component/src/components/DeploymentConfig.tsx +++ b/console/workspaces/libs/shared-component/src/components/DeploymentConfig.tsx @@ -16,227 +16,287 @@ * under the License. */ -import { useDeployAgent, useGetAgent, useGetAgentConfigurations, useListEnvironments } from "@agent-management-platform/api-client"; +import { + useDeployAgent, + useGetAgent, + useGetAgentConfigurations, + useListEnvironments, +} from "@agent-management-platform/api-client"; import { Rocket } from "@wso2/oxygen-ui-icons-react"; -import { Box, Button, Checkbox, Collapse, FormControlLabel, Skeleton, Typography} from "@wso2/oxygen-ui"; +import { + Box, + Button, + Form, + FormControlLabel, + Skeleton, + Switch, + Typography, +} from "@wso2/oxygen-ui"; import { EnvironmentVariable } from "./EnvironmentVariable"; -import type { Environment, EnvironmentVariable as EnvVar } from "@agent-management-platform/types"; +import type { + Environment, + EnvironmentVariable as EnvVar, +} from "@agent-management-platform/types"; import { useEffect, useState } from "react"; -import { TextInput, DrawerHeader, DrawerContent } from "@agent-management-platform/views"; +import { + TextInput, + DrawerHeader, + DrawerContent, +} from "@agent-management-platform/views"; interface DeploymentConfigProps { - onClose: () => void; - from?: string; - to: string; - orgName: string; - projName: string; - agentName: string; - imageId: string; + onClose: () => void; + from?: string; + to: string; + orgName: string; + projName: string; + agentName: string; + imageId: string; } export function DeploymentConfig({ - onClose, - from, - to, + onClose, + from, + to, + orgName, + projName, + agentName, + imageId, +}: DeploymentConfigProps) { + const [envVariables, setEnvVariables] = useState< + Array<{ + key: string; + value: string; + isSensitive?: boolean; + secretRef?: string; + isSecretEdited?: boolean; + }> + >([]); + const [enableAutoInstrumentation, setEnableAutoInstrumentation] = + useState(true); + + const { mutate: deployAgent, isPending } = useDeployAgent(); + const { data: agent, isLoading: isLoadingAgent } = useGetAgent({ orgName, projName, agentName, - imageId, -}: DeploymentConfigProps) { - const [envVariables, setEnvVariables] = useState>([]); - const [enableAutoInstrumentation, setEnableAutoInstrumentation] = useState(true); - - const { mutate: deployAgent, isPending } = useDeployAgent(); - const { data: agent, isLoading: isLoadingAgent } = useGetAgent({ - orgName, - projName, - agentName, + }); + const { data: environments, isLoading: isLoadingEnvironments } = + useListEnvironments({ + orgName, }); - const { data: environments, isLoading: isLoadingEnvironments } = useListEnvironments({ - orgName, - }); - const { data: configurations, isLoading: isLoadingConfigurations } = useGetAgentConfigurations({ + const { data: configurations, isLoading: isLoadingConfigurations } = + useGetAgentConfigurations( + { orgName, projName, agentName, - }, { - environment: to || '', - }); + }, + { + environment: to || "", + }, + ); + + useEffect(() => { + const configs = configurations?.configurations; + setEnvVariables( + configs ? [...configs].sort((a, b) => a.key.localeCompare(b.key)) : [], + ); + }, [configurations]); + + useEffect(() => { + if (agent?.configurations?.enableAutoInstrumentation !== undefined) { + setEnableAutoInstrumentation( + agent.configurations.enableAutoInstrumentation, + ); + } + }, [agent?.configurations?.enableAutoInstrumentation]); - useEffect(() => { - setEnvVariables(configurations?.configurations || []); - }, [configurations]); - - useEffect(() => { - if (agent?.configurations?.enableAutoInstrumentation !== undefined) { - setEnableAutoInstrumentation(agent.configurations.enableAutoInstrumentation); - } - }, [agent?.configurations?.enableAutoInstrumentation]); - - const isPythonBuildpack = agent?.build?.type === 'buildpack' && 'buildpack' in agent.build && agent.build.buildpack.language === 'python'; - - const handleDeploy = async () => { - try { - // Build env payload based on: - // 1. Deleted items are not in envVariables array (already filtered out) - // 2. If secret has secretRef and NOT edited: value = empty, - // secretRef = original ref (preserve) - // 3. If secret is new (no secretRef) OR edited: value = new value, no secretRef - const filteredEnvVars: EnvVar[] = envVariables - .filter((envVar) => { - // Include if it has a key and either: - // - Has a value (plain env var or new/updated secret) - // - Is an existing secret that wasn't edited (has secretRef) - if (!envVar.key) return false; - if (envVar.value) return true; - if (envVar.isSensitive && envVar.secretRef && !envVar.isSecretEdited) { - return true; - } - return false; - }) - .map((envVar) => { - if (envVar.isSensitive) { - // Check if this is an existing secret that should be preserved - const isExistingSecretPreserved = - envVar.secretRef && !envVar.isSecretEdited; - - if (isExistingSecretPreserved) { - // Existing secret NOT changed - send empty value, keep secretRef - return { - key: envVar.key, - value: '', - isSensitive: true, - secretRef: envVar.secretRef, - }; - } else { - // New secret OR existing secret with new value - send the value - return { - key: envVar.key, - value: envVar.value, - isSensitive: true, - // secretRef is intentionally omitted for new/updated secrets - }; - } - } - // Plain env var - return { - key: envVar.key, - value: envVar.value, - isSensitive: false, - }; - }); - - deployAgent({ - params: { - orgName, - projName, - agentName, - }, - body: { - imageId: imageId, - env: filteredEnvVars.length > 0 ? filteredEnvVars : undefined, - ...(isPythonBuildpack && { enableAutoInstrumentation }), - }, - }, { - onSuccess: () => { - onClose(); - }, - }); - } catch { - // Error handling is done by the mutation - } - }; - - - const toEnvironment = environments?.find((environment: Environment) => environment.name === to); - - const deployButtonText = from ? `Promote to ${toEnvironment?.displayName ?? to}` : `Deploy to ${toEnvironment?.displayName ?? to}`; - const titleText = from ? `Promote to ${toEnvironment?.displayName ?? to}` : `Deploy to ${toEnvironment?.displayName ?? to}`; - const descriptionText = from - ? `Promote ${agent?.displayName || 'Agent'} to ${toEnvironment?.displayName ?? to} Environment. Configure environment variables and deploy immediately.` - : `Deploy ${agent?.displayName || 'Agent'} to ${toEnvironment?.displayName ?? to} Environment. Configure environment variables and deploy immediately.`; - - return ( - - } - title={titleText} - onClose={onClose} - /> - + const isPythonBuildpack = + agent?.build?.type === "buildpack" && + "buildpack" in agent.build && + agent.build.buildpack.language === "python"; + + const handleDeploy = async () => { + try { + // Build env payload based on: + // 1. Deleted items are not in envVariables array (already filtered out) + // 2. If secret has secretRef and NOT edited: value = empty, + // secretRef = original ref (preserve) + // 3. If secret is new (no secretRef) OR edited: value = new value, no secretRef + const filteredEnvVars: EnvVar[] = envVariables + .filter((envVar) => { + // Include if it has a key and either: + // - Has a value (plain env var or new/updated secret) + // - Is an existing secret that wasn't edited (has secretRef) + if (!envVar.key) return false; + if (envVar.value) return true; + if ( + envVar.isSensitive && + envVar.secretRef && + !envVar.isSecretEdited + ) { + return true; + } + return false; + }) + .map((envVar) => { + if (envVar.isSensitive) { + // Check if this is an existing secret that should be preserved + const isExistingSecretPreserved = + envVar.secretRef && !envVar.isSecretEdited; + + if (isExistingSecretPreserved) { + // Existing secret NOT changed - send empty value, keep secretRef + return { + key: envVar.key, + value: "", + isSensitive: true, + secretRef: envVar.secretRef, + }; + } else { + // New secret OR existing secret with new value - send the value + return { + key: envVar.key, + value: envVar.value, + isSensitive: true, + // secretRef is intentionally omitted for new/updated secrets + }; + } + } + // Plain env var + return { + key: envVar.key, + value: envVar.value, + isSensitive: false, + }; + }); + + deployAgent( + { + params: { + orgName, + projName, + agentName, + }, + body: { + imageId: imageId, + env: filteredEnvVars.length > 0 ? filteredEnvVars : undefined, + ...(isPythonBuildpack && { enableAutoInstrumentation }), + }, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } catch { + // Error handling is done by the mutation + } + }; + + const toEnvironment = environments?.find( + (environment: Environment) => environment.name === to, + ); + + const deployButtonText = from + ? `Promote to ${toEnvironment?.displayName ?? to}` + : `Deploy to ${toEnvironment?.displayName ?? to}`; + const titleText = from + ? `Promote to ${toEnvironment?.displayName ?? to}` + : `Deploy to ${toEnvironment?.displayName ?? to}`; + const descriptionText = from + ? `Promote ${agent?.displayName || "Agent"} to ${toEnvironment?.displayName ?? to} Environment. Configure environment variables and deploy immediately.` + : `Deploy ${agent?.displayName || "Agent"} to ${toEnvironment?.displayName ?? to} Environment. Configure environment variables and deploy immediately.`; + + return ( + + } + title={titleText} + onClose={onClose} + /> + + + {descriptionText} + + + + + Deployment Details + + + + + + + Environment Variables + {isLoadingConfigurations || + isLoadingEnvironments || + isLoadingAgent ? ( + + ) : ( + + )} + + + {isPythonBuildpack && ( + + Instrumentation + + + setEnableAutoInstrumentation(checked) + } + disabled={isPending} + /> + } + label="Enable auto instrumentation" + /> - {descriptionText} + Automatically adds OTEL tracing instrumentation to your agent + for observability. + + + )} - - - - Deployment Details - - - - {isLoadingConfigurations || isLoadingEnvironments || isLoadingAgent ? ( - - - - ) : ( - - )} - - - setEnableAutoInstrumentation(e.target.checked)} - disabled={isPending} - /> - } - label="Enable auto instrumentation" - /> - - Automatically adds OTEL tracing instrumentation to - your agent for observability. - - - - - - - - - + + + + + + - ); + ); } diff --git a/console/workspaces/libs/shared-component/src/components/EnvironmentCard/EnvironmentCard.tsx b/console/workspaces/libs/shared-component/src/components/EnvironmentCard/EnvironmentCard.tsx index 714147610..d062d26db 100644 --- a/console/workspaces/libs/shared-component/src/components/EnvironmentCard/EnvironmentCard.tsx +++ b/console/workspaces/libs/shared-component/src/components/EnvironmentCard/EnvironmentCard.tsx @@ -68,7 +68,7 @@ export interface EnvironmentCardProps { actions?: React.ReactNode; } -export const EnvStatus = ({ status }: { status?: DeploymentStatus }) => { +export const EnvStatus = ({ status }: { status?: DeploymentStatus, }) => { const theme = useTheme(); if (!status) { return null; @@ -118,7 +118,7 @@ export const EnvStatus = ({ status }: { status?: DeploymentStatus }) => { variant="outlined" size="small" label="Suspended" - color="warning" + color="default" /> ); } diff --git a/console/workspaces/libs/shared-component/src/components/EnvironmentVariable.tsx b/console/workspaces/libs/shared-component/src/components/EnvironmentVariable.tsx index 7a09c00e1..1a8481caf 100644 --- a/console/workspaces/libs/shared-component/src/components/EnvironmentVariable.tsx +++ b/console/workspaces/libs/shared-component/src/components/EnvironmentVariable.tsx @@ -62,6 +62,8 @@ interface EnvironmentVariableProps { description?: string; /** When true, sensitive env variables are treated as existing secrets (locked by default) */ isExistingData?: boolean; + /** When true, the title is hidden */ + hideTitle?: boolean; } interface NewEnvVarForm { @@ -75,6 +77,7 @@ export const EnvironmentVariable = ({ setEnvVariables, hideAddButton = false, title = "Environment Variables (Optional)", + hideTitle = false, description = "Set environment variables for your agent deployment.", isExistingData = false, }: EnvironmentVariableProps) => { @@ -146,7 +149,7 @@ export const EnvironmentVariable = ({ return ( - {title} + {!hideTitle && {title}} {description} {/* Existing variables as read-only cards */} diff --git a/console/workspaces/libs/shared-component/src/components/ResourceMetricChip.tsx b/console/workspaces/libs/shared-component/src/components/ResourceMetricChip.tsx new file mode 100644 index 000000000..bb77e9e09 --- /dev/null +++ b/console/workspaces/libs/shared-component/src/components/ResourceMetricChip.tsx @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { alpha, Box, Stack, Theme, Tooltip, Typography, useTheme } from "@wso2/oxygen-ui"; + +export function formatCpu(cores: number): string { + return cores < 1 ? `${Math.round(cores * 1000)}m` : cores.toFixed(1); +} + +export function formatMemory(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}Ki`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(0)}Mi`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}Gi`; +} + +/** Parse Kubernetes-style CPU string to cores (e.g. "500m" -> 0.5, "1" -> 1) */ +export function parseCpuToCores(str: string): number | undefined { + if (!str || str === "—") return undefined; + const m = str.match(/^([0-9]+(?:\.[0-9]+)?)\s*m?$/i); + if (!m) return undefined; + const val = parseFloat(m[1]); + return str.toLowerCase().endsWith("m") ? val / 1000 : val; +} + +/** Parse Kubernetes-style memory string to bytes (e.g. "512Mi" -> bytes) */ +export function parseMemoryToBytes(str: string): number | undefined { + if (!str || str === "—") return undefined; + const m = str.match(/^([0-9]+(?:\.[0-9]+)?)\s*(Ki|Mi|Gi|Ti|Pi|Ei)?$/i); + if (!m) return undefined; + const val = parseFloat(m[1]); + const unit = (m[2] ?? "").toLowerCase(); + const factors: Record = { + ki: 1024, + mi: 1024 * 1024, + gi: 1024 * 1024 * 1024, + ti: 1024 ** 4, + pi: 1024 ** 5, + ei: 1024 ** 6, + }; + return val * (factors[unit] ?? 1); +} + +/** Format usage as percentage of request (e.g. current/request * 100) */ +export function formatUsagePercent( + current: number, + request: number | undefined +): string | undefined { + if (request === undefined || request <= 0) return undefined; + const pct = Math.round((current / request) * 100); + return `${pct}%`; +} + +/** Get color variant for usage percentage: >90% error, >70% warning, else success */ +export function getUsagePercentVariant( + current: number, + request: number | undefined +): "success" | "warning" | "error" | undefined { + if (request === undefined || request <= 0) return undefined; + const pct = (current / request) * 100; + if (pct > 90) return "error"; + if (pct > 70) return "warning"; + return "success"; +} +import type { ReactNode } from "react"; + +export type SecondaryVariant = "success" | "warning" | "error" | "default"; + +export interface ResourceMetricChipProps { + icon: ReactNode; + label: string; + primaryValue: string | number; + secondaryValue?: string | number; + secondaryTooltip?: string; + secondaryVariant?: SecondaryVariant; +} + +function getSecondaryBadgeStyles(theme: Theme, variant?: SecondaryVariant) { + if (!variant || variant === "default") { + return { + bgcolor: theme.vars?.palette?.background?.default ?? theme.palette?.background?.default, + color: "text.secondary", + }; + } + const paletteMap = { + success: theme.palette.success, + warning: theme.palette.warning, + error: theme.palette.error, + }; + const palette = paletteMap[variant]; + return { + bgcolor: alpha(palette.main, 0.2), + }; +} + +export function ResourceMetricChip({ + icon, + label, + primaryValue, + secondaryValue, + secondaryTooltip, + secondaryVariant, +}: ResourceMetricChipProps) { + const theme = useTheme(); + const badgeStyles = getSecondaryBadgeStyles(theme, secondaryVariant); + const secondaryBadge = ( + + {secondaryValue ?? "—"} + + ); + + return ( + + + + {icon} + + + {primaryValue} + {secondaryTooltip ? ( + {secondaryBadge} + ) : ( + secondaryBadge + )} + + + + ); +} diff --git a/console/workspaces/libs/shared-component/src/components/index.ts b/console/workspaces/libs/shared-component/src/components/index.ts index 28dc34458..8a041be35 100644 --- a/console/workspaces/libs/shared-component/src/components/index.ts +++ b/console/workspaces/libs/shared-component/src/components/index.ts @@ -22,6 +22,7 @@ export * from './BuildSteps'; export * from './CodeBlock'; export * from './DeploymentConfig'; export * from './EnvironmentVariable'; +export * from './ResourceMetricChip'; export * from './EnvironmentCard'; export * from './ConfirmationDialog'; export * from './ErrorPages'; diff --git a/console/workspaces/libs/types/src/api/index.ts b/console/workspaces/libs/types/src/api/index.ts index 801b9a103..4d929dab5 100644 --- a/console/workspaces/libs/types/src/api/index.ts +++ b/console/workspaces/libs/types/src/api/index.ts @@ -30,5 +30,6 @@ export * from './metrics'; export * from './monitors'; export * from './logs'; export * from './repositories'; +export * from './resource-configs'; export * from './llm-providers'; export * from './gateways'; diff --git a/console/workspaces/libs/types/src/api/metrics.ts b/console/workspaces/libs/types/src/api/metrics.ts index 79de1479e..27506d3da 100644 --- a/console/workspaces/libs/types/src/api/metrics.ts +++ b/console/workspaces/libs/types/src/api/metrics.ts @@ -21,8 +21,8 @@ import { type AgentPathParams } from "./common"; // Metrics Request export interface MetricsFilterRequest { environmentName: string; - startTime: string; // RFC3339 format - endTime: string; // RFC3339 format + startTime?: string; // RFC3339 format + endTime?: string; // RFC3339 format } // Metrics Response diff --git a/console/workspaces/libs/types/src/api/resource-configs.ts b/console/workspaces/libs/types/src/api/resource-configs.ts new file mode 100644 index 000000000..0850a9061 --- /dev/null +++ b/console/workspaces/libs/types/src/api/resource-configs.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AgentPathParams } from "./common"; + +// ----------------------------------------------------------------------------- +// Resource config schemas +// ----------------------------------------------------------------------------- + +export interface ResourceRequests { + cpu?: string; + memory?: string; +} + +export interface ResourceLimits { + cpu?: string; + memory?: string; +} + +export interface ResourceConfig { + requests?: ResourceRequests; + limits?: ResourceLimits; +} + +export interface AutoScalingConfig { + enabled?: boolean; + minReplicas?: number; + maxReplicas?: number; +} + +// ----------------------------------------------------------------------------- +// Request / Response types +// ----------------------------------------------------------------------------- + +export interface UpdateAgentResourceConfigsRequest { + replicas: number; + resources: ResourceConfig; + autoScaling: AutoScalingConfig; +} + +export interface AgentResourceConfigsResponse { + replicas?: number; + resources?: ResourceConfig; + autoScaling?: AutoScalingConfig; +} + +// ----------------------------------------------------------------------------- +// Path params and query +// ----------------------------------------------------------------------------- + +export type GetAgentResourceConfigsPathParams = AgentPathParams; +export type UpdateAgentResourceConfigsPathParams = AgentPathParams; + +export interface GetAgentResourceConfigsQuery { + environment?: string; +} + +export interface UpdateAgentResourceConfigsQuery { + environment?: string; +} diff --git a/console/workspaces/libs/views/src/component/PageLayout/PageErrorBoundary.tsx b/console/workspaces/libs/views/src/component/PageLayout/PageErrorBoundary.tsx index 32251c251..ee4b3aaa8 100644 --- a/console/workspaces/libs/views/src/component/PageLayout/PageErrorBoundary.tsx +++ b/console/workspaces/libs/views/src/component/PageLayout/PageErrorBoundary.tsx @@ -57,7 +57,7 @@ export class PageErrorBoundary extends Component< return ( { return ( - + {environments?.map((env) => ( - + + ({ + width: theme.spacing(4), + height: theme.spacing(0.5), + mt: theme.spacing(14), + bgcolor: "divider", + })} + /> + + ))} diff --git a/console/workspaces/pages/deploy/src/subComponent/BuildCard.tsx b/console/workspaces/pages/deploy/src/subComponent/BuildCard.tsx index 794830469..12124e69a 100644 --- a/console/workspaces/pages/deploy/src/subComponent/BuildCard.tsx +++ b/console/workspaces/pages/deploy/src/subComponent/BuildCard.tsx @@ -138,7 +138,7 @@ export function BuildCard(props: BuildCardProps) { }} > - + @@ -160,6 +160,7 @@ export function BuildCard(props: BuildCardProps) { } disableBackground /> @@ -181,7 +182,7 @@ export function BuildCard(props: BuildCardProps) { > - Set up + Setup {/* Build ID Selector */} diff --git a/console/workspaces/pages/deploy/src/subComponent/BuildSelectorDrawer.tsx b/console/workspaces/pages/deploy/src/subComponent/BuildSelectorDrawer.tsx index 178e9ec99..62589ab6f 100644 --- a/console/workspaces/pages/deploy/src/subComponent/BuildSelectorDrawer.tsx +++ b/console/workspaces/pages/deploy/src/subComponent/BuildSelectorDrawer.tsx @@ -16,7 +16,16 @@ * under the License. */ -import { Box, Typography, Button, List, ListItem, ListItemButton, ListItemText } from "@wso2/oxygen-ui"; +import { + Box, + Button, + Form, + List, + ListItem, + ListItemButton, + ListItemText, + Typography, +} from "@wso2/oxygen-ui"; import { useCallback, useEffect, useState } from "react"; import { Clock as AccessTime, GitCommit, Package, Check } from "@wso2/oxygen-ui-icons-react"; import { DrawerWrapper, DrawerHeader, DrawerContent } from "@agent-management-platform/views"; @@ -75,64 +84,70 @@ export function BuildSelectorDrawer({ onClose={onClose} /> - - Choose a build to deploy. Only completed builds are available. - - - - {builds.length === 0 ? ( - - - No builds available - - - ) : ( - builds.map((build) => { - const isSelected = tempSelectedBuild === build.buildName; - return ( - - handleBuildClick(build.buildName)} - selected={isSelected} - > - - - - - {build.commitId?.substring(0, 12) || "N/A"} - - - - - - {formatBuildDate(build.startedAt)} - - - - } - /> - - - ); - }) - )} - - - + + + Available Builds + + Choose a build to deploy. Only completed builds are available. + + + + {builds.length === 0 ? ( + theme.spacing(25) }} + > + + No builds available + + + ) : ( + builds.map((build) => { + const isSelected = tempSelectedBuild === build.buildName; + return ( + ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + })} + disablePadding + > + handleBuildClick(build.buildName)} + selected={isSelected} + > + + + + + {build.commitId?.substring(0, 12) || "N/A"} + + + + + + {formatBuildDate(build.startedAt)} + + + + } + /> + + + ); + }) + )} + + + + + @@ -146,6 +161,7 @@ export function BuildSelectorDrawer({ Select + ); diff --git a/console/workspaces/pages/deploy/src/subComponent/DeployCard.tsx b/console/workspaces/pages/deploy/src/subComponent/DeployCard.tsx index 8369a6887..acffeeecb 100644 --- a/console/workspaces/pages/deploy/src/subComponent/DeployCard.tsx +++ b/console/workspaces/pages/deploy/src/subComponent/DeployCard.tsx @@ -16,45 +16,231 @@ * under the License. */ -import { useListAgentDeployments, useUpdateDeploymentState } from "@agent-management-platform/api-client"; +import { + useGetAgentMetrics, + useGetAgentResourceConfigs, + useListAgentDeployments, + useUpdateDeploymentState, +} from "@agent-management-platform/api-client"; import { Environment } from "@agent-management-platform/types/dist/api/deployments"; import { NoDataFound, TextInput } from "@agent-management-platform/views"; import { Clock, + Cpu, ExternalLink, FlaskConical, Rocket, Workflow, - StopCircle, - RefreshCw, + PlayCircle, + PauseCircle, + Info, + SquareStack, + MemoryStick, + SlidersVertical, } from "@wso2/oxygen-ui-icons-react"; -import { generatePath, Link, useParams } from "react-router-dom"; +import { generatePath, Link, useParams, useSearchParams } from "react-router-dom"; import { + alpha, Box, Button, Card, CardContent, CircularProgress, + Collapse, Divider, IconButton, + Skeleton, Stack, Typography, + useTheme, } from "@wso2/oxygen-ui"; import { - EnvStatus, DeploymentStatus, + EnvStatus, + ResourceMetricChip, + formatUsagePercent, + getUsagePercentVariant, } from "@agent-management-platform/shared-component"; -import { absoluteRouteMap } from "@agent-management-platform/types"; +import { + absoluteRouteMap, + AgentResourceConfigsResponse, + MetricsResponse, +} from "@agent-management-platform/types"; import { extractBuildIdFromImageId } from "../utils/extractBuildIdFromImageId"; import { formatDistanceToNow } from "date-fns"; +import { useCallback, useMemo } from "react"; +import { EditResourceConfigsDrawer } from "./EditResourceConfigsDrawer"; + +function DeploymentStatusPanel({ status }: { status: DeploymentStatus }) { + const theme = useTheme(); + const backgroundColor = useMemo(() => { + if (status === DeploymentStatus.ACTIVE) { + return alpha(theme.palette.success.light, 0.1); + } + if (status === DeploymentStatus.INACTIVE) { + return theme.vars?.palette?.Skeleton.bg; + } + if (status === DeploymentStatus.DEPLOYING) { + return alpha(theme.palette.warning.light, 0.1); + } + if (status === DeploymentStatus.ERROR) { + return alpha(theme.palette.error.light, 0.1); + } + if (status === DeploymentStatus.SUSPENDED) { + return theme.vars?.palette?.Skeleton?.bg; + } + return theme.vars?.palette?.Skeleton?.bg; + }, [status, theme]); + + return ( + + Deployment Status: + + + ); +} + +function ResourceConfigsPanel({ + resourceConfigs, + isLoading, + metrics, +}: { + resourceConfigs?: AgentResourceConfigsResponse; + isLoading: boolean; + metrics?: MetricsResponse; +}) { + const lastCpu = metrics?.cpuUsage?.length + ? metrics.cpuUsage[metrics.cpuUsage.length - 1]?.value + : undefined; + const lastMemory = metrics?.memory?.length + ? metrics.memory[metrics.memory.length - 1]?.value + : undefined; + const lastCpuRequest = metrics?.cpuRequests?.length + ? metrics.cpuRequests[metrics.cpuRequests.length - 1]?.value + : undefined; + const lastMemoryRequest = metrics?.memoryRequests?.length + ? metrics.memoryRequests[metrics.memoryRequests.length - 1]?.value + : undefined; + const cpuRequest = resourceConfigs?.resources?.requests?.cpu ?? "—"; + const memoryRequest = resourceConfigs?.resources?.requests?.memory ?? "—"; + const cpuPercent = + lastCpu !== undefined && lastCpuRequest !== undefined && lastCpuRequest > 0 + ? formatUsagePercent(lastCpu, lastCpuRequest) + : undefined; + const memoryPercent = + lastMemory !== undefined && + lastMemoryRequest !== undefined && + lastMemoryRequest > 0 + ? formatUsagePercent(lastMemory, lastMemoryRequest) + : undefined; + const cpuVariant = + lastCpu !== undefined && lastCpuRequest !== undefined && lastCpuRequest > 0 + ? getUsagePercentVariant(lastCpu, lastCpuRequest) + : undefined; + const memoryVariant = + lastMemory !== undefined && + lastMemoryRequest !== undefined && + lastMemoryRequest > 0 + ? getUsagePercentVariant(lastMemory, lastMemoryRequest) + : undefined; + if (isLoading) { + return ( + + + + ); + } + if (!resourceConfigs) { + return ( + } + disableBackground + /> + ); + } + return ( + + } + label="Replicas" + primaryValue={""} + secondaryValue={ + resourceConfigs.autoScaling?.enabled + ? "AUTO" + : (resourceConfigs.replicas ?? "--") + } + secondaryTooltip={ + resourceConfigs.autoScaling?.enabled + ? `Autoscaling is enabled, replicas can be ${resourceConfigs.autoScaling?.minReplicas} to ${resourceConfigs.autoScaling?.maxReplicas}` + : "Autoscaling is disabled, replicas are fixed" + } + secondaryVariant={"success"} + /> + } + label="CPU" + primaryValue={cpuRequest} + secondaryValue={cpuPercent} + secondaryTooltip={ + cpuPercent ? "Current usage as % of requested." : undefined + } + secondaryVariant={cpuVariant} + /> + } + label="Memory" + primaryValue={memoryRequest} + secondaryValue={memoryPercent} + secondaryTooltip={ + memoryPercent ? "Current usage as % of requested." : undefined + } + secondaryVariant={memoryVariant} + /> + + ); +} interface DeployCardProps { currentEnvironment: Environment; } +const ENV_ID_PARAM = "envId"; +const OPEN_RES_CONFIG_PARAM = "openResConfig"; + export function DeployCard(props: DeployCardProps) { const { currentEnvironment } = props; const { orgId, agentId, projectId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + + const resourceConfigDrawerOpen = + searchParams.get(OPEN_RES_CONFIG_PARAM) === "open" && + searchParams.get(ENV_ID_PARAM) === currentEnvironment.name; + + const handleOpenResourceConfigDrawer = useCallback(() => { + const next = new URLSearchParams(searchParams); + next.set(ENV_ID_PARAM, currentEnvironment.name); + next.set(OPEN_RES_CONFIG_PARAM, "open"); + setSearchParams(next); + }, [currentEnvironment.name, searchParams, setSearchParams]); + + const handleCloseResourceConfigDrawer = useCallback(() => { + const next = new URLSearchParams(searchParams); + next.delete(OPEN_RES_CONFIG_PARAM); + next.delete(ENV_ID_PARAM); + setSearchParams(next); + }, [searchParams, setSearchParams]); const { data: deployments, isLoading: isDeploymentsLoading } = useListAgentDeployments({ @@ -62,8 +248,44 @@ export function DeployCard(props: DeployCardProps) { projName: projectId, agentName: agentId, }); - const updateDeploymentState = useUpdateDeploymentState(); + const { mutate: updateDeploymentState, isPending: isUpdating } = + useUpdateDeploymentState(); + + const { data: resourceConfigs, isLoading: isResourceConfigsLoading } = + useGetAgentResourceConfigs( + { + orgName: orgId, + projName: projectId, + agentName: agentId, + }, + { + environment: currentEnvironment.name, + }, + ); + const currentDeployment = deployments?.[currentEnvironment.name]; + const isEnvironmentActive = + currentDeployment?.status === DeploymentStatus.ACTIVE; + + const { data: metrics } = useGetAgentMetrics( + { + orgName: orgId, + projName: projectId, + agentName: agentId, + }, + { + environmentName: currentEnvironment.name, + }, + { + enabled: + !!orgId && + !!projectId && + !!agentId && + !!currentEnvironment.name && + isEnvironmentActive, + enableAutoRefresh: true, + }, + ); const selectedBuildId = extractBuildIdFromImageId(currentDeployment?.imageId); const lastDeployedText = currentDeployment?.lastDeployed ? formatDistanceToNow(new Date(currentDeployment.lastDeployed), { @@ -71,11 +293,9 @@ export function DeployCard(props: DeployCardProps) { }) : "Unknown"; - const isUpdating = updateDeploymentState.isPending; - const handleStop = () => { if (!currentEnvironment?.name || !orgId || !projectId || !agentId) return; - updateDeploymentState.mutate({ + updateDeploymentState({ params: { orgName: orgId, projName: projectId, @@ -90,7 +310,7 @@ export function DeployCard(props: DeployCardProps) { const handleRedeploy = () => { if (!currentEnvironment?.name || !orgId || !projectId || !agentId) return; - updateDeploymentState.mutate({ + updateDeploymentState({ params: { orgName: orgId, projName: projectId, @@ -114,7 +334,7 @@ export function DeployCard(props: DeployCardProps) { }} > - + @@ -136,6 +356,7 @@ export function DeployCard(props: DeployCardProps) { } disableBackground /> @@ -145,82 +366,52 @@ export function DeployCard(props: DeployCardProps) { ); } - if (currentDeployment.status === DeploymentStatus.SUSPENDED) { - return ( - - - - - - - {currentEnvironment?.displayName} - - - - - - - } - disableBackground - /> - - - - ); - } - return ( - + - - {currentEnvironment?.displayName} + + {currentEnvironment?.displayName} Environment - - - {currentDeployment?.status === DeploymentStatus.ACTIVE && ( + + {currentDeployment?.status !== DeploymentStatus.SUSPENDED && ( )} {currentDeployment?.status === DeploymentStatus.SUSPENDED && ( - + + + + + + Resource Usage + + + + + + + + + {agentId && ( + + )} + + + + + + diff --git a/console/workspaces/pages/deploy/src/subComponent/EditResourceConfigsDrawer.tsx b/console/workspaces/pages/deploy/src/subComponent/EditResourceConfigsDrawer.tsx new file mode 100644 index 000000000..4413b4a08 --- /dev/null +++ b/console/workspaces/pages/deploy/src/subComponent/EditResourceConfigsDrawer.tsx @@ -0,0 +1,456 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + Box, + Button, + Collapse, + Form, + FormControlLabel, + Switch, + TextField, +} from "@wso2/oxygen-ui"; +import { Settings } from "@wso2/oxygen-ui-icons-react"; +import { + DrawerWrapper, + DrawerHeader, + DrawerContent, + useFormValidation, +} from "@agent-management-platform/views"; +import { z } from "zod"; +import { useUpdateAgentResourceConfigs } from "@agent-management-platform/api-client"; +import type { + AgentResourceConfigsResponse, + UpdateAgentResourceConfigsRequest, +} from "@agent-management-platform/types"; +import { useCallback, useEffect, useState } from "react"; + +const cpuPattern = /^[0-9]+(\.[0-9]+)?m?$/i; +const memoryPattern = /^[0-9]+(\.[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei)$/i; + +/** Parse CPU quantity to millicores (e.g. 100m -> 100, 0.5 -> 500, 1 -> 1000). */ +function parseCpuQuantity(value: string): number { + if (!value?.trim()) return NaN; + const v = value.trim(); + const match = v.match(/^([0-9]+(?:\.[0-9]+)?)m?$/i); + if (!match) return NaN; + const num = parseFloat(match[1]); + return v.toLowerCase().endsWith("m") ? num : num * 1000; +} + +const MEMORY_UNITS: Record = { + ki: 1024, + mi: 1024 ** 2, + gi: 1024 ** 3, + ti: 1024 ** 4, + pi: 1024 ** 5, + ei: 1024 ** 6, +}; + +/** Parse memory quantity to bytes (e.g. 256Mi -> 268435456). */ +function parseMemoryQuantity(value: string): number { + if (!value?.trim()) return NaN; + const v = value.trim(); + const match = v.match(/^([0-9]+(?:\.[0-9]+)?)\s*(Ki|Mi|Gi|Ti|Pi|Ei)$/i); + if (!match) return NaN; + const num = parseFloat(match[1]); + const unit = match[2].toLowerCase(); + return num * (MEMORY_UNITS[unit] ?? 1); +} + +const resourceConfigsSchema = z + .object({ + replicas: z + .number() + .int("Replicas must be a whole number") + .min(0, "Replicas must be at least 0") + .max(10, "Replicas must be at most 10"), + cpuRequest: z + .string() + .trim() + .min(1, "CPU request is required") + .regex(cpuPattern, "Use format like 100m, 0.5, or 1.5") + .refine((v) => parseCpuQuantity(v) > 0, "CPU request must be greater than zero"), + memoryRequest: z + .string() + .trim() + .min(1, "Memory request is required") + .regex(memoryPattern, "Use format like 256Mi, 512Mi, or 1Gi") + .refine((v) => parseMemoryQuantity(v) > 0, "Memory request must be greater than zero"), + cpuLimit: z + .string() + .trim() + .optional() + .refine((v) => !v || cpuPattern.test(v), "Use format like 500m or 2"), + memoryLimit: z + .string() + .trim() + .optional() + .refine((v) => !v || memoryPattern.test(v), "Use format like 512Mi or 2Gi"), + autoScalingEnabled: z.boolean(), + minReplicas: z.number().int().min(1).optional(), + maxReplicas: z.number().int().min(1).optional(), + }) + .refine( + (data) => { + if (!data.autoScalingEnabled) return true; + return ( + data.minReplicas !== undefined && + data.maxReplicas !== undefined && + data.minReplicas <= data.maxReplicas + ); + }, + { message: "Min replicas must be ≤ max replicas", path: ["maxReplicas"] } + ) + .refine( + (data) => { + if (!data.cpuLimit?.trim()) return true; + const req = parseCpuQuantity(data.cpuRequest); + const lim = parseCpuQuantity(data.cpuLimit); + return !Number.isNaN(req) && !Number.isNaN(lim) && lim >= req; + }, + { message: "CPU limit must be ≥ CPU request", path: ["cpuLimit"] } + ) + .refine( + (data) => { + if (!data.memoryLimit?.trim()) return true; + const req = parseMemoryQuantity(data.memoryRequest); + const lim = parseMemoryQuantity(data.memoryLimit); + return !Number.isNaN(req) && !Number.isNaN(lim) && lim >= req; + }, + { message: "Memory limit must be ≥ memory request", path: ["memoryLimit"] } + ); + +type ResourceConfigsFormValues = z.infer; + +const DEFAULT_FORM_VALUES: ResourceConfigsFormValues = { + replicas: 1, + cpuRequest: "500m", + memoryRequest: "512Mi", + cpuLimit: "", + memoryLimit: "", + autoScalingEnabled: false, + minReplicas: 1, + maxReplicas: 3, +}; + +function toFormValues(config: AgentResourceConfigsResponse): ResourceConfigsFormValues { + return { + replicas: config.replicas ?? 1, + cpuRequest: config.resources?.requests?.cpu ?? "500m", + memoryRequest: config.resources?.requests?.memory ?? "512Mi", + cpuLimit: config.resources?.limits?.cpu ?? "", + memoryLimit: config.resources?.limits?.memory ?? "", + autoScalingEnabled: config.autoScaling?.enabled ?? false, + minReplicas: config.autoScaling?.minReplicas ?? 1, + maxReplicas: config.autoScaling?.maxReplicas ?? 3, + }; +} + +function toRequestPayload( + form: ResourceConfigsFormValues +): UpdateAgentResourceConfigsRequest { + return { + replicas: form.replicas, + resources: { + requests: { + cpu: form.cpuRequest, + memory: form.memoryRequest, + }, + limits: + form.cpuLimit || form.memoryLimit + ? { + ...(form.cpuLimit && { cpu: form.cpuLimit }), + ...(form.memoryLimit && { memory: form.memoryLimit }), + } + : undefined, + }, + autoScaling: { + enabled: form.autoScalingEnabled, + ...(form.autoScalingEnabled && { + minReplicas: form.minReplicas ?? 1, + maxReplicas: form.maxReplicas ?? 3, + }), + }, + }; +} + +export interface EditResourceConfigsDrawerProps { + open: boolean; + onClose: () => void; + resourceConfigs: AgentResourceConfigsResponse | undefined; + orgName: string; + projName: string; + agentName: string; + environment?: string; +} + +export function EditResourceConfigsDrawer({ + open, + onClose, + resourceConfigs, + orgName, + projName, + agentName, + environment, +}: EditResourceConfigsDrawerProps) { + const [formData, setFormData] = useState(DEFAULT_FORM_VALUES); + + const { + errors, + validateField, + validateForm, + clearErrors, + setFieldError, + } = useFormValidation(resourceConfigsSchema); + + const { mutate: updateConfigs, isPending } = useUpdateAgentResourceConfigs(); + + useEffect(() => { + if (open) { + setFormData( + resourceConfigs ? toFormValues(resourceConfigs) : DEFAULT_FORM_VALUES + ); + clearErrors(); + } + }, [open, resourceConfigs, clearErrors]); + + const handleFieldChange = useCallback( + (field: keyof ResourceConfigsFormValues, value: unknown) => { + setFormData((prev) => { + const newData = { ...prev, [field]: value }; + const error = validateField(field, value, newData); + setFieldError(field, error); + return newData; + }); + }, + [validateField, setFieldError] + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const parseResult = resourceConfigsSchema.safeParse(formData); + if (!parseResult.success) { + validateForm(formData); + return; + } + + const payload = toRequestPayload(parseResult.data); + updateConfigs( + { + params: { orgName, projName, agentName }, + body: payload, + query: environment ? { environment } : undefined, + }, + { + onSuccess: () => { + clearErrors(); + onClose(); + }, + } + ); + }, + [ + formData, + validateForm, + updateConfigs, + orgName, + projName, + agentName, + environment, + onClose, + clearErrors, + ] + ); + + const isValid = resourceConfigsSchema.safeParse(formData).success; + + return ( + + } + title="Edit Resource Configurations" + onClose={onClose} + /> + +
+ + + Replicas + + + + handleFieldChange("replicas", parseInt(e.target.value, 10) || 0) + } + error={!!errors.replicas} + helperText={errors.replicas} + slotProps={{ input: { inputProps: { min: 0, max: 10 } } }} + /> + + + handleFieldChange("autoScalingEnabled", checked) + } + disabled={isPending} + /> + } + label="Enable autoscaling" + /> + + + + + handleFieldChange( + "minReplicas", + parseInt(e.target.value, 10) || 1 + ) + } + error={!!errors.minReplicas} + helperText={errors.minReplicas} + slotProps={{ input: { inputProps: { min: 1 } } }} + /> + + + + handleFieldChange( + "maxReplicas", + parseInt(e.target.value, 10) || 3 + ) + } + error={!!errors.maxReplicas} + helperText={errors.maxReplicas} + slotProps={{ input: { inputProps: { min: 1 } } }} + /> + + + + + + + + Resource Requests + + + handleFieldChange("cpuRequest", e.target.value)} + error={!!errors.cpuRequest} + helperText={errors.cpuRequest || "e.g., 100m, 0.5, 1.5"} + /> + + + handleFieldChange("memoryRequest", e.target.value)} + error={!!errors.memoryRequest} + helperText={errors.memoryRequest || "e.g., 256Mi, 512Mi, 1Gi"} + /> + + + + + + Resource Limits (optional) + + + handleFieldChange("cpuLimit", e.target.value)} + error={!!errors.cpuLimit} + helperText={errors.cpuLimit} + /> + + + handleFieldChange("memoryLimit", e.target.value)} + error={!!errors.memoryLimit} + helperText={errors.memoryLimit} + /> + + + + + + + + + +
+
+
+ ); +} diff --git a/console/workspaces/pages/llm-providers/src/index.ts b/console/workspaces/pages/llm-providers/src/index.ts index 64408ec3f..357c558fb 100644 --- a/console/workspaces/pages/llm-providers/src/index.ts +++ b/console/workspaces/pages/llm-providers/src/index.ts @@ -22,8 +22,8 @@ import { AddLLMProvidersOrganization } from './AddLLMProviders.Organization'; import { BrainCircuit } from '@wso2/oxygen-ui-icons-react'; export const metaData = { - title: 'LLM Providers', - description: 'A page component for LLM Provider management', + title: 'LLM Service Providers', + description: 'A page component for LLM Service Provider management', icon: BrainCircuit, path: '/llm-providers', component: LLMProvidersComponent, diff --git a/console/workspaces/pages/llm-providers/src/subComponents/LLMProviderTable.tsx b/console/workspaces/pages/llm-providers/src/subComponents/LLMProviderTable.tsx index 80e5d4415..e975854da 100644 --- a/console/workspaces/pages/llm-providers/src/subComponents/LLMProviderTable.tsx +++ b/console/workspaces/pages/llm-providers/src/subComponents/LLMProviderTable.tsx @@ -147,7 +147,7 @@ export function LLMProviderTable() { color="primary" startIcon={} > - Add Provider + Add Service Provider
); diff --git a/console/workspaces/pages/metrics/src/components/MetricsView/MetricsView.tsx b/console/workspaces/pages/metrics/src/components/MetricsView/MetricsView.tsx index a2458249d..c89d9e506 100644 --- a/console/workspaces/pages/metrics/src/components/MetricsView/MetricsView.tsx +++ b/console/workspaces/pages/metrics/src/components/MetricsView/MetricsView.tsx @@ -216,7 +216,7 @@ export const MetricsView: React.FC = ({ { dataKey: "cpuUsage", name: "Usage", - stroke: theme.palette.primary.main, + stroke: theme.palette.info.main, dot: false, connectNulls: true, unit: " cores", @@ -224,7 +224,7 @@ export const MetricsView: React.FC = ({ { dataKey: "cpuRequests", name: "Requests", - stroke: theme.palette.secondary.main, + stroke: theme.palette.warning.main, dot: false, connectNulls: true, unit: " cores", @@ -310,7 +310,7 @@ export const MetricsView: React.FC = ({ { dataKey: "memoryUsage", name: "Usage", - stroke: theme.palette.primary.main, + stroke: theme.palette.info.main, dot: false, connectNulls: true, unit: " GB", @@ -318,7 +318,7 @@ export const MetricsView: React.FC = ({ { dataKey: "memoryRequests", name: "Requests", - stroke: theme.palette.secondary.main, + stroke: theme.palette.warning.main, dot: false, connectNulls: true, unit: " GB", diff --git a/console/workspaces/pages/overview/src/AddLLMProvider.Component.tsx b/console/workspaces/pages/overview/src/AddLLMProvider.Component.tsx index 6500f304b..fc4999c11 100644 --- a/console/workspaces/pages/overview/src/AddLLMProvider.Component.tsx +++ b/console/workspaces/pages/overview/src/AddLLMProvider.Component.tsx @@ -123,7 +123,7 @@ export const ProviderDisplay: React.FC<{ {provider?.name ?? fallbackLabel}   {provider?.template && ( - + { if (isEditMode && isLoadingConfig) { return ( { if (isEditMode && !isLoadingConfig && (isConfigError || !existingConfig)) { return ( { return ( { - LLM Model Provider + LLM Service Provider { (environments.length < 1 && !isLoadingEnvironments) && ( { } - Provider + Service Provider {providerByEnv[selectedEnvName] ? ( setProviderDrawerOpen(true)} @@ -644,7 +644,7 @@ export const AddLLMProviderComponent: React.FC = () => { } title="No providers available" - description="No LLM providers found in the catalog. Add LLM providers from the organization LLM Providers page first." + description="No LLM service providers found in the catalog. Add LLM service providers from the organization LLM Service Providers page first." action={ orgId ? ( ) : undefined } @@ -676,7 +676,7 @@ export const AddLLMProviderComponent: React.FC = () => { disabled={providers.length === 0} startIcon={} > - Select a Provider + Select a Service Provider @@ -694,7 +694,7 @@ export const AddLLMProviderComponent: React.FC = () => { > } - title="Select Provider" + title="Select Service Provider" onClose={() => setProviderDrawerOpen(false)} /> diff --git a/console/workspaces/pages/overview/src/Configure/subComponents/AgentLLMProvidersSection.tsx b/console/workspaces/pages/overview/src/Configure/subComponents/AgentLLMProvidersSection.tsx index 44d269cea..0f22bfd9e 100644 --- a/console/workspaces/pages/overview/src/Configure/subComponents/AgentLLMProvidersSection.tsx +++ b/console/workspaces/pages/overview/src/Configure/subComponents/AgentLLMProvidersSection.tsx @@ -139,7 +139,7 @@ export function AgentLLMProvidersSection() { startIcon={} disabled={!orgId || !projectId || !agentId} > - Add Provider + Add Service Provider } /> diff --git a/deployments/docker-compose.yml b/deployments/docker-compose.yml index b10acf400..99e06bd83 100644 --- a/deployments/docker-compose.yml +++ b/deployments/docker-compose.yml @@ -106,6 +106,8 @@ services: - API_BASE_URL=http://localhost:9000 - DISABLE_AUTH=true - OBS_API_BASE_URL=http://localhost:9098 + - GUARDRAILS_CATALOG_URL=https://db720294-98fd-40f4-85a1-cc6a3b65bc9a-prod.e1-us-east-azure.choreoapis.dev/api-platform/policy-hub-api/policy-hub-public/v1.0/policies?categories=Guardrails + - GUARDRAILS_DEFINITION_BASE_URL=https://db720294-98fd-40f4-85a1-cc6a3b65bc9a-prod.e1-us-east-azure.choreoapis.dev/api-platform/policy-hub-api/policy-hub-public/v1.0/policies volumes: # Mount source code for hot-reloading with Vite HMR - ../console:/app