fix: UI redesign — horizontal instance rows, proper scaling, readable tag cards

- Backend: add replicas field to InstanceResponse (extracted from values.replicaCount)
- InstanceCard: complete redesign as horizontal row layout
  - Status bar | Name+Chart | Replicas +/- | Action buttons
  - Scale controls show for deployed AND failed statuses (scale to 0)
  - Fix replicas display using new instance.replicas backend field
- InstancesManagementPage: vertical row list + onScale callback to update state
- TagCard: restore proper padding (p-4), min-width, readable button sizes
- ArtifactBrowserPage: reduce grid density (sm:1 md:2 lg:3)
- ModifyModal: simplify to YAML-only editing with current values pre-populated
  - Remove schema-based form generator
  - Keep values-diff as collapsible reference panel
This commit is contained in:
Ivan087
2026-05-13 12:30:52 +08:00
parent 28ecb2e636
commit 49b92e66c3
8 changed files with 247 additions and 462 deletions

View File

@ -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"`
}

View File

@ -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"),

View File

@ -271,6 +271,7 @@ export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceR
name?: string;
namespace?: string;
registryId?: string;
replicas?: number;
repository?: string;
revision?: number;
/** 实例当前状态 */

View File

@ -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<InstanceStatus, StatusVisual> = {
[INSTANCE_STATUS.deployed]: {
const STATUS_INFO_MAP: Record<string, StatusVisual> = {
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<string, string> = {
[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<InstanceCardProps> = ({
instance,
onModify,
@ -143,44 +105,31 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
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<string, any>);
} 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<InstanceCardProps> = ({
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 (
<div className="hover-lift group relative bg-white border border-slate-200 rounded-xl hover:border-blue-500/50 duration-200 overflow-hidden">
<div className="hover-lift group relative bg-white border border-slate-200 rounded-lg flex items-center gap-3 px-4 py-3 transition-all">
{/* Left color bar (status) */}
<div className={`self-stretch w-1 rounded-full flex-shrink-0 ${statusInfo.bg} border ${statusInfo.border}`} />
{/* Header - compact */}
<div className="relative px-4 py-3 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Icon */}
<div className="flex-shrink-0 p-2 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 rounded-lg border border-blue-500/30">
<Box className="w-5 h-5 text-blue-400" />
</div>
{/* Status icon + label */}
<div className="flex items-center gap-1.5 flex-shrink-0 min-w-[90px]">
<StatusIcon className={`w-4 h-4 ${statusInfo.color}`} />
<span className={`text-xs font-semibold ${statusInfo.color}`}>{statusInfo.label}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-slate-950 truncate">
{instanceName}
</h3>
<div className="flex items-center gap-1.5 mt-1">
<Package className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
<span className="text-sm text-slate-500 font-mono truncate">{repository}</span>
<span className="text-slate-300 flex-shrink-0">&#183;</span>
<span className="px-1.5 py-0.5 text-[11px] font-semibold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded flex-shrink-0">
{version}
</span>
</div>
</div>
</div>
{/* Status Badge - prominent but smaller */}
<div
className={`flex items-center gap-1.5 px-3 py-1 rounded-full border shadow-sm ${statusInfo.bg} flex-shrink-0`}
>
<StatusIcon className={`w-3.5 h-3.5 ${statusInfo.color}`} />
<span className={`text-xs font-semibold ${statusInfo.color} uppercase tracking-wide`}>
{statusLabel}
{/* Name + Chart info */}
<div className="flex-1 min-w-0 flex items-center gap-4">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-slate-900 truncate">{instanceName}</h4>
<div className="flex items-center gap-3 text-xs text-slate-500 mt-0.5">
<span className="flex items-center gap-1">
<Box className="w-3 h-3" />
<span className="truncate max-w-[200px]">{chart}:{version}</span>
</span>
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{namespace}
</span>
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
rev{revision}
</span>
</div>
</div>
{/* Status reason + last operation - compact inline */}
<div className="mt-2 flex items-center gap-2 text-xs text-slate-600">
<span className="truncate">{statusReason}</span>
{lastOperationLabel && (
<>
<span className="text-slate-300 flex-shrink-0">|</span>
<span className="uppercase tracking-wide text-slate-400 whitespace-nowrap flex-shrink-0">
{lastOperationLabel}
</span>
</>
)}
</div>
</div>
{/* Content - compact 3-column layout */}
<div className="px-4 py-3 space-y-2">
{/* Row 1: Namespace | Revision | Launched */}
<div className="grid grid-cols-3 gap-3">
<div>
<div className="flex items-center gap-1 mb-0.5">
<Layers className="w-3 h-3 text-purple-400 flex-shrink-0" />
<p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Namespace</p>
</div>
<p className="text-sm font-medium text-slate-900">{namespace}</p>
</div>
<div>
<div className="flex items-center gap-1 mb-0.5">
<GitBranch className="w-3 h-3 text-green-400 flex-shrink-0" />
<p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Revision</p>
</div>
<p className="text-sm font-medium text-slate-900">{revision}</p>
</div>
<div>
<div className="flex items-center gap-1 mb-0.5">
<Calendar className="w-3 h-3 text-amber-400 flex-shrink-0" />
<p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Launched</p>
</div>
<p className="text-sm font-medium text-slate-900">{createdAtText}</p>
</div>
{/* Status message or error */}
{(statusReason || lastError) && (
<div className="hidden xl:block flex-1 min-w-0 max-w-[280px]">
{lastError ? (
<p className="text-xs text-rose-600 truncate flex items-center gap-1">
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
{lastError}
</p>
) : statusReason ? (
<p className="text-xs text-slate-500 truncate">{statusReason}</p>
) : null}
</div>
)}
{lastError && (
<div className="flex items-start gap-2 p-2.5 border border-rose-500/30 bg-rose-500/10 rounded-lg">
<AlertTriangle className="w-4 h-4 text-rose-500 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-xs font-semibold text-rose-700">Last error</p>
<p className="text-xs text-rose-600 truncate">{lastError}</p>
</div>
</div>
)}
</div>
{/* Scale Controls */}
{instance.status === "deployed" && (
<div className="flex items-center justify-between gap-2 px-6 py-3 bg-slate-50/50 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">Replicas:</span>
<div className="flex items-center gap-1">
{/* Scale controls */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{canScale ? (
<>
<button
onClick={() => handleScale(-1)}
disabled={scaling || currentReplicas <= 0}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-200 bg-white text-slate-600 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-150"
className="p-1 rounded hover:bg-slate-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Scale down"
>
{scaling ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Loader2 className="w-3.5 h-3.5 text-slate-400 animate-spin" />
) : (
<Minus className="w-4 h-4" />
<Minus className="w-3.5 h-3.5 text-slate-500" />
)}
</button>
<span className="inline-flex items-center justify-center min-w-[2.5rem] px-2 py-1 text-sm font-bold text-slate-900 bg-white border border-slate-200 rounded-lg">
<span className="w-8 text-center text-sm font-bold text-slate-700 tabular-nums">
{currentReplicas}
</span>
<button
onClick={() => handleScale(1)}
disabled={scaling}
className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-200 bg-white text-slate-600 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-150"
className="p-1 rounded hover:bg-slate-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Scale up"
>
{scaling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
<Plus className="w-3.5 h-3.5 text-slate-500" />
</button>
</div>
</div>
)}
</>
) : (
<span className="text-xs text-slate-400 w-16 text-center">{currentReplicas} repl.</span>
)}
</div>
{/* Enhanced Actions Bar */}
<div className="relative px-6 py-4 bg-gradient-to-r from-slate-50 via-slate-50 to-white border-t border-slate-200 backdrop-blur-sm">
<div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">
<button
onClick={() => onViewEntries(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-emerald-500/40 bg-emerald-50 px-3 py-2.5 text-sm font-semibold text-emerald-700 transition-all duration-200 hover:border-emerald-500/60 hover:bg-emerald-100 hover:shadow-lg"
title="View service entries"
>
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
<span className="truncate">Entries</span>
</button>
<button
onClick={() => onViewDiagnostics(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2.5 text-sm font-semibold text-indigo-700 transition-all duration-200 hover:border-indigo-300 hover:bg-indigo-100 hover:shadow-lg"
title="View describe, events, and pod logs"
>
<Activity className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
<span className="truncate">Diagnostics</span>
</button>
<button
onClick={() => onModify(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-blue-500/40 bg-blue-50 px-3 py-2.5 text-sm font-semibold text-blue-700 transition-all duration-200 hover:border-blue-500/60 hover:bg-blue-100 hover:shadow-lg"
title="Modify instance configuration"
>
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
<span className="truncate">Modify</span>
</button>
<button
onClick={() => onTerminate(instance)}
className="group/btn inline-flex min-w-0 items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-red-50 px-3 py-2.5 text-sm font-semibold text-rose-700 transition-all duration-200 hover:border-rose-500/60 hover:bg-rose-100 hover:shadow-lg"
title="Terminate instance"
>
<StopCircle className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
<span className="truncate">Delete</span>
</button>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onViewEntries(instance)}
className="p-1.5 rounded-md text-slate-400 hover:text-blue-500 hover:bg-blue-50 transition-colors"
title="Entries"
>
<Network className="w-4 h-4" />
</button>
<button
onClick={() => onViewDiagnostics(instance)}
className="p-1.5 rounded-md text-slate-400 hover:text-amber-500 hover:bg-amber-50 transition-colors"
title="Diagnostics"
>
<Activity className="w-4 h-4" />
</button>
<button
onClick={() => onModify(instance)}
className="p-1.5 rounded-md text-slate-400 hover:text-indigo-500 hover:bg-indigo-50 transition-colors"
title="Modify"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => onTerminate(instance)}
className="p-1.5 rounded-md text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors"
title="Delete"
>
<StopCircle className="w-4 h-4" />
</button>
</div>
</div>
);

View File

@ -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<ModifyModalProps> = ({
const [valuesYaml, setValuesYaml] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Values Schema support
const [loadingSchema, setLoadingSchema] = useState(false);
const [valuesSchema, setValuesSchema] = useState<JsonSchema | null>(null);
const [inputMethod, setInputMethod] = useState<'form' | 'yaml'>('yaml');
const [formValues, setFormValues] = useState<Record<string, any>>({});
// Values Diff support
const [showDiff, setShowDiff] = useState(false);
@ -56,15 +48,14 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
// 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<ModifyModalProps> = ({
}
}
// 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<ModifyModalProps> = ({
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<ModifyModalProps> = ({
});
};
const handleFormValuesChange = (values: Record<string, any>) => {
setFormValues(values);
setValuesYaml(stringifyYaml(values));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -278,59 +226,21 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
/>
</FormField>
{/* Values Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-slate-700">
Configuration Values
</label>
{valuesSchema?.properties && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setInputMethod('form')}
className="cursor-pointer"
>
<Badge
variant={inputMethod === 'form' ? 'success' : 'default'}
size="sm"
>
Form
</Badge>
</button>
<button
type="button"
onClick={() => setInputMethod('yaml')}
className="cursor-pointer"
>
<Badge
variant={inputMethod === 'yaml' ? 'success' : 'default'}
size="sm"
>
YAML
</Badge>
</button>
</div>
)}
</div>
{loadingSchema ? (
<LoadingState message="Loading configuration schema..." />
) : inputMethod === 'form' && valuesSchema ? (
<SchemaFormGenerator
schema={valuesSchema}
values={formValues}
onChange={handleFormValuesChange}
/>
) : (
<Textarea
value={valuesYaml}
onChange={(e) => setValuesYaml(e.target.value)}
rows={12}
placeholder="key: value&#10;nested:&#10; key: value"
className="font-mono text-sm"
/>
)}
{/* Current Values — directly editable as YAML */}
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Configuration Values
</label>
<p className="text-xs text-slate-500">
Editing current deployed values. Changes are merged with existing release values (--reuse-values).
</p>
<Textarea
value={valuesYaml}
onChange={(e) => setValuesYaml(e.target.value)}
rows={14}
placeholder="key: value&#10;nested:&#10; key: value"
className="font-mono text-sm"
/>
</div>
{/* Values Diff Section */}
@ -415,42 +325,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
);
};
const isJsonSchemaObject = (value: unknown): value is JsonSchema =>
typeof value === "object" && value !== null && !Array.isArray(value);
const extractJsonSchema = (schemaResponse: unknown): JsonSchema | null => {
if (schemaResponse == null) {
return null;
}
const tryParse = (value: unknown): unknown => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return null;
}
}
return value;
};
let candidate: unknown = tryParse(schemaResponse);
if (candidate && typeof candidate === "object" && "schema" in (candidate as Record<string, unknown>)) {
const inner = (candidate as { schema?: unknown }).schema;
const normalizedInner = extractJsonSchema(inner);
if (normalizedInner) {
return normalizedInner;
}
}
if (isJsonSchemaObject(candidate)) {
return candidate as JsonSchema;
}
return null;
};
const parseValuesYaml = (source: string): Record<string, any> => {
const parsed = parseYaml(source);
if (parsed == null) {

View File

@ -188,6 +188,22 @@ const InstancesManagementPage: React.FC = () => {
};
}, [autoRefresh]);
const handleScale = useCallback((updatedInstance: InstanceResponse) => {
setInstancesByCluster((prev) => {
const next = new Map(prev);
for (const [clusterId, insts] of next) {
const idx = insts.findIndex((i) => i.id === updatedInstance.id);
if (idx !== -1) {
const updated = [...insts];
updated[idx] = updatedInstance;
next.set(clusterId, updated);
break;
}
}
return next;
});
}, []);
const handleModify = useCallback((instance: Instance) => {
setModifyInstance(instance);
}, []);
@ -421,7 +437,7 @@ const InstancesManagementPage: React.FC = () => {
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="flex flex-col gap-2">
{instances.map((instance) => (
<InstanceCard
key={instance.id}
@ -429,6 +445,7 @@ const InstancesManagementPage: React.FC = () => {
onModify={handleModify}
onTerminate={handleTerminate}
onScale={handleScale}
onViewEntries={handleViewEntries}
onViewDiagnostics={handleViewDiagnostics}
/>

View File

@ -1,6 +1,6 @@
/**
* Tag Card Component
* Simple card for displaying a single tag/artifact
* Card for displaying a single chart tag/artifact with Launch action
*/
import React, { useState } from "react";
import { Box, Copy, File, HardDrive, Package, Rocket } from "lucide-react";
@ -22,9 +22,7 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
const category = inferArtifactCategory(tag);
const handleLaunch = () => {
if (category !== "chart") {
return;
}
if (category !== "chart") return;
setLaunchModalOpen(true);
};
@ -43,90 +41,80 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
const formatSize = (bytes: number) => {
if (bytes === 0) return "N/A";
const mb = bytes / (1024 * 1024);
if (mb < 1) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (mb < 1) return `${(bytes / 1024).toFixed(1)} KB`;
return `${mb.toFixed(1)} MB`;
};
const getTypeColor = (type: ArtifactCategory) => {
switch (type) {
case "chart":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
return "text-blue-500 bg-blue-500/10 border-blue-500/30";
case "image":
return "text-green-400 bg-green-500/10 border-green-500/30";
return "text-green-500 bg-green-500/10 border-green-500/30";
default:
return "text-slate-500 bg-gray-500/10 border-gray-500/30";
}
};
const getTypeIcon = (type: ArtifactCategory) => {
const className = "w-5 h-5";
const className = "w-4 h-4";
switch (type) {
case "chart":
return <Package className={className} />;
case "image":
return <Box className={className} />;
default:
return <File className={className} />;
case "chart": return <Package className={className} />;
case "image": return <Box className={className} />;
default: return <File className={className} />;
}
};
return (
<>
<div className="hover-lift relative bg-white border border-slate-200 rounded-lg p-3 hover:border-brand-blue/50 transition-all group">
<div className="hover-lift relative bg-white border border-slate-200 rounded-lg p-4 min-w-[180px] hover:border-brand-blue/50 transition-all group">
{/* LATEST badge */}
{isLatest && (
<span className="absolute -top-1.5 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500 text-white shadow-sm">
<span className="absolute -top-2 right-2 px-2 py-0.5 rounded-full text-[11px] font-bold uppercase tracking-wider bg-emerald-500 text-white shadow-sm z-10">
LATEST
</span>
)}
{/* Top row: tag name + type badge */}
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md border ${getTypeColor(category)} flex items-center justify-center flex-shrink-0`}>
<span className="scale-75">{getTypeIcon(category)}</span>
{/* Tag name + type */}
<div className="flex items-center gap-2 mb-2">
<div className={`w-7 h-7 rounded-md border ${getTypeColor(category)} flex items-center justify-center flex-shrink-0`}>
{getTypeIcon(category)}
</div>
<h3 className="text-sm font-semibold text-slate-900 truncate flex-1">
<h3 className="text-sm font-semibold text-slate-900 truncate flex-1" title={tag.tag}>
{tag.tag || 'N/A'}
</h3>
<span
className={`px-1.5 py-0.5 rounded text-[10px] border ${getTypeColor(category)} flex-shrink-0`}
title={tag.mediaType || tag.type || ''}
>
<span className={`px-1.5 py-0.5 rounded text-[11px] border ${getTypeColor(category)} flex-shrink-0`}>
{category}
</span>
</div>
{/* Repository path */}
<p className="text-[11px] text-slate-400 truncate mb-2" title={tag.repositoryName || ''}>
<p className="text-xs text-slate-400 truncate mb-3" title={tag.repositoryName || ''}>
{tag.repositoryName || ' '}
</p>
{/* Bottom row: size + actions */}
{/* Actions row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 flex-shrink-0">
<HardDrive className="w-3 h-3" />
<div className="flex items-center gap-1.5 text-xs text-slate-400 flex-shrink-0">
<HardDrive className="w-3.5 h-3.5" />
<span>{formatSize(tag.size || 0)}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleCopy}
className="px-2 py-1 bg-white hover:bg-slate-50 text-slate-500
border border-slate-200 rounded text-[11px] transition-colors flex items-center gap-1"
className="px-2.5 py-1.5 bg-white hover:bg-slate-50 text-slate-500 border border-slate-200 rounded text-xs transition-colors flex items-center gap-1"
title="Copy pull command"
>
<Copy className="w-3 h-3" />
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</button>
{category === "chart" && (
<button
onClick={handleLaunch}
className="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded
text-[11px] font-medium transition-colors flex items-center gap-1"
className="px-2.5 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium transition-colors flex items-center gap-1"
title="Launch this Helm chart"
>
<Rocket className="w-3 h-3" />
<Rocket className="w-3.5 h-3.5" />
<span>Launch</span>
</button>
)}
@ -134,7 +122,6 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
</div>
</div>
{/* Launch Modal */}
{launchModalOpen && tag.repositoryName && tag.tag && (
<LaunchModal
isOpen={launchModalOpen}
@ -151,9 +138,6 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
const normalizeRegistryHost = (url?: string) => {
if (!url) return "";
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
}
try { return new URL(url).host; }
catch { return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); }
};

View File

@ -525,7 +525,7 @@ const ArtifactBrowserPage: React.FC = () => {
description={`No tags matching "${tagSearchTerm}" found.`}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredArtifacts.map((artifact, index) => (
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}