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:
@ -73,6 +73,7 @@ type InstanceResponse struct {
|
|||||||
LastError string `json:"lastError,omitempty"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
Revision int `json:"revision"`
|
Revision int `json:"revision"`
|
||||||
Values map[string]interface{} `json:"values,omitempty"`
|
Values map[string]interface{} `json:"values,omitempty"`
|
||||||
|
Replicas int `json:"replicas"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -566,6 +566,17 @@ func formatTime(value time.Time) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse {
|
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{
|
response := &dto.InstanceResponse{
|
||||||
ID: instance.ID,
|
ID: instance.ID,
|
||||||
ClusterID: instance.ClusterID,
|
ClusterID: instance.ClusterID,
|
||||||
@ -583,6 +594,7 @@ func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto
|
|||||||
LastOperation: string(instance.LastOperation),
|
LastOperation: string(instance.LastOperation),
|
||||||
LastError: instance.LastError,
|
LastError: instance.LastError,
|
||||||
Revision: instance.Revision,
|
Revision: instance.Revision,
|
||||||
|
Replicas: replicas,
|
||||||
AllowedActions: []string{"view", "update", "delete"},
|
AllowedActions: []string{"view", "update", "delete"},
|
||||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
|||||||
@ -271,6 +271,7 @@ export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceR
|
|||||||
name?: string;
|
name?: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
registryId?: string;
|
registryId?: string;
|
||||||
|
replicas?: number;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
/** 实例当前状态 */
|
/** 实例当前状态 */
|
||||||
|
|||||||
@ -1,30 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Instance Card Component
|
* Instance Card Component — horizontal row layout
|
||||||
* Display instance information with action buttons
|
* Compact, readable, with inline scale controls and action buttons
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Package,
|
Box, Settings, StopCircle, CheckCircle, XCircle, Clock,
|
||||||
Settings,
|
Network, Activity, GitBranch, Layers,
|
||||||
StopCircle,
|
AlertTriangle, HelpCircle, Minus, Plus, Loader2,
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Clock,
|
|
||||||
Network,
|
|
||||||
Activity,
|
|
||||||
Box,
|
|
||||||
Calendar,
|
|
||||||
GitBranch,
|
|
||||||
Layers,
|
|
||||||
AlertTriangle,
|
|
||||||
History,
|
|
||||||
HelpCircle,
|
|
||||||
Minus,
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { InstanceResponse, InstanceStatus } from "@/api";
|
import type { InstanceResponse } from "@/api";
|
||||||
import { INSTANCE_LAST_OPERATION, INSTANCE_STATUS, scaleInstance } from "@/api";
|
import { scaleInstance } from "@/api";
|
||||||
|
import { useToast } from "@/shared";
|
||||||
|
import { formatApiError } from "@/shared/utils";
|
||||||
|
|
||||||
interface InstanceCardProps {
|
interface InstanceCardProps {
|
||||||
instance: InstanceResponse;
|
instance: InstanceResponse;
|
||||||
@ -39,101 +26,76 @@ type StatusVisual = {
|
|||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
color: string;
|
color: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
glow: string;
|
border: string;
|
||||||
label: string;
|
label: string;
|
||||||
defaultReason: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_INFO_MAP: Record<InstanceStatus, StatusVisual> = {
|
const STATUS_INFO_MAP: Record<string, StatusVisual> = {
|
||||||
[INSTANCE_STATUS.deployed]: {
|
deployed: {
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
color: "text-emerald-400",
|
color: "text-emerald-500",
|
||||||
bg: "bg-gradient-to-r from-emerald-500/20 to-green-500/20 border-emerald-500/40",
|
bg: "bg-emerald-50",
|
||||||
glow: "shadow-emerald-500/20",
|
border: "border-emerald-400",
|
||||||
label: "Deployed",
|
label: "Deployed",
|
||||||
defaultReason: "Deployment completed successfully.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS.failed]: {
|
failed: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: "text-rose-400",
|
color: "text-rose-500",
|
||||||
bg: "bg-gradient-to-r from-rose-500/20 to-red-500/20 border-rose-500/40",
|
bg: "bg-rose-50",
|
||||||
glow: "shadow-rose-500/20",
|
border: "border-rose-400",
|
||||||
label: "Failed",
|
label: "Failed",
|
||||||
defaultReason: "Last operation reported a failure.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS["pending-install"]]: {
|
"pending-install": {
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-amber-400",
|
color: "text-amber-500",
|
||||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
bg: "bg-amber-50",
|
||||||
glow: "shadow-amber-500/20",
|
border: "border-amber-400",
|
||||||
label: "Pending Install",
|
label: "Pending Install",
|
||||||
defaultReason: "Installation is in progress.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS["pending-upgrade"]]: {
|
"pending-upgrade": {
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-amber-400",
|
color: "text-amber-500",
|
||||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
bg: "bg-amber-50",
|
||||||
glow: "shadow-amber-500/20",
|
border: "border-amber-400",
|
||||||
label: "Pending Upgrade",
|
label: "Pending Upgrade",
|
||||||
defaultReason: "Upgrade is in progress.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS["pending-rollback"]]: {
|
"pending-rollback": {
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-amber-400",
|
color: "text-amber-500",
|
||||||
bg: "bg-gradient-to-r from-amber-500/20 to-yellow-500/20 border-amber-500/40",
|
bg: "bg-amber-50",
|
||||||
glow: "shadow-amber-500/20",
|
border: "border-amber-400",
|
||||||
label: "Pending Rollback",
|
label: "Pending Rollback",
|
||||||
defaultReason: "Rollback is in progress.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS["pending-delete"]]: {
|
"pending-delete": {
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-orange-400",
|
color: "text-orange-500",
|
||||||
bg: "bg-gradient-to-r from-orange-500/20 to-red-500/20 border-orange-500/40",
|
bg: "bg-orange-50",
|
||||||
glow: "shadow-orange-500/20",
|
border: "border-orange-400",
|
||||||
label: "Pending Delete",
|
label: "Pending Delete",
|
||||||
defaultReason: "Deletion is in progress.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS.superseded]: {
|
superseded: {
|
||||||
icon: History,
|
icon: Layers,
|
||||||
color: "text-indigo-300",
|
color: "text-indigo-400",
|
||||||
bg: "bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border-indigo-500/40",
|
bg: "bg-indigo-50",
|
||||||
glow: "shadow-indigo-500/20",
|
border: "border-indigo-300",
|
||||||
label: "Superseded",
|
label: "Superseded",
|
||||||
defaultReason: "A newer revision has replaced this instance.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS.uninstalled]: {
|
uninstalled: {
|
||||||
icon: StopCircle,
|
icon: StopCircle,
|
||||||
color: "text-slate-700",
|
color: "text-slate-500",
|
||||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40",
|
bg: "bg-slate-50",
|
||||||
glow: "shadow-slate-500/20",
|
border: "border-slate-300",
|
||||||
label: "Uninstalled",
|
label: "Uninstalled",
|
||||||
defaultReason: "Instance has been removed from the cluster.",
|
|
||||||
},
|
},
|
||||||
[INSTANCE_STATUS.unknown]: {
|
unknown: {
|
||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
color: "text-slate-700",
|
color: "text-slate-400",
|
||||||
bg: "bg-gradient-to-r from-slate-500/20 to-gray-500/20 border-slate-300/40",
|
bg: "bg-slate-50",
|
||||||
glow: "shadow-slate-500/20",
|
border: "border-slate-300",
|
||||||
label: "Unknown",
|
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> = ({
|
export const InstanceCard: React.FC<InstanceCardProps> = ({
|
||||||
instance,
|
instance,
|
||||||
onModify,
|
onModify,
|
||||||
@ -143,44 +105,31 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
|||||||
onScale,
|
onScale,
|
||||||
}) => {
|
}) => {
|
||||||
const [scaling, setScaling] = React.useState(false);
|
const [scaling, setScaling] = React.useState(false);
|
||||||
const normalizedStatus = (instance.status ?? INSTANCE_STATUS.unknown) as InstanceStatus;
|
const { error: toastError } = useToast();
|
||||||
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() : "";
|
|
||||||
|
|
||||||
// Extract replica count from values
|
const statusKey = instance.status ?? "unknown";
|
||||||
const parsedValues = React.useMemo(() => {
|
const statusInfo = STATUS_INFO_MAP[statusKey] ?? STATUS_INFO_MAP["unknown"];
|
||||||
if (!instance.values) return null;
|
const StatusIcon = statusInfo.icon;
|
||||||
try {
|
|
||||||
return typeof instance.values === "string"
|
const instanceName = instance.name || "Unnamed";
|
||||||
? JSON.parse(instance.values)
|
const chart = instance.chart || instance.repository || "—";
|
||||||
: (instance.values as Record<string, any>);
|
const version = instance.version || "—";
|
||||||
} catch {
|
const namespace = instance.namespace || "default";
|
||||||
return null;
|
const revision = instance.revision ?? "—";
|
||||||
}
|
|
||||||
}, [instance.values]);
|
const currentReplicas: number = instance.replicas ?? 0;
|
||||||
const currentReplicas = parsedValues?.replicaCount ?? parsedValues?.replicas ?? 1;
|
|
||||||
|
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 handleScale = async (delta: number) => {
|
||||||
const newReplicas = Math.max(0, currentReplicas + delta);
|
const newReplicas = Math.max(0, currentReplicas + delta);
|
||||||
@ -189,179 +138,126 @@ export const InstanceCard: React.FC<InstanceCardProps> = ({
|
|||||||
|
|
||||||
setScaling(true);
|
setScaling(true);
|
||||||
try {
|
try {
|
||||||
const result = await scaleInstance(instance.clusterId, instance.id, { replicas: newReplicas });
|
const result = await scaleInstance(instance.clusterId, instance.id, {
|
||||||
onScale?.(result.instance ?? instance);
|
replicas: newReplicas,
|
||||||
|
});
|
||||||
|
onScale?.(result.instance ?? { ...instance, replicas: newReplicas });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[InstanceCard] Scale failed:", err);
|
toastError(formatApiError(err) || "Scale failed");
|
||||||
} finally {
|
} finally {
|
||||||
setScaling(false);
|
setScaling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Status icon + label */}
|
||||||
<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-center gap-1.5 flex-shrink-0 min-w-[90px]">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<StatusIcon className={`w-4 h-4 ${statusInfo.color}`} />
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
<span className={`text-xs font-semibold ${statusInfo.color}`}>{statusInfo.label}</span>
|
||||||
{/* Icon */}
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
{/* Name + Chart info */}
|
||||||
<h3 className="text-base font-bold text-slate-950 truncate">
|
<div className="flex-1 min-w-0 flex items-center gap-4">
|
||||||
{instanceName}
|
<div className="min-w-0">
|
||||||
</h3>
|
<h4 className="text-sm font-semibold text-slate-900 truncate">{instanceName}</h4>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-3 text-xs text-slate-500 mt-0.5">
|
||||||
<Package className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
<span className="flex items-center gap-1">
|
||||||
<span className="text-sm text-slate-500 font-mono truncate">{repository}</span>
|
<Box className="w-3 h-3" />
|
||||||
<span className="text-slate-300 flex-shrink-0">·</span>
|
<span className="truncate max-w-[200px]">{chart}:{version}</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">
|
</span>
|
||||||
{version}
|
<span className="flex items-center gap-1">
|
||||||
</span>
|
<Layers className="w-3 h-3" />
|
||||||
</div>
|
{namespace}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="w-3 h-3" />
|
||||||
{/* Status Badge - prominent but smaller */}
|
rev{revision}
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content - compact 3-column layout */}
|
{/* Status message or error */}
|
||||||
<div className="px-4 py-3 space-y-2">
|
{(statusReason || lastError) && (
|
||||||
{/* Row 1: Namespace | Revision | Launched */}
|
<div className="hidden xl:block flex-1 min-w-0 max-w-[280px]">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{lastError ? (
|
||||||
<div>
|
<p className="text-xs text-rose-600 truncate flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1 mb-0.5">
|
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
|
||||||
<Layers className="w-3 h-3 text-purple-400 flex-shrink-0" />
|
{lastError}
|
||||||
<p className="text-[11px] text-slate-500 uppercase font-semibold tracking-wider">Namespace</p>
|
</p>
|
||||||
</div>
|
) : statusReason ? (
|
||||||
<p className="text-sm font-medium text-slate-900">{namespace}</p>
|
<p className="text-xs text-slate-500 truncate">{statusReason}</p>
|
||||||
</div>
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lastError && (
|
{/* Scale controls */}
|
||||||
<div className="flex items-start gap-2 p-2.5 border border-rose-500/30 bg-rose-500/10 rounded-lg">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
<AlertTriangle className="w-4 h-4 text-rose-500 flex-shrink-0 mt-0.5" />
|
{canScale ? (
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleScale(-1)}
|
onClick={() => handleScale(-1)}
|
||||||
disabled={scaling || currentReplicas <= 0}
|
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"
|
title="Scale down"
|
||||||
>
|
>
|
||||||
{scaling ? (
|
{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>
|
</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}
|
{currentReplicas}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleScale(1)}
|
onClick={() => handleScale(1)}
|
||||||
disabled={scaling}
|
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"
|
title="Scale up"
|
||||||
>
|
>
|
||||||
{scaling ? (
|
<Plus className="w-3.5 h-3.5 text-slate-500" />
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<span className="text-xs text-slate-400 w-16 text-center">{currentReplicas} repl.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Enhanced Actions Bar */}
|
{/* Action buttons */}
|
||||||
<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="flex items-center gap-1 flex-shrink-0">
|
||||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-2 xl:grid-cols-4">
|
<button
|
||||||
<button
|
onClick={() => onViewEntries(instance)}
|
||||||
onClick={() => onViewEntries(instance)}
|
className="p-1.5 rounded-md text-slate-400 hover:text-blue-500 hover:bg-blue-50 transition-colors"
|
||||||
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="Entries"
|
||||||
title="View service entries"
|
>
|
||||||
>
|
<Network className="w-4 h-4" />
|
||||||
<Network className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
</button>
|
||||||
<span className="truncate">Entries</span>
|
<button
|
||||||
</button>
|
onClick={() => onViewDiagnostics(instance)}
|
||||||
<button
|
className="p-1.5 rounded-md text-slate-400 hover:text-amber-500 hover:bg-amber-50 transition-colors"
|
||||||
onClick={() => onViewDiagnostics(instance)}
|
title="Diagnostics"
|
||||||
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" />
|
||||||
>
|
</button>
|
||||||
<Activity className="w-4 h-4 group-hover/btn:scale-110 transition-transform" />
|
<button
|
||||||
<span className="truncate">Diagnostics</span>
|
onClick={() => onModify(instance)}
|
||||||
</button>
|
className="p-1.5 rounded-md text-slate-400 hover:text-indigo-500 hover:bg-indigo-50 transition-colors"
|
||||||
|
title="Modify"
|
||||||
<button
|
>
|
||||||
onClick={() => onModify(instance)}
|
<Settings className="w-4 h-4" />
|
||||||
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"
|
</button>
|
||||||
title="Modify instance configuration"
|
<button
|
||||||
>
|
onClick={() => onTerminate(instance)}
|
||||||
<Settings className="w-4 h-4 group-hover/btn:rotate-90 transition-transform duration-300" />
|
className="p-1.5 rounded-md text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors"
|
||||||
<span className="truncate">Modify</span>
|
title="Delete"
|
||||||
</button>
|
>
|
||||||
|
<StopCircle className="w-4 h-4" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,19 +7,17 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||||
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
|
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
|
||||||
import { getValuesSchema, getInstanceValuesDiff } from "@/api";
|
import { getInstanceValuesDiff } from "@/api";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Button,
|
Button,
|
||||||
FormField,
|
FormField,
|
||||||
Input,
|
Input,
|
||||||
Textarea,
|
Textarea,
|
||||||
ErrorState,
|
ErrorState,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
Badge,
|
Badge,
|
||||||
SchemaFormGenerator
|
|
||||||
} from "@/shared/components";
|
} from "@/shared/components";
|
||||||
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
|
|
||||||
|
|
||||||
interface ModifyModalProps {
|
interface ModifyModalProps {
|
||||||
instance: InstanceResponse;
|
instance: InstanceResponse;
|
||||||
@ -37,12 +35,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|||||||
const [valuesYaml, setValuesYaml] = useState("");
|
const [valuesYaml, setValuesYaml] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Values Diff support
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
@ -56,15 +48,14 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|||||||
// Initialize with current values
|
// Initialize with current values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTag(instance.version || "");
|
setTag(instance.version || "");
|
||||||
setDescription(""); // InstanceResponse doesn't have description field
|
setDescription("");
|
||||||
|
|
||||||
// Parse existing values
|
// Parse and display existing values as YAML
|
||||||
if (instance.values) {
|
if (instance.values) {
|
||||||
try {
|
try {
|
||||||
const parsedValues = typeof instance.values === 'string'
|
const parsedValues = typeof instance.values === 'string'
|
||||||
? JSON.parse(instance.values)
|
? JSON.parse(instance.values)
|
||||||
: instance.values;
|
: instance.values;
|
||||||
setFormValues(parsedValues);
|
|
||||||
setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues));
|
setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ModifyModal] Failed to parse existing values:', err);
|
console.error('[ModifyModal] Failed to parse existing values:', err);
|
||||||
@ -72,46 +63,10 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load values schema
|
// Load values diff for reference
|
||||||
loadValuesSchema();
|
|
||||||
|
|
||||||
// Load values diff
|
|
||||||
loadValuesDiff();
|
loadValuesDiff();
|
||||||
}, [instance]);
|
}, [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 () => {
|
const loadValuesDiff = async () => {
|
||||||
if (!instance.clusterId || !instance.id) return;
|
if (!instance.clusterId || !instance.id) return;
|
||||||
|
|
||||||
@ -133,9 +88,7 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|||||||
|
|
||||||
const applyDefaults = () => {
|
const applyDefaults = () => {
|
||||||
if (!diffData?.defaults) return;
|
if (!diffData?.defaults) return;
|
||||||
const defaultYaml = stringifyYaml(diffData.defaults);
|
setValuesYaml(stringifyYaml(diffData.defaults));
|
||||||
setValuesYaml(defaultYaml);
|
|
||||||
setFormValues(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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -278,59 +226,21 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* Values Configuration */}
|
{/* Current Values — directly editable as YAML */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
Configuration Values
|
||||||
Configuration Values
|
</label>
|
||||||
</label>
|
<p className="text-xs text-slate-500">
|
||||||
{valuesSchema?.properties && (
|
Editing current deployed values. Changes are merged with existing release values (--reuse-values).
|
||||||
<div className="flex gap-2">
|
</p>
|
||||||
<button
|
<Textarea
|
||||||
type="button"
|
value={valuesYaml}
|
||||||
onClick={() => setInputMethod('form')}
|
onChange={(e) => setValuesYaml(e.target.value)}
|
||||||
className="cursor-pointer"
|
rows={14}
|
||||||
>
|
placeholder="key: value nested: key: value"
|
||||||
<Badge
|
className="font-mono text-sm"
|
||||||
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 nested: key: value"
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values Diff Section */}
|
{/* 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 parseValuesYaml = (source: string): Record<string, any> => {
|
||||||
const parsed = parseYaml(source);
|
const parsed = parseYaml(source);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
|
|||||||
@ -188,6 +188,22 @@ const InstancesManagementPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [autoRefresh]);
|
}, [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) => {
|
const handleModify = useCallback((instance: Instance) => {
|
||||||
setModifyInstance(instance);
|
setModifyInstance(instance);
|
||||||
}, []);
|
}, []);
|
||||||
@ -421,7 +437,7 @@ const InstancesManagementPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{instances.map((instance) => (
|
||||||
<InstanceCard
|
<InstanceCard
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
@ -429,6 +445,7 @@ const InstancesManagementPage: React.FC = () => {
|
|||||||
onModify={handleModify}
|
onModify={handleModify}
|
||||||
onTerminate={handleTerminate}
|
onTerminate={handleTerminate}
|
||||||
|
|
||||||
|
onScale={handleScale}
|
||||||
onViewEntries={handleViewEntries}
|
onViewEntries={handleViewEntries}
|
||||||
onViewDiagnostics={handleViewDiagnostics}
|
onViewDiagnostics={handleViewDiagnostics}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Tag Card Component
|
* 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 React, { useState } from "react";
|
||||||
import { Box, Copy, File, HardDrive, Package, Rocket } from "lucide-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 category = inferArtifactCategory(tag);
|
||||||
|
|
||||||
const handleLaunch = () => {
|
const handleLaunch = () => {
|
||||||
if (category !== "chart") {
|
if (category !== "chart") return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLaunchModalOpen(true);
|
setLaunchModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -43,90 +41,80 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
|
|||||||
const formatSize = (bytes: number) => {
|
const formatSize = (bytes: number) => {
|
||||||
if (bytes === 0) return "N/A";
|
if (bytes === 0) return "N/A";
|
||||||
const mb = bytes / (1024 * 1024);
|
const mb = bytes / (1024 * 1024);
|
||||||
if (mb < 1) {
|
if (mb < 1) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
}
|
|
||||||
return `${mb.toFixed(1)} MB`;
|
return `${mb.toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeColor = (type: ArtifactCategory) => {
|
const getTypeColor = (type: ArtifactCategory) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "chart":
|
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":
|
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:
|
default:
|
||||||
return "text-slate-500 bg-gray-500/10 border-gray-500/30";
|
return "text-slate-500 bg-gray-500/10 border-gray-500/30";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeIcon = (type: ArtifactCategory) => {
|
const getTypeIcon = (type: ArtifactCategory) => {
|
||||||
const className = "w-5 h-5";
|
const className = "w-4 h-4";
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "chart":
|
case "chart": return <Package className={className} />;
|
||||||
return <Package className={className} />;
|
case "image": return <Box className={className} />;
|
||||||
case "image":
|
default: return <File className={className} />;
|
||||||
return <Box className={className} />;
|
|
||||||
default:
|
|
||||||
return <File className={className} />;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* LATEST badge */}
|
||||||
{isLatest && (
|
{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
|
LATEST
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top row: tag name + type badge */}
|
{/* Tag name + type */}
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className={`w-6 h-6 rounded-md border ${getTypeColor(category)} flex items-center justify-center flex-shrink-0`}>
|
<div className={`w-7 h-7 rounded-md border ${getTypeColor(category)} flex items-center justify-center flex-shrink-0`}>
|
||||||
<span className="scale-75">{getTypeIcon(category)}</span>
|
{getTypeIcon(category)}
|
||||||
</div>
|
</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'}
|
{tag.tag || 'N/A'}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span className={`px-1.5 py-0.5 rounded text-[11px] border ${getTypeColor(category)} flex-shrink-0`}>
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] border ${getTypeColor(category)} flex-shrink-0`}
|
|
||||||
title={tag.mediaType || tag.type || ''}
|
|
||||||
>
|
|
||||||
{category}
|
{category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repository path */}
|
{/* 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 || ' '}
|
{tag.repositoryName || ' '}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Bottom row: size + actions */}
|
{/* Actions row */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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">
|
<div className="flex items-center gap-1.5 text-xs text-slate-400 flex-shrink-0">
|
||||||
<HardDrive className="w-3 h-3" />
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
<span>{formatSize(tag.size || 0)}</span>
|
<span>{formatSize(tag.size || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="px-2 py-1 bg-white hover:bg-slate-50 text-slate-500
|
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"
|
||||||
border border-slate-200 rounded text-[11px] transition-colors flex items-center gap-1"
|
|
||||||
title="Copy pull command"
|
title="Copy pull command"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
<span>Copy</span>
|
<span>Copy</span>
|
||||||
</button>
|
</button>
|
||||||
{category === "chart" && (
|
{category === "chart" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleLaunch}
|
onClick={handleLaunch}
|
||||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded
|
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"
|
||||||
text-[11px] font-medium transition-colors flex items-center gap-1"
|
|
||||||
title="Launch this Helm chart"
|
title="Launch this Helm chart"
|
||||||
>
|
>
|
||||||
<Rocket className="w-3 h-3" />
|
<Rocket className="w-3.5 h-3.5" />
|
||||||
<span>Launch</span>
|
<span>Launch</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -134,7 +122,6 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Launch Modal */}
|
|
||||||
{launchModalOpen && tag.repositoryName && tag.tag && (
|
{launchModalOpen && tag.repositoryName && tag.tag && (
|
||||||
<LaunchModal
|
<LaunchModal
|
||||||
isOpen={launchModalOpen}
|
isOpen={launchModalOpen}
|
||||||
@ -151,9 +138,6 @@ export const TagCard: React.FC<TagCardProps> = ({ registryId, registryUrl, tag,
|
|||||||
|
|
||||||
const normalizeRegistryHost = (url?: string) => {
|
const normalizeRegistryHost = (url?: string) => {
|
||||||
if (!url) return "";
|
if (!url) return "";
|
||||||
try {
|
try { return new URL(url).host; }
|
||||||
return new URL(url).host;
|
catch { return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); }
|
||||||
} catch {
|
|
||||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -525,7 +525,7 @@ const ArtifactBrowserPage: React.FC = () => {
|
|||||||
description={`No tags matching "${tagSearchTerm}" found.`}
|
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) => (
|
{filteredArtifacts.map((artifact, index) => (
|
||||||
<TagCard
|
<TagCard
|
||||||
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user