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"`
|
||||
Revision int `json:"revision"`
|
||||
Values map[string]interface{} `json:"values,omitempty"`
|
||||
Replicas int `json:"replicas"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -271,6 +271,7 @@ export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceR
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
registryId?: string;
|
||||
replicas?: number;
|
||||
repository?: string;
|
||||
revision?: number;
|
||||
/** 实例当前状态 */
|
||||
|
||||
@ -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">·</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>
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ 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 { getInstanceValuesDiff } from "@/api";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
@ -17,9 +17,7 @@ import {
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Badge,
|
||||
SchemaFormGenerator
|
||||
} from "@/shared/components";
|
||||
import type { JsonSchema } from "@/shared/components/form/SchemaFormGenerator";
|
||||
|
||||
interface ModifyModalProps {
|
||||
instance: InstanceResponse;
|
||||
@ -38,12 +36,6 @@ export const ModifyModal: React.FC<ModifyModalProps> = ({
|
||||
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);
|
||||
const [loadingDiff, setLoadingDiff] = 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
|
||||
setDescription("");
|
||||
|
||||
// Parse existing values
|
||||
// Parse and display existing values as YAML
|
||||
if (instance.values) {
|
||||
try {
|
||||
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 nested: 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 nested: 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) {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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(/\/+$/, ""); }
|
||||
};
|
||||
|
||||
@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user