317 lines
17 KiB
TypeScript
317 lines
17 KiB
TypeScript
import React, { useState } from 'react';
|
||
import {
|
||
Package,
|
||
Play,
|
||
Pause,
|
||
RotateCcw,
|
||
Sliders,
|
||
} from 'lucide-react';
|
||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||
import { StatusBadge } from '../components/StatusBadge';
|
||
import { RolloutStage } from '../contracts';
|
||
|
||
export const ModulesPage: React.FC = () => {
|
||
const modules = useKccStore((state) => state.modules);
|
||
const rollouts = useKccStore((state) => state.rollouts);
|
||
const organizations = useKccStore((state) => state.organizations);
|
||
|
||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||
const triggerRolloutStage = useKccStore((state) => state.triggerRolloutStage);
|
||
const createRollout = useKccStore((state) => state.createRollout);
|
||
const { permissions } = useOperatorRole();
|
||
|
||
const [selectedModuleId, setSelectedModuleId] = useState<string>(modules[0]?.moduleId || 'krona-core');
|
||
const selectedModule = modules.find((m) => m.moduleId === selectedModuleId);
|
||
|
||
// Derived rollout defaults for selected module
|
||
const rolloutVersion = selectedModule?.latestVersion || '1.0.0';
|
||
const pilotOrgs = organizations.slice(0, 2).map((o) => o.tenantId);
|
||
|
||
const activeRollout = rollouts.find((r) => r.moduleId === selectedModuleId);
|
||
|
||
const handleStageChange = (stage: RolloutStage, percentage: number) => {
|
||
if (!activeRollout) return;
|
||
openConfirmModal({
|
||
title: `Смена фазы раскатки: ${activeRollout.moduleName}`,
|
||
actionName: `rollout.stage_${stage}`,
|
||
impactSummary: `Конвейер раскатки будет переведен в состояние «${stage}» (${percentage}% трафика).`,
|
||
targetType: 'rollout',
|
||
targetId: activeRollout.rolloutId,
|
||
targetName: activeRollout.moduleName,
|
||
onConfirm: async (reason) => {
|
||
await triggerRolloutStage(activeRollout.rolloutId, stage, percentage, reason);
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleCreateRollout = () => {
|
||
if (!selectedModule) return;
|
||
openConfirmModal({
|
||
title: `Запуск новой раскатки: ${selectedModule.name}`,
|
||
actionName: `rollout.start_pilot (${rolloutVersion})`,
|
||
impactSummary: `Будет запущен пилотный этап обновления до версии ${rolloutVersion} для ${pilotOrgs.length} пилотных организаций.`,
|
||
targetType: 'rollout',
|
||
targetId: selectedModule.moduleId,
|
||
targetName: selectedModule.name,
|
||
onConfirm: async (reason) => {
|
||
await createRollout(
|
||
selectedModule.moduleId,
|
||
rolloutVersion,
|
||
'pilot',
|
||
pilotOrgs,
|
||
15,
|
||
'Пилотная фаза раскатки обновления',
|
||
reason
|
||
);
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||
<Package className="w-5 h-5 text-amber-400" />
|
||
<span>Каталог модулей и Rollout конвейер</span>
|
||
</h1>
|
||
<p className="text-xs text-zinc-400 mt-0.5">
|
||
Управление манифестами, compatibility core/schema, пилотными организациями и фазовыми релизами
|
||
</p>
|
||
</div>
|
||
|
||
<div className="text-xs text-zinc-400">
|
||
Всего модулей: <span className="font-bold text-zinc-100">{modules.length}</span> • Активных раскаток:{' '}
|
||
<span className="font-bold text-amber-400">{rollouts.length}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grid: Module Selector + Module Detail & Rollout Manager */}
|
||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||
{/* Left Column: Modules Catalog List */}
|
||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
|
||
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||
Каталог системных модулей
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{modules.map((m) => {
|
||
const isSelected = m.moduleId === selectedModuleId;
|
||
const hasRollout = rollouts.some((r) => r.moduleId === m.moduleId);
|
||
|
||
return (
|
||
<div
|
||
key={m.moduleId}
|
||
onClick={() => setSelectedModuleId(m.moduleId)}
|
||
className={`p-3 rounded-lg border text-xs cursor-pointer transition-colors space-y-1.5 ${
|
||
isSelected
|
||
? 'bg-amber-500/15 border-amber-500/40 text-zinc-100'
|
||
: 'bg-zinc-950/60 border-zinc-800 text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
|
||
}`}
|
||
>
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-bold text-zinc-100">{m.name}</span>
|
||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-xs bg-zinc-900 border border-zinc-700 text-amber-300">
|
||
v{m.latestVersion}
|
||
</span>
|
||
</div>
|
||
<p className="text-[11px] text-zinc-400 line-clamp-1">{m.description}</p>
|
||
<div className="flex justify-between items-center text-[10px] text-zinc-500 pt-1">
|
||
<span>Тенантов: {m.activeTenantsCount} • Инстансов: {m.instancesCount}</span>
|
||
{hasRollout && (
|
||
<span className="px-1.5 py-0.5 rounded-xs bg-indigo-950 text-indigo-300 border border-indigo-700 font-bold uppercase">
|
||
Rollout
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right 2 Columns: Module Details & Rollout Controller */}
|
||
<div className="xl:col-span-2 space-y-6">
|
||
{selectedModule && (
|
||
<>
|
||
{/* Module Header Card */}
|
||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="font-mono text-xs text-amber-400 font-bold">{selectedModule.code}</span>
|
||
<span className="text-zinc-600">•</span>
|
||
<span className="text-xs text-zinc-400">Категория: {selectedModule.category}</span>
|
||
</div>
|
||
<h2 className="text-lg font-bold text-zinc-100">{selectedModule.name}</h2>
|
||
<p className="text-xs text-zinc-300 mt-1">{selectedModule.description}</p>
|
||
</div>
|
||
|
||
<div className="text-right">
|
||
<span className="text-xs text-zinc-400">Текущая стабильная версия:</span>
|
||
<div className="font-mono font-bold text-lg text-emerald-400">v{selectedModule.latestVersion}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 text-xs pt-3 border-t border-zinc-800">
|
||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||
<div className="text-zinc-400 mb-0.5">Канал релиза:</div>
|
||
<div className="font-semibold text-zinc-200 uppercase">{selectedModule.rolloutChannel}</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||
<div className="text-zinc-400 mb-0.5">Манифест:</div>
|
||
<div className="font-semibold text-zinc-200 font-mono">v{selectedModule.manifestVersion}</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||
<div className="text-zinc-400 mb-0.5">Зависимости:</div>
|
||
<div className="font-semibold text-zinc-200">
|
||
{selectedModule.dependencies.length > 0 ? selectedModule.dependencies.join(', ') : 'Нет (Базовый)'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rollout Control Panel (Сценарий 8) */}
|
||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-5 space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<div className="flex items-center gap-2">
|
||
<Sliders className="w-4 h-4 text-amber-400" />
|
||
<h3 className="text-sm font-bold text-zinc-200">Управление раскаткой (Rollout Pipeline)</h3>
|
||
</div>
|
||
{activeRollout && <StatusBadge status={activeRollout.stage} size="sm" />}
|
||
</div>
|
||
|
||
{activeRollout ? (
|
||
<div className="space-y-4 text-xs">
|
||
{/* Progress visualizer */}
|
||
<div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-semibold text-zinc-200">
|
||
Целевая версия: <span className="font-mono text-amber-300">v{activeRollout.targetVersion}</span>
|
||
</span>
|
||
<span className="font-bold text-amber-400 font-mono text-sm">{activeRollout.percentage}%</span>
|
||
</div>
|
||
|
||
<div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-amber-400 transition-all duration-300"
|
||
style={{ width: `${activeRollout.percentage}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||
<span>Стадия: <strong className="text-zinc-200 uppercase">{activeRollout.stage}</strong></span>
|
||
<span>Пилотных организаций: {activeRollout.pilotTenantIds.length}</span>
|
||
<span>Старт: {new Date(activeRollout.startedAt).toLocaleDateString('ru-RU')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rollout Action Buttons */}
|
||
<div className="flex flex-wrap gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'pilot'}
|
||
onClick={() => handleStageChange('pilot', 15)}
|
||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
|
||
>
|
||
Фаза 1: Pilot (15%)
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'percentage'}
|
||
onClick={() => handleStageChange('percentage', 50)}
|
||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
|
||
>
|
||
Фаза 2: 50% организаций
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'general_availability'}
|
||
onClick={() => handleStageChange('general_availability', 100)}
|
||
className="px-3 py-1.5 rounded-lg bg-emerald-950 text-emerald-300 border border-emerald-700 hover:bg-emerald-900 text-xs font-bold disabled:opacity-40 transition-colors"
|
||
>
|
||
Фаза 3: Полный релиз (100% GA)
|
||
</button>
|
||
|
||
{activeRollout.stage !== 'paused' ? (
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canPauseRollbackRollouts}
|
||
onClick={() => handleStageChange('paused', activeRollout.percentage)}
|
||
className="px-3 py-1.5 rounded-lg bg-amber-950/70 text-amber-300 border border-amber-700 hover:bg-amber-900/60 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<Pause className="w-3.5 h-3.5" />
|
||
<span>Поставить на паузу</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canTriggerRollouts}
|
||
onClick={() => handleStageChange('pilot', activeRollout.percentage)}
|
||
className="px-3 py-1.5 rounded-lg bg-amber-500 text-zinc-950 font-bold text-xs hover:bg-amber-400 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<Play className="w-3.5 h-3.5" />
|
||
<span>Снять с паузы</span>
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canPauseRollbackRollouts || activeRollout.stage === 'rolled_back'}
|
||
onClick={() => handleStageChange('rolled_back', 0)}
|
||
className="px-3 py-1.5 rounded-lg bg-rose-950 text-rose-300 border border-rose-700 hover:bg-rose-900 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
<span>Rollback (Откат)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 text-xs space-y-3">
|
||
<p className="text-zinc-400">
|
||
Для модуля <strong className="text-zinc-200">{selectedModule.name}</strong> нет активных раскаток.
|
||
Вы можете запустить пилотный релиз новой версии.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
disabled={!permissions.canTriggerRollouts}
|
||
onClick={handleCreateRollout}
|
||
className="px-4 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center gap-1.5 disabled:opacity-40"
|
||
>
|
||
<Play className="w-3.5 h-3.5" />
|
||
<span>Запустить пилотный Rollout</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Release History & Deployments */}
|
||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-3 text-xs">
|
||
<h3 className="text-sm font-bold text-zinc-200">История релизов и совместимость (Compatibility)</h3>
|
||
<div className="space-y-2">
|
||
{selectedModule.releaseHistory.map((rel) => (
|
||
<div key={rel.version} className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-bold text-amber-300 font-mono">v{rel.version}</span>
|
||
<span className="text-[11px] text-zinc-500">
|
||
{new Date(rel.releasedAt).toLocaleDateString('ru-RU')}
|
||
</span>
|
||
</div>
|
||
<p className="text-zinc-300 text-[11px]">{rel.changelog}</p>
|
||
<div className="text-[10px] text-zinc-400 font-mono pt-1">
|
||
Совместимость с Core: {rel.compatibleCore}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|