krona-front/src/features/control-center/pages/ModulesPage.tsx

317 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};