krona-front/src/features/control-center/store/kccStore.ts

643 lines
20 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 { create } from 'zustand';
import type {
OperatorRole,
OperatorProfile,
OrganizationProjection,
ModuleDefinition,
Subscription,
Installation,
ModuleInstance,
ServerNode,
Incident,
Announcement,
Rollout,
OperatorAuditEvent,
PlatformStatus,
RolloutStage,
} from '../contracts';
import { OPERATOR_ROLE_DEFINITIONS } from '../contracts';
import { initKccStorage, persistItem, computeSha256 } from './db';
export interface InspectorState {
type: 'organization' | 'server' | 'incident' | 'module' | 'installation';
id: string;
}
export interface ConfirmActionModalConfig {
title: string;
actionName: string;
impactSummary: string;
targetType: OperatorAuditEvent['target']['type'];
targetId: string;
targetName: string;
onConfirm: (reason: string) => Promise<void> | void;
}
interface KccStoreState {
// Session & RBAC
activeRole: OperatorRole;
activeOperator: OperatorProfile;
isAccessGatePassed: boolean;
// Data
isLoading: boolean;
isInitialized: boolean;
serverMonitorOutage: boolean; // Mock-состояние «Server Monitor недоступен»
organizations: OrganizationProjection[];
modules: ModuleDefinition[];
subscriptions: Subscription[];
installations: Installation[];
instances: ModuleInstance[];
servers: ServerNode[];
incidents: Incident[];
announcements: Announcement[];
rollouts: Rollout[];
auditLog: OperatorAuditEvent[];
// Inspector & Action Modal
inspectorTarget: InspectorState | null;
pendingConfirmModal: ConfirmActionModalConfig | null;
// Actions
initialize: (forceReset?: boolean) => Promise<void>;
setActiveRole: (role: OperatorRole) => void;
setAccessGatePassed: (passed: boolean) => void;
openInspector: (type: InspectorState['type'], id: string) => void;
closeInspector: () => void;
setServerMonitorOutage: (outage: boolean) => void;
toggleServerMonitorOutage: () => void;
openConfirmModal: (config: ConfirmActionModalConfig) => void;
closeConfirmModal: () => void;
// Mutations with audit recording
recordAuditEvent: (params: {
action: string;
target: OperatorAuditEvent['target'];
reason: string;
impactSummary: string;
}) => Promise<OperatorAuditEvent>;
updateOrganizationStatus: (tenantId: string, status: PlatformStatus, reason: string) => Promise<void>;
grantModuleTrial: (
tenantId: string,
moduleId: string,
durationDays: number,
reason: string
) => Promise<void>;
grantGracePeriod: (subscriptionId: string, days: number, reason: string) => Promise<void>;
renewSubscription: (
subscriptionId: string,
newPeriod: 'month' | 'year',
activeModules: string[],
reason: string
) => Promise<void>;
createIncidentFromAlert: (
serverId: string,
summary: string,
severity: Incident['severity'],
reason: string
) => Promise<void>;
resolveIncident: (incidentId: string, reason: string) => Promise<void>;
createAnnouncement: (
announcement: Omit<Announcement, 'announcementId' | 'createdAt' | 'author' | 'stats'>,
reason: string
) => Promise<void>;
triggerRolloutStage: (rolloutId: string, stage: RolloutStage, percentage: number, reason: string) => Promise<void>;
createRollout: (
moduleId: string,
version: string,
stage: RolloutStage,
pilotTenantIds: string[],
percentage: number,
notes: string,
reason: string
) => Promise<void>;
requestServerDiagnostics: (serverId: string, reason: string) => Promise<void>;
scheduleServerMaintenance: (serverId: string, reason: string) => Promise<void>;
resetToDefaults: () => Promise<void>;
}
const DEFAULT_OPERATORS: Record<OperatorRole, OperatorProfile> = {
platform_owner: {
id: 'op-001',
name: 'Михаил Романов',
role: 'platform_owner',
email: 'm.romanov@krona.systems',
title: 'Владелец платформы & Архитектор',
},
platform_admin: {
id: 'op-002',
name: 'Алексей Смирнов',
role: 'platform_admin',
email: 'a.smirnov@krona.systems',
title: 'Главный системный администратор',
},
support_operator: {
id: 'op-003',
name: 'Елена Соколова',
role: 'support_operator',
email: 'e.sokolova@krona.systems',
title: 'Ведущий инженер поддержки L2',
},
billing_manager: {
id: 'op-004',
name: 'Анна Кузнецова',
role: 'billing_manager',
email: 'a.kuznetsova@krona.systems',
title: 'Руководитель биллинговых операций',
},
security_auditor: {
id: 'op-005',
name: 'Сергей Белов',
role: 'security_auditor',
email: 's.belov@krona.systems',
title: 'Офицер информационной безопасности',
},
release_manager: {
id: 'op-006',
name: 'Мария Лебедева',
role: 'release_manager',
email: 'm.lebedeva@krona.systems',
title: 'Руководитель релизного конвейера',
},
};
export const useKccStore = create<KccStoreState>((set, get) => ({
activeRole: 'platform_owner',
activeOperator: DEFAULT_OPERATORS.platform_owner,
isAccessGatePassed: typeof window !== 'undefined' ? localStorage.getItem('kcc_access_gate') === '1' : false,
isLoading: false,
isInitialized: false,
serverMonitorOutage: false,
organizations: [],
modules: [],
subscriptions: [],
installations: [],
instances: [],
servers: [],
incidents: [],
announcements: [],
rollouts: [],
auditLog: [],
inspectorTarget: null,
pendingConfirmModal: null,
initialize: async (forceReset = false) => {
set({ isLoading: true });
try {
const data = await initKccStorage(forceReset);
set({
...data,
isInitialized: true,
isLoading: false,
});
} catch (err) {
console.error('[KCC DB] Failed to init indexedDB:', err);
set({ isLoading: false });
}
},
setActiveRole: (role) => {
set({
activeRole: role,
activeOperator: DEFAULT_OPERATORS[role],
});
},
setAccessGatePassed: (passed) => {
if (typeof window !== 'undefined') {
if (passed) localStorage.setItem('kcc_access_gate', '1');
else localStorage.removeItem('kcc_access_gate');
}
set({ isAccessGatePassed: passed });
},
openInspector: (type, id) => set({ inspectorTarget: { type, id } }),
closeInspector: () => set({ inspectorTarget: null }),
setServerMonitorOutage: (outage) => set({ serverMonitorOutage: outage }),
toggleServerMonitorOutage: () => set((state) => ({ serverMonitorOutage: !state.serverMonitorOutage })),
openConfirmModal: (config) => set({ pendingConfirmModal: config }),
closeConfirmModal: () => set({ pendingConfirmModal: null }),
recordAuditEvent: async ({ action, target, reason, impactSummary }) => {
const { activeOperator, auditLog } = get();
const prevEvent = auditLog[0];
const previousHash = prevEvent ? prevEvent.hash : 'GENESIS_HASH_00000000000000000000000000000000000000000000000000000000000000';
const eventId = `evt-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`;
const occurredAt = new Date().toISOString();
const correlationId = `corr-${Date.now().toString(36)}`;
const hashPayload = `${previousHash}|${occurredAt}|${activeOperator.id}|${activeOperator.role}|${action}|${target.type}:${target.id}|${reason}|${correlationId}`;
const hash = await computeSha256(hashPayload);
const event: OperatorAuditEvent = {
eventId,
occurredAt,
operatorId: activeOperator.id,
operatorName: activeOperator.name,
operatorRole: activeOperator.role,
action,
target,
reason,
impactSummary,
correlationId,
previousHash,
hash,
};
await persistItem('audit_events', event);
set((state) => ({ auditLog: [event, ...state.auditLog] }));
return event;
},
updateOrganizationStatus: async (tenantId, status, reason) => {
const { organizations, recordAuditEvent } = get();
const org = organizations.find((o) => o.tenantId === tenantId);
if (!org) return;
const oldStatus = org.platformStatus;
const updated: OrganizationProjection = {
...org,
platformStatus: status,
projectedAt: new Date().toISOString(),
};
await persistItem('organizations', updated);
set((state) => ({
organizations: state.organizations.map((o) => (o.tenantId === tenantId ? updated : o)),
}));
await recordAuditEvent({
action: 'organization.status_changed',
target: {
type: 'organization',
id: tenantId,
displayName: org.displayName,
tenantId,
},
reason,
impactSummary: `Статус организации изменен с «${oldStatus}» на «${status}»`,
});
},
grantModuleTrial: async (tenantId, moduleId, durationDays, reason) => {
const { organizations, modules, recordAuditEvent } = get();
const org = organizations.find((o) => o.tenantId === tenantId);
const mod = modules.find((m) => m.moduleId === moduleId);
if (!org || !mod) return;
const trialUntil = new Date(Date.now() + durationDays * 24 * 3600 * 1000).toISOString();
const updated: OrganizationProjection = {
...org,
installedModulesCount: org.installedModulesCount + 1,
tariffPlan: `${org.tariffPlan} (+Trial ${mod.name})`,
projectedAt: new Date().toISOString(),
};
await persistItem('organizations', updated);
set((state) => ({
organizations: state.organizations.map((o) => (o.tenantId === tenantId ? updated : o)),
}));
await recordAuditEvent({
action: 'entitlement.trial_granted',
target: {
type: 'organization',
id: tenantId,
displayName: org.displayName,
tenantId,
},
reason,
impactSummary: `Выдан пробный доступ к модулю «${mod.name}» на ${durationDays} дней (до ${trialUntil.slice(0, 10)})`,
});
},
grantGracePeriod: async (subscriptionId, days, reason) => {
const { subscriptions, recordAuditEvent } = get();
const sub = subscriptions.find((s) => s.subscriptionId === subscriptionId);
if (!sub) return;
const graceUntil = new Date(Date.now() + days * 24 * 3600 * 1000).toISOString();
const updated: Subscription = {
...sub,
status: 'grace',
graceUntil,
history: [
{
eventAt: new Date().toISOString(),
type: 'grace_granted',
description: `Предоставлен льготный период на ${days} дней`,
actor: get().activeOperator.name,
},
...sub.history,
],
};
await persistItem('subscriptions', updated);
set((state) => ({
subscriptions: state.subscriptions.map((s) => (s.subscriptionId === subscriptionId ? updated : s)),
}));
await recordAuditEvent({
action: 'subscription.grace_period_granted',
target: {
type: 'subscription',
id: subscriptionId,
displayName: sub.tenantName,
tenantId: sub.tenantId,
},
reason,
impactSummary: `Установлен Grace Period на ${days} дней (до ${graceUntil.slice(0, 10)})`,
});
},
renewSubscription: async (subscriptionId, newPeriod, activeModules, reason) => {
const { subscriptions, recordAuditEvent } = get();
const sub = subscriptions.find((s) => s.subscriptionId === subscriptionId);
if (!sub) return;
const currentEnd = new Date(sub.validUntil).getTime();
const addMs = (newPeriod === 'year' ? 365 : 30) * 24 * 3600 * 1000;
const newUntil = new Date(Math.max(Date.now(), currentEnd) + addMs).toISOString();
const updated: Subscription = {
...sub,
period: newPeriod,
activeModules,
status: 'normal',
validUntil: newUntil,
renewsAt: newUntil,
graceUntil: null,
history: [
{
eventAt: new Date().toISOString(),
type: 'renewed',
description: `Продление тарифа на ${newPeriod === 'year' ? '1 год' : '1 месяц'}`,
actor: get().activeOperator.name,
},
...sub.history,
],
};
await persistItem('subscriptions', updated);
set((state) => ({
subscriptions: state.subscriptions.map((s) => (s.subscriptionId === subscriptionId ? updated : s)),
}));
await recordAuditEvent({
action: 'subscription.renewed',
target: {
type: 'subscription',
id: subscriptionId,
displayName: sub.tenantName,
tenantId: sub.tenantId,
},
reason,
impactSummary: `Продление подписки до ${newUntil.slice(0, 10)}, активных модулей: ${activeModules.length}`,
});
},
createIncidentFromAlert: async (serverId, summary, severity, reason) => {
const { servers, instances, recordAuditEvent, incidents } = get();
const srv = servers.find((s) => s.serverId === serverId);
const affectedTenantIds = instances
.filter((i) => i.serverId === serverId)
.map((i) => i.tenantId);
const affectedModuleIds = Array.from(
new Set(instances.filter((i) => i.serverId === serverId).map((i) => i.moduleId))
);
const incNumber = `INC-${1050 + incidents.length}`;
const incidentId = `inc-${Date.now().toString(36)}`;
const openedAt = new Date().toISOString();
const newIncident: Incident = {
incidentId,
incidentNumber: incNumber,
severity,
status: 'open',
summary,
description: `Инцидент открыт оператором из телеметрического оповещения узла ${srv ? srv.name : serverId}.`,
affectedTenantIds,
affectedServerIds: [serverId],
affectedModuleIds,
ownerName: get().activeOperator.name,
openedAt,
timeline: [
{
timestamp: openedAt,
author: get().activeOperator.name,
message: `Инцидент зарегистрирован в системе. Причина: ${reason}`,
},
],
};
await persistItem('incidents', newIncident);
set((state) => ({ incidents: [newIncident, ...state.incidents] }));
await recordAuditEvent({
action: 'incident.created',
target: {
type: 'incident',
id: incidentId,
displayName: `${incNumber} (${summary})`,
},
reason,
impactSummary: `Создан инцидент ${incNumber} со степенью критичности ${severity}`,
});
},
resolveIncident: async (incidentId, reason) => {
const { incidents, recordAuditEvent, activeOperator } = get();
const inc = incidents.find((i) => i.incidentId === incidentId);
if (!inc) return;
const resolvedAt = new Date().toISOString();
const updated: Incident = {
...inc,
status: 'resolved',
resolvedAt,
timeline: [
...inc.timeline,
{
timestamp: resolvedAt,
author: activeOperator.name,
message: `Инцидент переведен в статус resolved. Решение: ${reason}`,
},
],
};
await persistItem('incidents', updated);
set((state) => ({
incidents: state.incidents.map((i) => (i.incidentId === incidentId ? updated : i)),
}));
await recordAuditEvent({
action: 'incident.resolved',
target: {
type: 'incident',
id: incidentId,
displayName: inc.incidentNumber,
},
reason,
impactSummary: `Инцидент ${inc.incidentNumber} успешно разрешен`,
});
},
createAnnouncement: async (announcementData, reason) => {
const { recordAuditEvent, activeOperator, organizations } = get();
const announcementId = `anc-${Date.now().toString(36)}`;
const createdAt = new Date().toISOString();
let targetCount = organizations.length;
if (announcementData.audience.tenantIds && announcementData.audience.tenantIds.length > 0) {
targetCount = announcementData.audience.tenantIds.length;
}
const newAnnouncement: Announcement = {
...announcementData,
announcementId,
createdAt,
author: activeOperator.name,
stats: {
totalTargeted: targetCount,
queued: targetCount,
delivered: 0,
failed: 0,
},
};
await persistItem('announcements', newAnnouncement);
set((state) => ({ announcements: [newAnnouncement, ...state.announcements] }));
await recordAuditEvent({
action: 'announcement.created',
target: {
type: 'announcement',
id: announcementId,
displayName: announcementData.title,
},
reason,
impactSummary: `Создано оповещение «${announcementData.title}» для ${targetCount} организаций`,
});
},
triggerRolloutStage: async (rolloutId, stage, percentage, reason) => {
const { rollouts, recordAuditEvent } = get();
const rol = rollouts.find((r) => r.rolloutId === rolloutId);
if (!rol) return;
const updated: Rollout = {
...rol,
stage,
percentage,
updatedAt: new Date().toISOString(),
};
await persistItem('rollouts', updated);
set((state) => ({
rollouts: state.rollouts.map((r) => (r.rolloutId === rolloutId ? updated : r)),
}));
await recordAuditEvent({
action: `rollout.stage_${stage}`,
target: {
type: 'rollout',
id: rolloutId,
displayName: `${rol.moduleName} ${rol.targetVersion}`,
},
reason,
impactSummary: `Раскатка переведена в стадию «${stage}» (${percentage}%)`,
});
},
createRollout: async (moduleId, version, stage, pilotTenantIds, percentage, notes, reason) => {
const { modules, recordAuditEvent } = get();
const mod = modules.find((m) => m.moduleId === moduleId);
const rolloutId = `rol-${Date.now().toString(36)}`;
const now = new Date().toISOString();
const newRollout: Rollout = {
rolloutId,
moduleId,
moduleName: mod ? mod.name : moduleId,
targetVersion: version,
stage,
pilotTenantIds,
percentage,
startedAt: now,
updatedAt: now,
notes,
};
await persistItem('rollouts', newRollout);
set((state) => ({ rollouts: [newRollout, ...state.rollouts] }));
await recordAuditEvent({
action: 'rollout.created',
target: {
type: 'rollout',
id: rolloutId,
displayName: `${newRollout.moduleName} ${version}`,
},
reason,
impactSummary: `Инициирован конвейер раскатки ${newRollout.moduleName} ${version} (стадия ${stage})`,
});
},
requestServerDiagnostics: async (serverId, reason) => {
const { servers, recordAuditEvent } = get();
const srv = servers.find((s) => s.serverId === serverId);
if (!srv) return;
await recordAuditEvent({
action: 'infrastructure.diagnostics_requested',
target: {
type: 'infrastructure',
id: serverId,
displayName: srv.name,
},
reason,
impactSummary: `Запрошен полный дамп телеметрии и логов с узла ${srv.name}`,
});
},
scheduleServerMaintenance: async (serverId, reason) => {
const { servers, recordAuditEvent } = get();
const srv = servers.find((s) => s.serverId === serverId);
if (!srv) return;
await recordAuditEvent({
action: 'infrastructure.maintenance_scheduled',
target: {
type: 'infrastructure',
id: serverId,
displayName: srv.name,
},
reason,
impactSummary: `Запланировано регламентное техническое обслуживание для узла ${srv.name}`,
});
},
resetToDefaults: async () => {
await get().initialize(true);
},
}));
export function useOperatorRole() {
const activeRole = useKccStore((state) => state.activeRole);
const activeOperator = useKccStore((state) => state.activeOperator);
const roleDef = OPERATOR_ROLE_DEFINITIONS[activeRole];
return {
role: activeRole,
operator: activeOperator,
definition: roleDef,
permissions: roleDef.permissions,
};
}