diff --git a/backend/internal/adapter/input/http/dto/instance_dto.go b/backend/internal/adapter/input/http/dto/instance_dto.go index d839456..db913d5 100644 --- a/backend/internal/adapter/input/http/dto/instance_dto.go +++ b/backend/internal/adapter/input/http/dto/instance_dto.go @@ -73,6 +73,7 @@ type InstanceResponse struct { LastError string `json:"lastError,omitempty"` Revision int `json:"revision"` Values map[string]interface{} `json:"values,omitempty"` + Replicas int `json:"replicas"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } diff --git a/backend/internal/adapter/input/http/rest/instance_handler.go b/backend/internal/adapter/input/http/rest/instance_handler.go index 78b76d3..94e9941 100644 --- a/backend/internal/adapter/input/http/rest/instance_handler.go +++ b/backend/internal/adapter/input/http/rest/instance_handler.go @@ -566,6 +566,17 @@ func formatTime(value time.Time) string { } func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse { + replicas := 0 + if v, ok := instance.Values["replicaCount"]; ok { + switch n := v.(type) { + case float64: + replicas = int(n) + case int: + replicas = n + case int64: + replicas = int(n) + } + } response := &dto.InstanceResponse{ ID: instance.ID, ClusterID: instance.ClusterID, @@ -583,6 +594,7 @@ func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto LastOperation: string(instance.LastOperation), LastError: instance.LastError, Revision: instance.Revision, + Replicas: replicas, AllowedActions: []string{"view", "update", "delete"}, CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), diff --git a/frontend/src/api/generated-orval/api.schemas.ts b/frontend/src/api/generated-orval/api.schemas.ts index 41c7b7d..64ba63f 100644 --- a/frontend/src/api/generated-orval/api.schemas.ts +++ b/frontend/src/api/generated-orval/api.schemas.ts @@ -271,6 +271,7 @@ export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceR name?: string; namespace?: string; registryId?: string; + replicas?: number; repository?: string; revision?: number; /** 实例当前状态 */ diff --git a/frontend/src/features/artifact/instances/components/InstanceCard.tsx b/frontend/src/features/artifact/instances/components/InstanceCard.tsx index 18302b5..1c2f137 100644 --- a/frontend/src/features/artifact/instances/components/InstanceCard.tsx +++ b/frontend/src/features/artifact/instances/components/InstanceCard.tsx @@ -1,30 +1,17 @@ /** - * Instance Card Component - * Display instance information with action buttons + * Instance Card Component — horizontal row layout + * Compact, readable, with inline scale controls and action buttons */ import React from "react"; import { - Package, - Settings, - StopCircle, - CheckCircle, - XCircle, - Clock, - Network, - Activity, - Box, - Calendar, - GitBranch, - Layers, - AlertTriangle, - History, - HelpCircle, - Minus, - Plus, - Loader2, + Box, Settings, StopCircle, CheckCircle, XCircle, Clock, + Network, Activity, GitBranch, Layers, + AlertTriangle, HelpCircle, Minus, Plus, Loader2, } from "lucide-react"; -import type { InstanceResponse, InstanceStatus } from "@/api"; -import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS, scaleInstance } from "@/api"; +import type { InstanceResponse } from "@/api"; +import { scaleInstance } from "@/api"; +import { useToast } from "@/shared"; +import { formatApiError } from "@/shared/utils"; interface InstanceCardProps { instance: InstanceResponse; @@ -39,101 +26,76 @@ type StatusVisual = { icon: React.ComponentType<{ className?: string }>; color: string; bg: string; - glow: string; + border: string; label: string; - defaultReason: string; }; -const STATUS_INFO_MAP: Record = { - [INSTANCE_STATUS.deployed]: { +const STATUS_INFO_MAP: Record = { + deployed: { icon: CheckCircle, - color: "text-emerald-400", - bg: "bg-gradient-to-r from-emerald-500/20 to-green-500/20 border-emerald-500/40", - glow: "shadow-emerald-500/20", + color: "text-emerald-500", + bg: "bg-emerald-50", + border: "border-emerald-400", label: "Deployed", - defaultReason: "Deployment completed successfully.", }, - [INSTANCE_STATUS.failed]: { + failed: { icon: XCircle, - color: "text-rose-400", - bg: "bg-gradient-to-r from-rose-500/20 to-red-500/20 border-rose-500/40", - glow: "shadow-rose-500/20", + color: "text-rose-500", + bg: "bg-rose-50", + border: "border-rose-400", label: "Failed", - defaultReason: "Last operation reported a failure.", }, - [INSTANCE_STATUS["pending-install"]]: { + "pending-install": { icon: Clock, - color: "text-amber-400", - bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40", - glow: "shadow-amber-500/20", + color: "text-amber-500", + bg: "bg-amber-50", + border: "border-amber-400", label: "Pending Install", - defaultReason: "Installation is in progress.", }, - [INSTANCE_STATUS["pending-upgrade"]]: { + "pending-upgrade": { icon: Clock, - color: "text-amber-400", - bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40", - glow: "shadow-amber-500/20", + color: "text-amber-500", + bg: "bg-amber-50", + border: "border-amber-400", label: "Pending Upgrade", - defaultReason: "Upgrade is in progress.", }, - [INSTANCE_STATUS["pending-rollback"]]: { + "pending-rollback": { icon: Clock, - color: "text-amber-400", - bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40", - glow: "shadow-amber-500/20", + color: "text-amber-500", + bg: "bg-amber-50", + border: "border-amber-400", label: "Pending Rollback", - defaultReason: "Rollback is in progress.", }, - [INSTANCE_STATUS["pending-delete"]]: { + "pending-delete": { icon: Clock, - color: "text-orange-400", - bg: "bg-gradient-to-r from-orange-500/20 to-red-500/20 border-orange-500/40", - glow: "shadow-orange-500/20", + color: "text-orange-500", + bg: "bg-orange-50", + border: "border-orange-400", label: "Pending Delete", - defaultReason: "Deletion is in progress.", }, - [INSTANCE_STATUS.superseded]: { - icon: History, - color: "text-indigo-300", - bg: "bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border-indigo-500/40", - glow: "shadow-indigo-500/20", + superseded: { + icon: Layers, + color: "text-indigo-400", + bg: "bg-indigo-50", + border: "border-indigo-300", label: "Superseded", - defaultReason: "A newer revision has replaced this instance.", }, - [INSTANCE_STATUS.uninstalled]: { + uninstalled: { icon: StopCircle, - color: "text-slate-700", - bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40", - glow: "shadow-slate-500/20", + color: "text-slate-500", + bg: "bg-slate-50", + border: "border-slate-300", label: "Uninstalled", - defaultReason: "Instance has been removed from the cluster.", }, - [INSTANCE_STATUS.unknown]: { + unknown: { icon: HelpCircle, - color: "text-slate-700", - bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40", - glow: "shadow-slate-500/20", + color: "text-slate-400", + bg: "bg-slate-50", + border: "border-slate-300", label: "Unknown", - defaultReason: "Awaiting next state update.", }, }; -const LAST_OPERATION_LABELS: Record = { - [INSTANCE_LAST_OPERATION.install]: "Install", - [INSTANCE_LAST_OPERATION.upgrade]: "Upgrade", - [INSTANCE_LAST_OPERATION.rollback]: "Rollback", - [INSTANCE_LAST_OPERATION.delete]: "Delete", - [INSTANCE_LAST_OPERATION.sync]: "Sync", -}; - -function toTitleCase(value: string): string { - return value - .split(/[\s-]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - export const InstanceCard: React.FC = ({ instance, onModify, @@ -143,44 +105,31 @@ export const InstanceCard: React.FC = ({ onScale, }) => { const [scaling, setScaling] = React.useState(false); - const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus; - const statusInfo = - STATUS_INFO_MAP[normalizedStatus] ?? STATUS_INFO_MAP[INSTANCE_STATUS.unknown]; - const StatusIcon = statusInfo.icon; - const statusLabel = statusInfo.label.toUpperCase(); - const instanceName = instance.name || "Unnamed Instance"; - const repository = instance.repository || "unknown"; - const version = instance.version || "latest"; - const namespace = instance.namespace || "default"; - const revision = instance.revision ?? "-"; - const createdAtText = instance.createdAt - ? new Date(instance.createdAt).toLocaleDateString() - : "N/A"; - const statusReason = - typeof instance.statusReason === "string" && instance.statusReason.trim().length > 0 - ? instance.statusReason.trim() - : statusInfo.defaultReason; - const rawOperation = - typeof instance.lastOperation === "string" ? instance.lastOperation.trim() : ""; - const lastOperationLabel = - rawOperation.length > 0 - ? LAST_OPERATION_LABELS[rawOperation] ?? toTitleCase(rawOperation) - : null; - const lastError = - typeof instance.lastError === "string" ? instance.lastError.trim() : ""; + const { error: toastError } = useToast(); - // Extract replica count from values - const parsedValues = React.useMemo(() => { - if (!instance.values) return null; - try { - return typeof instance.values === "string" - ? JSON.parse(instance.values) - : (instance.values as Record); - } catch { - return null; - } - }, [instance.values]); - const currentReplicas = parsedValues?.replicaCount ?? parsedValues?.replicas ?? 1; + const statusKey = instance.status ?? "unknown"; + const statusInfo = STATUS_INFO_MAP[statusKey] ?? STATUS_INFO_MAP["unknown"]; + const StatusIcon = statusInfo.icon; + + const instanceName = instance.name || "Unnamed"; + const chart = instance.chart || instance.repository || "—"; + const version = instance.version || "—"; + const namespace = instance.namespace || "default"; + const revision = instance.revision ?? "—"; + + const currentReplicas: number = instance.replicas ?? 0; + + const statusReason = + typeof instance.statusReason === "string" && instance.statusReason.trim() + ? instance.statusReason.trim() + : null; + + const lastError = + typeof instance.lastError === "string" && instance.lastError.trim() + ? instance.lastError.trim() + : ""; + + const canScale = instance.status === "deployed" || instance.status === "failed"; const handleScale = async (delta: number) => { const newReplicas = Math.max(0, currentReplicas + delta); @@ -189,179 +138,126 @@ export const InstanceCard: React.FC = ({ setScaling(true); try { - const result = await scaleInstance(instance.clusterId, instance.id, { replicas: newReplicas }); - onScale?.(result.instance ?? instance); + const result = await scaleInstance(instance.clusterId, instance.id, { + replicas: newReplicas, + }); + onScale?.(result.instance ?? { ...instance, replicas: newReplicas }); } catch (err) { - console.error("[InstanceCard] Scale failed:", err); + toastError(formatApiError(err) || "Scale failed"); } finally { setScaling(false); } }; return ( -
+
+ {/* Left color bar (status) */} +
- {/* Header - compact */} -
-
-
- {/* Icon */} -
- -
+ {/* Status icon + label */} +
+ + {statusInfo.label} +
-
-

- {instanceName} -

-
- - {repository} - · - - {version} - -
-
-
- - {/* Status Badge - prominent but smaller */} -
- - - {statusLabel} + {/* Name + Chart info */} +
+
+

{instanceName}

+
+ + + {chart}:{version} + + + + {namespace} + + + + rev{revision}
- - {/* Status reason + last operation - compact inline */} -
- {statusReason} - {lastOperationLabel && ( - <> - | - - {lastOperationLabel} - - - )} -
- {/* Content - compact 3-column layout */} -
- {/* Row 1: Namespace | Revision | Launched */} -
-
-
- -

Namespace

-
-

{namespace}

-
-
-
- -

Revision

-
-

{revision}

-
-
-
- -

Launched

-
-

{createdAtText}

-
+ {/* Status message or error */} + {(statusReason || lastError) && ( +
+ {lastError ? ( +

+ + {lastError} +

+ ) : statusReason ? ( +

{statusReason}

+ ) : null}
+ )} - {lastError && ( -
- -
-

Last error

-

{lastError}

-
-
- )} -
- - {/* Scale Controls */} - {instance.status === "deployed" && ( -
- Replicas: -
+ {/* Scale controls */} +
+ {canScale ? ( + <> - + {currentReplicas} -
-
- )} + + ) : ( + {currentReplicas} repl. + )} +
- {/* Enhanced Actions Bar */} -
-
- - - - - - -
+ {/* Action buttons */} +
+ + + +
); diff --git a/frontend/src/features/artifact/instances/components/ModifyModal.tsx b/frontend/src/features/artifact/instances/components/ModifyModal.tsx index 1003dc1..4d02091 100644 --- a/frontend/src/features/artifact/instances/components/ModifyModal.tsx +++ b/frontend/src/features/artifact/instances/components/ModifyModal.tsx @@ -7,19 +7,17 @@ import React, { useState, useEffect } from "react"; import { Settings } from "lucide-react"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { InstanceResponse, UpdateInstanceRequest } from "@/api"; -import { getValuesSchema, getInstanceValuesDiff } from "@/api"; -import { - Modal, - Button, - FormField, - Input, +import { getInstanceValuesDiff } from "@/api"; +import { + Modal, + Button, + FormField, + Input, Textarea, ErrorState, LoadingState, Badge, - SchemaFormGenerator } from "@/shared/components"; -import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator"; interface ModifyModalProps { instance: InstanceResponse; @@ -37,12 +35,6 @@ export const ModifyModal: React.FC = ({ const [valuesYaml, setValuesYaml] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - - // Values Schema support - const [loadingSchema, setLoadingSchema] = useState(false); - const [valuesSchema, setValuesSchema] = useState(null); - const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml'); - const [formValues, setFormValues] = useState>({}); // Values Diff support const [showDiff, setShowDiff] = useState(false); @@ -56,15 +48,14 @@ export const ModifyModal: React.FC = ({ // Initialize with current values useEffect(() => { setTag(instance.version || ""); - setDescription(""); // InstanceResponse doesn't have description field - - // Parse existing values + setDescription(""); + + // Parse and display existing values as YAML if (instance.values) { try { - const parsedValues = typeof instance.values === 'string' - ? JSON.parse(instance.values) + const parsedValues = typeof instance.values === 'string' + ? JSON.parse(instance.values) : instance.values; - setFormValues(parsedValues); setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues)); } catch (err) { console.error('[ModifyModal] Failed to parse existing values:', err); @@ -72,46 +63,10 @@ export const ModifyModal: React.FC = ({ } } - // Load values schema - loadValuesSchema(); - - // Load values diff + // Load values diff for reference loadValuesDiff(); }, [instance]); - const loadValuesSchema = async () => { - if (!instance.registryId || !instance.repository || !instance.version) { - setValuesSchema(null); - setInputMethod('yaml'); - return; - } - - setLoadingSchema(true); - try { - const schemaResponse = await getValuesSchema({ - registryId: instance.registryId, - repositoryName: instance.repository, - reference: instance.version, - }); - const normalizedSchema = extractJsonSchema(schemaResponse); - setValuesSchema(normalizedSchema); - - if (normalizedSchema) { - setInputMethod('form'); - console.log(`[ModifyModal] Loaded values schema with ${Object.keys(normalizedSchema.properties ?? {}).length} properties`); - } else { - setInputMethod('yaml'); - console.log('[ModifyModal] No values schema available, using YAML input'); - } - } catch (err) { - console.error('[ModifyModal] Failed to load values schema:', err); - setValuesSchema(null); - setInputMethod('yaml'); - } finally { - setLoadingSchema(false); - } - }; - const loadValuesDiff = async () => { if (!instance.clusterId || !instance.id) return; @@ -133,9 +88,7 @@ export const ModifyModal: React.FC = ({ const applyDefaults = () => { if (!diffData?.defaults) return; - const defaultYaml = stringifyYaml(diffData.defaults); - setValuesYaml(defaultYaml); - setFormValues(diffData.defaults); + setValuesYaml(stringifyYaml(diffData.defaults)); }; /** @@ -167,11 +120,6 @@ export const ModifyModal: React.FC = ({ }); }; - const handleFormValuesChange = (values: Record) => { - setFormValues(values); - setValuesYaml(stringifyYaml(values)); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -278,59 +226,21 @@ export const ModifyModal: React.FC = ({ /> - {/* Values Configuration */} -
-
- - {valuesSchema?.properties && ( -
- - -
- )} -
- - {loadingSchema ? ( - - ) : inputMethod === 'form' && valuesSchema ? ( - - ) : ( -