643 lines
20 KiB
TypeScript
643 lines
20 KiB
TypeScript
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,
|
||
};
|
||
}
|