diff --git a/apiclient/types/mcpserver.go b/apiclient/types/mcpserver.go index 887e552c9d..e060ccace0 100644 --- a/apiclient/types/mcpserver.go +++ b/apiclient/types/mcpserver.go @@ -235,6 +235,9 @@ type MCPServer struct { // NeedsUpdate indicates whether the configuration in this server's catalog entry has drift from this server's configuration. NeedsUpdate bool `json:"needsUpdate,omitempty"` + // NeedsK8sUpdate indicates whether this server needs redeployment with new K8s settings + NeedsK8sUpdate bool `json:"needsK8sUpdate,omitempty"` + // NeedsURL indicates whether the server's URL needs to be updated to match the catalog entry. NeedsURL bool `json:"needsURL,omitempty"` @@ -313,9 +316,10 @@ type ProjectMCPServer struct { Runtime Runtime `json:"runtime,omitempty"` // The following status fields are always copied from the MCPServer that this points to. - Configured bool `json:"configured"` - NeedsURL bool `json:"needsURL"` - NeedsUpdate bool `json:"needsUpdate"` + Configured bool `json:"configured"` + NeedsURL bool `json:"needsURL"` + NeedsUpdate bool `json:"needsUpdate"` + NeedsK8sUpdate bool `json:"needsK8sUpdate"` } type ProjectMCPServerList List[ProjectMCPServer] diff --git a/pkg/api/handlers/mcp.go b/pkg/api/handlers/mcp.go index 8df6f7df61..cc3f56996f 100644 --- a/pkg/api/handlers/mcp.go +++ b/pkg/api/handlers/mcp.go @@ -2463,6 +2463,7 @@ func ConvertMCPServer(server v1.MCPServer, credEnv map[string]string, serverURL, MCPCatalogID: server.Spec.MCPCatalogID, ConnectURL: connectURL, NeedsUpdate: server.Status.NeedsUpdate, + NeedsK8sUpdate: server.Status.NeedsK8sUpdate, NeedsURL: server.Spec.NeedsURL, PreviousURL: server.Spec.PreviousURL, MCPServerInstanceUserCount: server.Status.MCPServerInstanceUserCount, diff --git a/pkg/api/handlers/projectmcp.go b/pkg/api/handlers/projectmcp.go index 224b328943..43d8d02d46 100644 --- a/pkg/api/handlers/projectmcp.go +++ b/pkg/api/handlers/projectmcp.go @@ -51,9 +51,10 @@ func convertProjectMCPServer(projectServer *v1.ProjectMCPServer, mcpServer *v1.M Runtime: mcpServer.Spec.Manifest.Runtime, // Default values to show to the user for shared servers: - Configured: true, - NeedsURL: false, - NeedsUpdate: false, + Configured: true, + NeedsURL: false, + NeedsUpdate: false, + NeedsK8sUpdate: false, } pmcp.Alias = mcpServer.Spec.Alias @@ -66,6 +67,7 @@ func convertProjectMCPServer(projectServer *v1.ProjectMCPServer, mcpServer *v1.M pmcp.Configured = convertedServer.Configured pmcp.NeedsURL = convertedServer.NeedsURL pmcp.NeedsUpdate = convertedServer.NeedsUpdate + pmcp.NeedsK8sUpdate = convertedServer.NeedsK8sUpdate } return pmcp diff --git a/pkg/controller/handlers/deployment/deployment.go b/pkg/controller/handlers/deployment/deployment.go index a5ebf8d23b..9ca2f093c8 100644 --- a/pkg/controller/handlers/deployment/deployment.go +++ b/pkg/controller/handlers/deployment/deployment.go @@ -7,6 +7,8 @@ import ( "github.com/obot-platform/nah/pkg/apply" "github.com/obot-platform/nah/pkg/router" + "github.com/obot-platform/obot/apiclient/types" + "github.com/obot-platform/obot/pkg/mcp" v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" "github.com/obot-platform/obot/pkg/system" appsv1 "k8s.io/api/apps/v1" @@ -84,6 +86,7 @@ func (h *Handler) UpdateMCPServerStatus(req router.Request, _ router.Response) e mcpServer.Status.DeploymentConditions = conditions needsUpdate = true } + // Update K8s settings hash if it changed // Note: k8sSettingsHash will be empty string for non-K8s runtimes or if annotation is missing if mcpServer.Status.K8sSettingsHash != k8sSettingsHash { @@ -91,6 +94,33 @@ func (h *Handler) UpdateMCPServerStatus(req router.Request, _ router.Response) e needsUpdate = true } + // Manage NeedsK8sUpdate flag for K8s-compatible runtimes + isK8sRuntime := mcpServer.Spec.Manifest.Runtime == types.RuntimeContainerized || + mcpServer.Spec.Manifest.Runtime == types.RuntimeUVX || + mcpServer.Spec.Manifest.Runtime == types.RuntimeNPX + + if isK8sRuntime { + // Get current K8s settings to compare + var k8sSettings v1.K8sSettings + if err := h.storageClient.Get(req.Ctx, kclient.ObjectKey{ + Namespace: h.mcpNamespace, + Name: system.K8sSettingsName, + }, &k8sSettings); err == nil { + currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec) + + // Only SET to true if drift detected, never clear it + // The flag gets cleared by the RedeployWithK8sSettings API endpoint after successful redeploy + hasHash := k8sSettingsHash != "" + hasDrift := k8sSettingsHash != currentHash + flagNotSet := !mcpServer.Status.NeedsK8sUpdate + + if hasHash && hasDrift && flagNotSet { + mcpServer.Status.NeedsK8sUpdate = true + needsUpdate = true + } + } + } + // Update the MCPServer status if needed if needsUpdate { return h.storageClient.Status().Update(req.Ctx, &mcpServer) diff --git a/pkg/controller/handlers/k8ssettings/k8ssettings.go b/pkg/controller/handlers/k8ssettings/k8ssettings.go new file mode 100644 index 0000000000..42e0a36912 --- /dev/null +++ b/pkg/controller/handlers/k8ssettings/k8ssettings.go @@ -0,0 +1,51 @@ +package k8ssettings + +import ( + "github.com/obot-platform/nah/pkg/router" + "github.com/obot-platform/obot/pkg/mcp" + v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" + kclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// UpdateAllServerK8sSettingsDrift updates the NeedsK8sUpdate status on all MCP servers when K8s settings change +func UpdateAllServerK8sSettingsDrift(req router.Request, _ router.Response) error { + k8sSettings := req.Object.(*v1.K8sSettings) + + // Compute the new hash + currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec) + + // List all MCP servers + var servers v1.MCPServerList + if err := req.List(&servers, &kclient.ListOptions{ + Namespace: req.Object.GetNamespace(), + }); err != nil { + return err + } + + // Update each server's NeedsK8sUpdate status + for i := range servers.Items { + server := &servers.Items[i] + // Skip servers without K8s settings hash (not deployed or non-K8s runtimes) + if server.Status.K8sSettingsHash == "" { + if server.Status.NeedsK8sUpdate { + server.Status.NeedsK8sUpdate = false + if err := req.Client.Status().Update(req.Ctx, server); err != nil { + return err + } + } + continue + } + + // Check if the server needs update + needsK8sUpdate := server.Status.K8sSettingsHash != currentHash + + if server.Status.NeedsK8sUpdate != needsK8sUpdate { + server.Status.NeedsK8sUpdate = needsK8sUpdate + if err := req.Client.Status().Update(req.Ctx, server); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/controller/handlers/mcpserver/mcpserver.go b/pkg/controller/handlers/mcpserver/mcpserver.go index 93662a0210..311d45fdab 100644 --- a/pkg/controller/handlers/mcpserver/mcpserver.go +++ b/pkg/controller/handlers/mcpserver/mcpserver.go @@ -63,6 +63,44 @@ func (h *Handler) DetectDrift(req router.Request, _ router.Response) error { return nil } +// DetectK8sSettingsDrift detects when a server needs redeployment with new K8s settings +func (h *Handler) DetectK8sSettingsDrift(req router.Request, _ router.Response) error { + server := req.Object.(*v1.MCPServer) + + // Only check for containerized or uvx/npx runtimes that might run in K8s + if server.Spec.Manifest.Runtime != types.RuntimeContainerized && + server.Spec.Manifest.Runtime != types.RuntimeUVX && + server.Spec.Manifest.Runtime != types.RuntimeNPX { + return nil + } + + // Skip if server doesn't have K8s settings hash (not yet deployed) + if server.Status.K8sSettingsHash == "" { + return nil + } + + // Get current K8s settings + var k8sSettings v1.K8sSettings + if err := req.Get(&k8sSettings, server.Namespace, system.K8sSettingsName); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to get K8s settings: %w", err) + } + + // Compute current K8s settings hash + currentHash := mcp.ComputeK8sSettingsHash(k8sSettings.Spec) + + // Only SET to true when drift detected, never clear it + // The flag gets cleared by the RedeployWithK8sSettings API endpoint after successful redeploy + if server.Status.K8sSettingsHash != currentHash && !server.Status.NeedsK8sUpdate { + server.Status.NeedsK8sUpdate = true + return req.Client.Status().Update(req.Ctx, server) + } + + return nil +} + func configurationHasDrifted(serverManifest types.MCPServerManifest, entryManifest types.MCPServerCatalogEntryManifest) (bool, error) { // Check if runtime types differ if serverManifest.Runtime != entryManifest.Runtime { diff --git a/pkg/controller/routes.go b/pkg/controller/routes.go index 63de045ee5..84ae2ae1df 100644 --- a/pkg/controller/routes.go +++ b/pkg/controller/routes.go @@ -11,6 +11,7 @@ import ( "github.com/obot-platform/obot/pkg/controller/handlers/auditlogexport" "github.com/obot-platform/obot/pkg/controller/handlers/cleanup" "github.com/obot-platform/obot/pkg/controller/handlers/cronjob" + "github.com/obot-platform/obot/pkg/controller/handlers/k8ssettings" "github.com/obot-platform/obot/pkg/controller/handlers/knowledgefile" "github.com/obot-platform/obot/pkg/controller/handlers/knowledgeset" "github.com/obot-platform/obot/pkg/controller/handlers/knowledgesource" @@ -236,6 +237,7 @@ func (c *Controller) setupRoutes() { root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.DeleteServersForAnonymousUser) root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.CleanupNestedCompositeServers) root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.DetectDrift) + root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.DetectK8sSettingsDrift) root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.EnsureMCPServerInstanceUserCount) root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.EnsureMCPServerSecretInfo) root.Type(&v1.MCPServer{}).HandlerFunc(mcpserver.EnsureCompositeComponents) @@ -310,6 +312,9 @@ func (c *Controller) setupRoutes() { // ScheduledAuditLogExport root.Type(&v1.ScheduledAuditLogExport{}).HandlerFunc(scheduledAuditLogExportHandler.ScheduleExports) + // K8sSettings + root.Type(&v1.K8sSettings{}).HandlerFunc(k8ssettings.UpdateAllServerK8sSettingsDrift) + c.toolRefHandler = toolRef c.mcpCatalogHandler = mcpCatalog c.adminWorkspaceHandler = adminWorkspaceHandler diff --git a/pkg/mcp/details.go b/pkg/mcp/details.go index adc5a15c4d..bccb8eed8c 100644 --- a/pkg/mcp/details.go +++ b/pkg/mcp/details.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "fmt" "io" "slices" @@ -9,6 +10,9 @@ import ( "github.com/obot-platform/obot/apiclient/types" ) +// ErrServerNotRunning is returned when an MCP server is not running +var ErrServerNotRunning = errors.New("mcp server is not running") + // GetServerDetails will get the details of a specific MCP server based on its configuration, if the backend supports it. // If the server is remote, it will return an error as remote servers do not support this operation. // If the backend does not support the operation, it will return an [ErrNotSupportedByBackend] error. @@ -17,6 +21,19 @@ func (sm *SessionManager) GetServerDetails(ctx context.Context, serverConfig Ser return types.MCPServerDetails{}, fmt.Errorf("getting server details is not supported for remote servers") } + // Try to get details first - only deploy if server doesn't exist + // This prevents unnecessary redeployments that would update K8s settings and clear the NeedsK8sUpdate flag + details, err := sm.backend.getServerDetails(ctx, serverConfig.MCPServerName) + if err == nil { + return details, nil + } + + // Only deploy if server is not running - for any other error, return it + if !errors.Is(err, ErrServerNotRunning) { + return types.MCPServerDetails{}, err + } + + // Server not running - deploy it if err := sm.deployServer(ctx, serverConfig); err != nil { return types.MCPServerDetails{}, err } @@ -32,6 +49,19 @@ func (sm *SessionManager) StreamServerLogs(ctx context.Context, serverConfig Ser return nil, fmt.Errorf("streaming logs is not supported for remote servers") } + // Check if server exists first - only deploy if it doesn't + // This prevents unnecessary redeployments that would update K8s settings and clear the NeedsK8sUpdate flag + _, err := sm.backend.getServerDetails(ctx, serverConfig.MCPServerName) + if err == nil { + return sm.backend.streamServerLogs(ctx, serverConfig.MCPServerName) + } + + // Only deploy if server is not running - for any other error, return it + if !errors.Is(err, ErrServerNotRunning) { + return nil, err + } + + // Server not running - deploy it if err := sm.deployServer(ctx, serverConfig); err != nil { return nil, err } diff --git a/pkg/storage/apis/obot.obot.ai/v1/mcpserver.go b/pkg/storage/apis/obot.obot.ai/v1/mcpserver.go index 1aa6b093ee..53aca11218 100644 --- a/pkg/storage/apis/obot.obot.ai/v1/mcpserver.go +++ b/pkg/storage/apis/obot.obot.ai/v1/mcpserver.go @@ -143,6 +143,8 @@ type MCPServerStatus struct { // This field is only populated for servers running in Kubernetes runtime. // For Docker, local, or remote runtimes, this field is omitted entirely. K8sSettingsHash string `json:"k8sSettingsHash,omitempty"` + // NeedsK8sUpdate indicates whether this server needs redeployment with new K8s settings + NeedsK8sUpdate bool `json:"needsK8sUpdate,omitempty"` // AuditLogTokenHash is the hash of the token used to submit audit logs. AuditLogTokenHash string `json:"auditLogTokenHash,omitempty"` // ObservedCompositeManifestHash is the hash of the server's manifest the last time all component servers were updated to match the composite server. diff --git a/pkg/storage/openapi/generated/openapi_generated.go b/pkg/storage/openapi/generated/openapi_generated.go index 0ec0b617d5..bac861a4a6 100644 --- a/pkg/storage/openapi/generated/openapi_generated.go +++ b/pkg/storage/openapi/generated/openapi_generated.go @@ -4381,6 +4381,13 @@ func schema_obot_platform_obot_apiclient_types_MCPServer(ref common.ReferenceCal Format: "", }, }, + "needsK8sUpdate": { + SchemaProps: spec.SchemaProps{ + Description: "NeedsK8sUpdate indicates whether this server needs redeployment with new K8s settings", + Type: []string{"boolean"}, + Format: "", + }, + }, "needsURL": { SchemaProps: spec.SchemaProps{ Description: "NeedsURL indicates whether the server's URL needs to be updated to match the catalog entry.", @@ -7379,8 +7386,15 @@ func schema_obot_platform_obot_apiclient_types_ProjectMCPServer(ref common.Refer Format: "", }, }, + "needsK8sUpdate": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, }, - Required: []string{"Metadata", "ProjectMCPServerManifest", "name", "description", "icon", "userID", "configured", "needsURL", "needsUpdate"}, + Required: []string{"Metadata", "ProjectMCPServerManifest", "name", "description", "icon", "userID", "configured", "needsURL", "needsUpdate", "needsK8sUpdate"}, }, }, Dependencies: []string{ @@ -14813,6 +14827,13 @@ func schema_storage_apis_obotobotai_v1_MCPServerStatus(ref common.ReferenceCallb Format: "", }, }, + "needsK8sUpdate": { + SchemaProps: spec.SchemaProps{ + Description: "NeedsK8sUpdate indicates whether this server needs redeployment with new K8s settings", + Type: []string{"boolean"}, + Format: "", + }, + }, "auditLogTokenHash": { SchemaProps: spec.SchemaProps{ Description: "AuditLogTokenHash is the hash of the token used to submit audit logs.", diff --git a/ui/user/src/app.css b/ui/user/src/app.css index a9319b23e7..24e352853b 100644 --- a/ui/user/src/app.css +++ b/ui/user/src/app.css @@ -45,6 +45,18 @@ --color-red-900: #7f1d1d; --color-red-950: #450a0a; + --color-orange-50: oklch(0.98 0.016 73.684); + --color-orange-100: oklch(0.954 0.038 75.164); + --color-orange-200: oklch(0.901 0.076 70.697); + --color-orange-300: oklch(0.837 0.128 66.29); + --color-orange-400: oklch(0.75 0.183 55.934); + --color-orange-500: oklch(0.705 0.213 47.604); + --color-orange-600: oklch(0.646 0.222 41.116); + --color-orange-700: oklch(0.553 0.195 38.402); + --color-orange-800: oklch(0.47 0.157 37.304); + --color-orange-900: oklch(0.408 0.123 38.172); + --color-orange-950: oklch(0.266 0.079 36.259); + --color-blue-50: #eff5ff; --color-blue-100: #dce7fd; --color-blue-200: #c0d5fd; @@ -118,15 +130,19 @@ } dialog { - border-radius: 0.75rem; /* rounded-xl */ + border-radius: 0.75rem; + /* rounded-xl */ border-width: 1px; border-color: var(--surface3); margin: auto; background-color: white; - font-size: 0.875rem; /* text-sm */ + font-size: 0.875rem; + /* text-sm */ color: black; - box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); /* shadow-lg */ + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); + /* shadow-lg */ margin: auto; + .dark & { background-color: var(--surface2); color: white; @@ -164,7 +180,8 @@ blockquote { border-left-width: 2px; border-color: var(--color-gray-200); - padding-left: 1rem; /* pl-4 */ + padding-left: 1rem; + /* pl-4 */ font-style: italic; } @@ -210,6 +227,7 @@ .no-scrollbar { scrollbar-width: none; } + .no-scrollbar::-webkit-scrollbar { display: none; } @@ -227,12 +245,15 @@ @layer components { .icon-button-colors { color: var(--color-gray-500); + &:hover { background-color: var(--surface3); } + &:focus { outline: none; } + .dark & { color: var(--color-gray-400); } @@ -240,28 +261,38 @@ .icon-button { display: flex; - min-height: 2.5rem; /* min-h-10 */ - min-width: 2.5rem; /* min-w-10 */ + min-height: 2.5rem; + /* min-h-10 */ + min-width: 2.5rem; + /* min-w-10 */ align-items: center; justify-content: center; - border-radius: 9999px; /* rounded-full */ - padding: 0.625rem; /* p-2.5 */ - font-size: 0.875rem; /* text-sm */ + border-radius: 9999px; + /* rounded-full */ + padding: 0.625rem; + /* p-2.5 */ + font-size: 0.875rem; + /* text-sm */ transition-property: all; transition-duration: 200ms; color: var(--color-gray-500); + &:hover { background-color: var(--surface3); } + &:focus { outline: none; } + .dark & { color: var(--color-gray-400); } + &:disabled { opacity: 0.5; } + &:disabled:hover { background-color: transparent; } @@ -269,67 +300,90 @@ .button-icon { display: flex; - min-height: 2.5rem; /* min-h-10 */ - min-width: 2.5rem; /* min-w-10 */ + min-height: 2.5rem; + /* min-h-10 */ + min-width: 2.5rem; + /* min-w-10 */ align-items: center; justify-content: center; - border-radius: 9999px; /* rounded-full */ - padding: 0.625rem; /* p-2.5 */ - font-size: 0.875rem; /* text-sm */ + border-radius: 9999px; + /* rounded-full */ + padding: 0.625rem; + /* p-2.5 */ + font-size: 0.875rem; + /* text-sm */ transition-property: all; transition-duration: 200ms; color: var(--color-gray-500); + &:hover { background-color: var(--surface3); } + &:focus { outline: none; } + .dark & { color: var(--color-gray-400); } + &:disabled { opacity: 0.5; } + &:disabled:hover { background-color: transparent; } } .icon-default { - height: 1.25rem; /* h-5 */ - width: 1.25rem; /* w-5 */ + height: 1.25rem; + /* h-5 */ + width: 1.25rem; + /* w-5 */ } .icon-button-small { - border-radius: 0.5rem; /* rounded-lg */ - padding: 0.25rem; /* p-1 */ - font-size: 0.875rem; /* text-sm */ + border-radius: 0.5rem; + /* rounded-lg */ + padding: 0.25rem; + /* p-1 */ + font-size: 0.875rem; + /* text-sm */ transition-property: all; transition-duration: 200ms; color: var(--color-gray-500); + &:hover { background-color: var(--surface3); } + &:focus { outline: none; } + .dark & { color: var(--color-gray-400); } } .icon-default-size { - height: 1.25rem; /* h-5 */ - width: 1.25rem; /* w-5 */ + height: 1.25rem; + /* h-5 */ + width: 1.25rem; + /* w-5 */ } .button-colors { background-color: var(--surface3); border-width: 0; + &:hover { - background-color: rgb(229 231 235 / 0.5); /* bg-gray-200/50 */ + background-color: rgb(229 231 235 / 0.5); + /* bg-gray-200/50 */ } + .dark &:hover { background-color: var(--color-gray-700); } @@ -343,6 +397,7 @@ border-color: var(--surface3); transition-property: all; transition-duration: 200ms; + &:hover { background-color: color-mix(in oklab, var(--surface3) 90%, var(--color-black)); border-color: color-mix(in oklab, var(--surface3) 90%, var(--color-black)); @@ -356,6 +411,7 @@ &:disabled { opacity: 0.5; } + &:disabled:hover { background-color: var(--surface3); border-color: var(--surface3); @@ -365,8 +421,10 @@ .button-small { display: flex; align-items: center; - gap: 0.25rem; /* gap-1 */ - font-size: 0.875rem; /* text-sm */ + gap: 0.25rem; + /* gap-1 */ + font-size: 0.875rem; + /* text-sm */ border-radius: 1.5rem; padding: 0.5rem 1.25rem; @@ -386,19 +444,25 @@ } .button-secondary { - border-radius: 1.5rem; /* rounded-3xl */ - border-width: 2px; /* border-2 */ - padding: 0.5rem 1.25rem; /* px-5 py-2 */ + border-radius: 1.5rem; + /* rounded-3xl */ + border-width: 2px; + /* border-2 */ + padding: 0.5rem 1.25rem; + /* px-5 py-2 */ border-color: var(--color-gray-100); transition-property: all; transition-duration: 300ms; + &:hover { border-color: var(--color-gray-200); background-color: var(--color-gray-200); } + .dark & { border-color: var(--color-gray-900); } + .dark &:hover { border-color: var(--color-gray-800); background-color: var(--color-gray-800); @@ -406,24 +470,31 @@ } .button-primary { - border-radius: 1.5rem; /* rounded-3xl */ - border-width: 2px; /* border-2 */ - padding: 0.5rem 1.25rem; /* px-5 py-2 */ + border-radius: 1.5rem; + /* rounded-3xl */ + border-width: 2px; + /* border-2 */ + padding: 0.5rem 1.25rem; + /* px-5 py-2 */ border-color: var(--color-primary); background-color: var(--color-primary); color: white; transition-property: all; transition-duration: 300ms; + &:hover { border-color: color-mix(in oklab, var(--color-primary) 75%, var(--color-background)); } + .dark & { color: var(--color-gray-50); } + &:disabled { cursor: default; opacity: 0.5; } + &:disabled:hover { border-color: var(--color-primary); background-color: var(--color-primary); @@ -434,19 +505,25 @@ display: flex; align-items: center; justify-content: center; - border-radius: 9999px; /* rounded-full */ - padding: 0.5rem; /* p-2 */ + border-radius: 9999px; + /* rounded-full */ + padding: 0.5rem; + /* p-2 */ color: var(--color-gray); + &:hover { color: var(--color-primary); background-color: var(--color-gray-100); } + &:focus { outline: none; } + .dark &:hover { background-color: var(--color-gray-950); } + &:disabled:hover { color: var(--color-gray); background-color: transparent; @@ -460,15 +537,18 @@ padding: 1rem; text-align: left; font-size: var(--text-sm); + &:hover { text-decoration: underline; } + &:disabled { &:hover { @media (hover: hover) { text-decoration-line: none; } } + cursor: default; opacity: 0.5; } @@ -479,12 +559,15 @@ align-items: center; gap: 0.25rem; border-radius: 9999px; - background-color: rgb(239 68 68 / 0.2); /* bg-red-500/20 */ - padding: 1rem 1rem; /* px-4 py-2 */ + background-color: rgb(239 68 68 / 0.2); + /* bg-red-500/20 */ + padding: 1rem 1rem; + /* px-4 py-2 */ font-size: 0.875rem; color: var(--color-red-500); transition-property: all; transition-duration: 200ms; + &:hover { background-color: var(--color-red-500); color: white; @@ -501,11 +584,16 @@ width: 100%; align-items: center; justify-content: center; - gap: 0.375rem; /* gap-1.5 */ - border-radius: 9999px; /* rounded-full */ - padding: 0.5rem 2rem; /* p-2 px-8 */ - font-size: 1.125rem; /* text-lg */ - font-weight: 600; /* font-semibold */ + gap: 0.375rem; + /* gap-1.5 */ + border-radius: 9999px; + /* rounded-full */ + padding: 0.5rem 2rem; + /* p-2 px-8 */ + font-size: 1.125rem; + /* text-lg */ + font-weight: 600; + /* font-semibold */ background-color: var(--surface1); transition-property: background-color; transition-duration: 200ms; @@ -527,15 +615,20 @@ display: flex; align-items: center; justify-content: center; - border-radius: 9999px; /* rounded-full */ + border-radius: 9999px; + /* rounded-full */ color: var(--color-gray); padding: 0; + &:focus { outline: none; } + .dark &:hover { - background-color: rgb(55 65 81 / 0.3); /* bg-gray-700/30 */ + background-color: rgb(55 65 81 / 0.3); + /* bg-gray-700/30 */ } + &:hover { color: var(--color-primary); background-color: transparent; @@ -590,19 +683,23 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor); --tw-ring-color: var(--color-primary); } + .dark & { background-color: var(--surface1); border: 1px solid var(--surface3); } + &.error { /* important to override default styles & precedence over dark mode */ border: 1px solid var(--color-red-500) !important; - background-color: rgb(239 68 68 / 0.2) !important; /* bg-red-500/20 */ + background-color: rgb(239 68 68 / 0.2) !important; + /* bg-red-500/20 */ color: var(--color-red-500) !important; } } @@ -629,6 +726,7 @@ outline: none; transition-property: all; transition-duration: 300ms; + &:focus, &:active { border-color: var(--color-primary); @@ -661,6 +759,7 @@ font-size: 0.875rem; color: black; box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); + .dark & { background-color: var(--surface2); color: white; @@ -706,17 +805,22 @@ display: flex; align-items: center; justify-content: center; - border-radius: 9999px; /* rounded-full */ - padding: 0.625rem; /* p-2.5 */ + border-radius: 9999px; + /* rounded-full */ + padding: 0.625rem; + /* p-2.5 */ transition-property: all; transition-duration: 200ms; color: var(--color-gray-500); + &:hover { background-color: var(--surface3); } + &:focus { outline: none; } + .dark & { color: var(--color-gray-400); } @@ -744,18 +848,23 @@ .card { display: flex; - max-width: 32rem; /* max-w-lg */ + max-width: 32rem; + /* max-w-lg */ flex-direction: column; align-items: center; align-self: center; - border-radius: 0.375rem; /* rounded-md */ + border-radius: 0.375rem; + /* rounded-md */ border: 1px solid transparent; background-color: white; - padding: 1rem; /* p-4 */ - box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* shadow-sm */ + padding: 1rem; + /* p-4 */ + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + /* shadow-sm */ @media (min-width: 768px) { - padding: 1.5rem; /* md:p-6 */ + padding: 1.5rem; + /* md:p-6 */ } .dark & { @@ -785,6 +894,7 @@ font-size: 0.875rem; transition-property: all; transition-duration: 200ms; + &:hover { background-color: var(--surface2); } @@ -843,6 +953,7 @@ opacity: 0.5; cursor: default; } + &:disabled:hover { background-color: rgb(239 68 68 / 0.1); } @@ -877,6 +988,7 @@ opacity: 0.5; cursor: default; } + &:disabled:hover { background-color: color-mix(in oklab, var(--color-primary) /* #4f7ef3 */ 10%, transparent); } @@ -896,6 +1008,7 @@ outline: none; transition-property: all; transition-duration: 300ms; + &:hover { border-color: var(--surface2); } @@ -920,6 +1033,7 @@ height: 100%; display: inline-block; padding: 1.25rem 0.5rem; + &::after { content: ''; position: absolute; @@ -931,6 +1045,7 @@ opacity: 0; transition: opacity 0.3s ease; } + &:hover::after { opacity: 1; } @@ -973,6 +1088,7 @@ font-weight: 500; transition-property: all; transition-duration: 300ms; + &:hover { color: var(--color-blue-600); } @@ -1026,6 +1142,7 @@ &:first-child { margin-top: 0; } + &:last-child { margin-bottom: 0; } @@ -1033,15 +1150,19 @@ & h1 { margin-top: 1rem; - margin-bottom: 1rem; /* my-4 */ - font-size: 1.5rem; /* text-2xl */ - font-weight: 700; /* font-bold */ + margin-bottom: 1rem; + /* my-4 */ + font-size: 1.5rem; + /* text-2xl */ + font-weight: 700; + /* font-bold */ } & h2 { margin-top: 1rem; margin-bottom: 1rem; - font-size: 1.25rem; /* text-xl */ + font-size: 1.25rem; + /* text-xl */ font-weight: 700; } @@ -1049,7 +1170,8 @@ & h4 { margin-top: 1rem; margin-bottom: 1rem; - font-size: 1rem; /* text-base */ + font-size: 1rem; + /* text-base */ font-weight: 700; } @@ -1069,6 +1191,7 @@ & a { color: var(--color-blue-500); text-decoration: underline; + &:hover { color: var(--color-blue-600); } @@ -1112,6 +1235,7 @@ & th { padding: 0.5rem 1rem; border-bottom: 1px solid var(--surface3); + &:not(:last-child) { border-right: 1px solid var(--surface3); } @@ -1119,6 +1243,7 @@ & td { padding: 0.5rem 1rem; + &:not(:last-child) { border-right: 1px solid var(--surface3); } diff --git a/ui/user/src/lib/components/mcp/DeploymentsView.svelte b/ui/user/src/lib/components/mcp/DeploymentsView.svelte index 6cf480e24c..0e6f6624e7 100644 --- a/ui/user/src/lib/components/mcp/DeploymentsView.svelte +++ b/ui/user/src/lib/components/mcp/DeploymentsView.svelte @@ -23,7 +23,7 @@ hasEditableConfiguration, requiresUserUpdate } from '$lib/services/chat/mcp'; - import { profile, mcpServersAndEntries } from '$lib/stores'; + import { profile, mcpServersAndEntries, version } from '$lib/stores'; import { formatTimeAgo } from '$lib/time'; import { setSearchParamsToLocalStorage } from '$lib/url'; import { getUserDisplayName, openUrl } from '$lib/utils'; @@ -66,6 +66,13 @@ onlyMyServers?: boolean; } + const SERVER_UPGRADES_AVAILABLE = { + NONE: 'Up to date', + BOTH: 'Server & Scheduling Updates', + SERVER: 'New Server Config Update', + K8S: 'Scheduling Update' + }; + let { entity = 'catalog', usersMap = new Map(), @@ -92,8 +99,14 @@ | { type: 'single'; server: MCPCatalogServer; onConfirm?: () => void } | undefined >(); - let showDeleteConfirm = $state< + let showK8sUpgradeConfirm = $state< { type: 'multi' } | { type: 'single'; server: MCPCatalogServer } | undefined + >(undefined); + + let showDeleteConfirm = $state< + | { type: 'multi' } + | { type: 'single'; server: MCPCatalogServer; onConfirm?: () => void } + | undefined >(); let selected = $state>({}); let updating = $state>({}); @@ -163,6 +176,39 @@ const compositeParentName = compositeParent ? compositeParent.alias || compositeParent.manifest.name : ''; + + const needsUpdate = deployment.needsUpdate && !deployment.compositeName; + const needsK8sUpdate = + version.current.engine === 'kubernetes' && + deployment.needsK8sUpdate && + !deployment.compositeName; + + let updateStatus = deployment.deploymentStatus || 'Unknown'; + let updatesAvailable = [SERVER_UPGRADES_AVAILABLE.NONE]; + + if ( + !needsUpdate && + !needsK8sUpdate && + deployment.deploymentStatus?.toLocaleLowerCase().includes('unavailable') + ) { + updateStatus = SERVER_UPGRADES_AVAILABLE.NONE; + updatesAvailable = [SERVER_UPGRADES_AVAILABLE.NONE]; + } else if (deployment.deploymentStatus?.toLocaleLowerCase().includes('available')) { + if (needsUpdate && needsK8sUpdate) { + updateStatus = SERVER_UPGRADES_AVAILABLE.BOTH; + updatesAvailable = [SERVER_UPGRADES_AVAILABLE.SERVER, SERVER_UPGRADES_AVAILABLE.K8S]; + } else if (needsUpdate) { + updateStatus = SERVER_UPGRADES_AVAILABLE.SERVER; + updatesAvailable = [SERVER_UPGRADES_AVAILABLE.SERVER]; + } else if (needsK8sUpdate) { + updateStatus = SERVER_UPGRADES_AVAILABLE.K8S; + updatesAvailable = [SERVER_UPGRADES_AVAILABLE.K8S]; + } else { + updateStatus = SERVER_UPGRADES_AVAILABLE.NONE; + updatesAvailable = [SERVER_UPGRADES_AVAILABLE.NONE]; + } + } + return { ...deployment, displayName: deployment.alias || deployment.manifest.name || '', @@ -179,7 +225,9 @@ : false, isMyServer: (deployment.catalogEntryID && deployment.userID === profile.current.id) || - (powerUserID === profile.current.id && powerUserWorkspaceID === id) + (powerUserID === profile.current.id && powerUserWorkspaceID === id), + updateStatus, + updatesAvailable }; }) .filter((d) => !d.disabled && (onlyMyServers ? d.isMyServer : true)); @@ -236,6 +284,10 @@ await reload(); } + async function handleK8sBulkUpdate(selections: typeof selected) { + return Promise.all(Object.values(selections).map((server) => updateK8sSettings(server))); + } + async function handleBulkRestart() { restarting = true; try { @@ -277,6 +329,41 @@ delete updating[server.id]; } + async function updateK8sSettings(server?: MCPCatalogServer) { + if (!server) return; + updating[server.id] = { inProgress: true, error: '' }; + + const mcpServerId = server.id; + const catalogEntryId = server.catalogEntryID; + // Use powerUserWorkspaceID if available, otherwise use the component's workspace id + const workspaceId = server.powerUserWorkspaceID || (entity === 'workspace' ? id : undefined); + + let result: unknown | undefined = undefined; + + try { + result = await (workspaceId + ? catalogEntryId + ? ChatService.redeployWorkspaceCatalogEntryServerWithK8sSettings( + workspaceId, + catalogEntryId, + mcpServerId + ) + : ChatService.redeployWorkspaceK8sServerWithK8sSettings(workspaceId, mcpServerId) + : catalogEntryId + ? AdminService.redeployMCPCatalogServerWithK8sSettings(catalogEntryId, mcpServerId) + : AdminService.redeployWithK8sSettings(mcpServerId)); + } catch (err) { + updating[server.id] = { + inProgress: false, + error: err instanceof Error ? err.message : 'An unknown error occurred' + }; + + return undefined; + } + + delete updating[server.id]; + return result; + } async function handleSingleDelete(server: MCPCatalogServer) { if (server.compositeName) { @@ -369,14 +456,14 @@ bind:this={tableRef} data={tableData} fields={entity === 'workspace' - ? ['displayName', 'type', 'deploymentStatus', 'created'] - : ['displayName', 'type', 'deploymentStatus', 'userName', 'registry', 'created']} - filterable={['displayName', 'type', 'deploymentStatus', 'userName', 'registry']} + ? ['displayName', 'type', 'updatesAvailable', 'created'] + : ['displayName', 'type', 'updatesAvailable', 'userName', 'registry', 'created']} + filterable={['displayName', 'type', 'updatesAvailable', 'userName', 'registry']} {filters} headers={[ { title: 'Name', property: 'displayName' }, { title: 'User', property: 'userName' }, - { title: 'Status', property: 'deploymentStatus' } + { title: 'Status', property: 'updatesAvailable' } ]} onClickRow={(d, isCtrlClick) => { setLastVisitedMcpServer(d); @@ -389,7 +476,7 @@ {onClearAllFilters} {onSort} {initSort} - sortable={['displayName', 'type', 'deploymentStatus', 'userName', 'registry', 'created']} + sortable={['displayName', 'type', 'updatesAvailable', 'userName', 'registry', 'created']} noDataMessage="No catalog servers added." classes={{ root: 'rounded-none rounded-b-md shadow-none', @@ -398,8 +485,21 @@ sectionedBy="isMyServer" sectionPrimaryTitle="My Deployments" sectionSecondaryTitle="All Deployments" - setRowClasses={(d) => - d.needsUpdate ? 'bg-primary/10' : requiresUserUpdate(d) ? 'bg-yellow-500/10' : ''} + setRowClasses={(d) => { + if (d.needsUpdate && d.needsK8sUpdate) { + return 'bg-orange-500/5 hover:bg-orange-500/10 border-orange-500/20'; + } + + if (d.needsUpdate) { + return 'bg-primary/5 hover:bg-primary/10 border-primary/20'; + } + + if (d.needsK8sUpdate) { + return 'bg-yellow-500/5 hover:bg-yellow-500/10 border-yellow-500/20'; + } + + return ''; + }} > {#snippet onRenderColumn(property, d)} {#if property === 'displayName'} @@ -422,15 +522,8 @@ {:else if property === 'created'} {formatTimeAgo(d.created).relativeTime} - {:else if property === 'deploymentStatus'} -
- {d.deploymentStatus || '--'} - {#if d.needsUpdate && !d.compositeName} -
- -
- {/if} -
+ {:else if property === 'updatesAvailable'} + {d.updateStatus || '--'} {:else} {d[property as keyof typeof d]} {/if} @@ -526,6 +619,7 @@ Update Server {/if} + + {/if} + {#if d.isMyServer || profile.current?.hasAdminAccess?.()} {#if d.manifest.runtime !== 'remote' && !readonly && isAtLeastPowerUser} +