feat(governance): Governance Engine MVP implementation
- Backend: - Migration 032: agent_gov_level, status, incidents, permissions tables - Domain types for governance layer - Permission Engine with all governance checks - Governance Service (promote/demote/roles) - Revocation Service (revoke/suspend/reinstate) - Audit Service (events filtering and stats) - Incidents Service (create/assign/escalate/resolve) - REST API routes for governance, audit, incidents - Frontend: - TypeScript types for governance - API clients for governance, audit, incidents - GovernanceLevelBadge component - CityGovernancePanel component - AuditDashboard component - IncidentsList component with detail modal Based on: Agent_Governance_Protocol_v1.md
This commit is contained in:
82
src/api/audit.ts
Normal file
82
src/api/audit.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Audit API Client
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
GovernanceEvent,
|
||||
GovernanceEventType,
|
||||
GovernanceScope,
|
||||
AuditEventFilter,
|
||||
AuditStats,
|
||||
} from '../types/governance';
|
||||
|
||||
// ============================================================================
|
||||
// AUDIT EVENTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAuditEvents(filter: AuditEventFilter = {}): Promise<{
|
||||
events: GovernanceEvent[];
|
||||
total: number;
|
||||
}> {
|
||||
const response = await apiClient.get('/audit/events', { params: filter });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAuditEvent(eventId: string): Promise<GovernanceEvent | null> {
|
||||
const response = await apiClient.get(`/audit/events/${eventId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByActor(actorId: string, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/actor/${actorId}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByTarget(targetId: string, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/target/${targetId}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByScope(scope: GovernanceScope, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/scope/${scope}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAuditStats(fromDate?: Date, toDate?: Date): Promise<AuditStats> {
|
||||
const params: Record<string, string> = {};
|
||||
if (fromDate) params.fromDate = fromDate.toISOString();
|
||||
if (toDate) params.toDate = toDate.toISOString();
|
||||
|
||||
const response = await apiClient.get('/audit/stats', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEntityHistory(
|
||||
entityType: 'agent' | 'microdao' | 'district' | 'node' | 'room',
|
||||
entityId: string,
|
||||
limit?: number
|
||||
): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/entity/${entityType}/${entityId}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const auditApi = {
|
||||
getAuditEvents,
|
||||
getAuditEvent,
|
||||
getEventsByActor,
|
||||
getEventsByTarget,
|
||||
getEventsByScope,
|
||||
getAuditStats,
|
||||
getEntityHistory,
|
||||
};
|
||||
|
||||
187
src/api/governance.ts
Normal file
187
src/api/governance.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Governance API Client
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
AgentGovLevel,
|
||||
AgentRolesResponse,
|
||||
GovernanceScope,
|
||||
RevocationType,
|
||||
AgentRevocation,
|
||||
GovernancePower,
|
||||
} from '../types/governance';
|
||||
|
||||
// ============================================================================
|
||||
// AGENT PROMOTION/DEMOTION
|
||||
// ============================================================================
|
||||
|
||||
export async function promoteAgent(params: {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
newLevel: AgentGovLevel;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post('/governance/agent/promote', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function demoteAgent(params: {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
newLevel: AgentGovLevel;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post('/governance/agent/demote', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
export async function revokeAgent(params: {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
revocationType?: RevocationType;
|
||||
}): Promise<{ success: boolean; revocationId?: string; error?: string }> {
|
||||
const response = await apiClient.post('/governance/agent/revoke', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function suspendAgent(params: {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
durationHours?: number;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post('/governance/agent/suspend', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function reinstateAgent(params: {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post('/governance/agent/reinstate', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getRevocationHistory(agentId: string): Promise<AgentRevocation[]> {
|
||||
const response = await apiClient.get(`/governance/agent/${agentId}/revocations`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT ROLES & PERMISSIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAgentRoles(agentId: string): Promise<AgentRolesResponse> {
|
||||
const response = await apiClient.get(`/governance/agent/${agentId}/roles`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAgentPermissions(
|
||||
agentId: string,
|
||||
targetType?: string,
|
||||
targetId?: string,
|
||||
action?: string
|
||||
): Promise<{ level: AgentGovLevel; powers: GovernancePower[] } | { hasPermission: boolean }> {
|
||||
const params: Record<string, string> = {};
|
||||
if (targetType) params.targetType = targetType;
|
||||
if (targetId) params.targetId = targetId;
|
||||
if (action) params.action = action;
|
||||
|
||||
const response = await apiClient.get(`/governance/agent/${agentId}/permissions`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PERMISSION CHECKS
|
||||
// ============================================================================
|
||||
|
||||
export async function checkPermission(params: {
|
||||
actorId: string;
|
||||
action: string;
|
||||
targetId?: string;
|
||||
scope?: GovernanceScope;
|
||||
roomType?: string;
|
||||
}): Promise<{ allowed: boolean; reason?: string; requiredLevel?: AgentGovLevel; requiredPower?: GovernancePower }> {
|
||||
const response = await apiClient.post('/governance/check', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE AGENTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getCityGovernanceAgents(): Promise<Array<{ id: string; name: string; role: string }>> {
|
||||
const response = await apiClient.get('/governance/agents/city');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getDistrictLeadAgents(districtId?: string): Promise<Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
districtId: string;
|
||||
districtName: string;
|
||||
}>> {
|
||||
const response = await apiClient.get('/governance/agents/district-leads', {
|
||||
params: districtId ? { districtId } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAgentsByLevel(
|
||||
level: AgentGovLevel,
|
||||
limit?: number
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
level: AgentGovLevel;
|
||||
status: string;
|
||||
homeMicrodaoId?: string;
|
||||
}>> {
|
||||
const response = await apiClient.get(`/governance/agents/by-level/${level}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DAIS KEY REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
export async function revokeDaisKeys(params: {
|
||||
actorId: string;
|
||||
daisId: string;
|
||||
reason: string;
|
||||
}): Promise<{ success: boolean; keysRevoked?: number; error?: string }> {
|
||||
const response = await apiClient.post('/governance/dais/keys/revoke', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const governanceApi = {
|
||||
promoteAgent,
|
||||
demoteAgent,
|
||||
revokeAgent,
|
||||
suspendAgent,
|
||||
reinstateAgent,
|
||||
getRevocationHistory,
|
||||
getAgentRoles,
|
||||
getAgentPermissions,
|
||||
checkPermission,
|
||||
getCityGovernanceAgents,
|
||||
getDistrictLeadAgents,
|
||||
getAgentsByLevel,
|
||||
revokeDaisKeys,
|
||||
};
|
||||
|
||||
143
src/api/incidents.ts
Normal file
143
src/api/incidents.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Incidents API Client
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Incident,
|
||||
IncidentHistory,
|
||||
IncidentStatus,
|
||||
IncidentPriority,
|
||||
EscalationLevel,
|
||||
TargetScopeType,
|
||||
IncidentsCount,
|
||||
} from '../types/governance';
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENTS CRUD
|
||||
// ============================================================================
|
||||
|
||||
export async function createIncident(params: {
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
priority?: IncidentPriority;
|
||||
title: string;
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<Incident> {
|
||||
const response = await apiClient.post('/incidents', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getIncident(incidentId: string): Promise<Incident | null> {
|
||||
const response = await apiClient.get(`/incidents/${incidentId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listIncidents(filters: {
|
||||
status?: IncidentStatus;
|
||||
priority?: IncidentPriority;
|
||||
escalationLevel?: EscalationLevel;
|
||||
targetScopeType?: TargetScopeType;
|
||||
targetScopeId?: string;
|
||||
assignedToDaisId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<{ incidents: Incident[]; total: number }> {
|
||||
const response = await apiClient.get('/incidents', { params: filters });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getIncidentsCount(): Promise<IncidentsCount> {
|
||||
const response = await apiClient.get('/incidents/count');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENT ACTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function assignIncident(params: {
|
||||
incidentId: string;
|
||||
assignedToDaisId: string;
|
||||
actorDaisId: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post(`/incidents/${params.incidentId}/assign`, {
|
||||
assignedToDaisId: params.assignedToDaisId,
|
||||
actorDaisId: params.actorDaisId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function escalateIncident(params: {
|
||||
incidentId: string;
|
||||
newLevel: EscalationLevel;
|
||||
actorDaisId: string;
|
||||
reason?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post(`/incidents/${params.incidentId}/escalate`, {
|
||||
newLevel: params.newLevel,
|
||||
actorDaisId: params.actorDaisId,
|
||||
reason: params.reason,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function resolveIncident(params: {
|
||||
incidentId: string;
|
||||
resolution: string;
|
||||
actorDaisId: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post(`/incidents/${params.incidentId}/resolve`, {
|
||||
resolution: params.resolution,
|
||||
actorDaisId: params.actorDaisId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function closeIncident(params: {
|
||||
incidentId: string;
|
||||
actorDaisId: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post(`/incidents/${params.incidentId}/close`, {
|
||||
actorDaisId: params.actorDaisId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function addIncidentComment(params: {
|
||||
incidentId: string;
|
||||
actorDaisId: string;
|
||||
comment: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await apiClient.post(`/incidents/${params.incidentId}/comment`, {
|
||||
actorDaisId: params.actorDaisId,
|
||||
comment: params.comment,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENT HISTORY
|
||||
// ============================================================================
|
||||
|
||||
export async function getIncidentHistory(incidentId: string): Promise<IncidentHistory[]> {
|
||||
const response = await apiClient.get(`/incidents/${incidentId}/history`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const incidentsApi = {
|
||||
createIncident,
|
||||
getIncident,
|
||||
listIncidents,
|
||||
getIncidentsCount,
|
||||
assignIncident,
|
||||
escalateIncident,
|
||||
resolveIncident,
|
||||
closeIncident,
|
||||
addIncidentComment,
|
||||
getIncidentHistory,
|
||||
};
|
||||
|
||||
307
src/features/governance/components/AuditDashboard.tsx
Normal file
307
src/features/governance/components/AuditDashboard.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Audit Dashboard
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Shows governance events from event_outbox with filters and statistics
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { auditApi } from '../../../api/audit';
|
||||
import type { GovernanceEvent, GovernanceEventType, AuditEventFilter } from '../../../types/governance';
|
||||
|
||||
const EVENT_TYPE_LABELS: Partial<Record<GovernanceEventType, string>> = {
|
||||
'agent.promoted': '⬆️ Просування агента',
|
||||
'agent.demoted': '⬇️ Зниження агента',
|
||||
'agent.revoked': '🚫 Відкликання агента',
|
||||
'agent.reinstated': '✅ Відновлення агента',
|
||||
'agent.assigned': '📋 Призначення',
|
||||
'incident.created': '⚠️ Створено інцидент',
|
||||
'incident.escalated': '📈 Ескалація',
|
||||
'incident.resolved': '✅ Вирішено',
|
||||
'microdao.created': '🏢 Створено MicroDAO',
|
||||
'district.created': '🏘️ Створено District',
|
||||
'node.registered': '🖥️ Зареєстровано Node',
|
||||
'room.created': '💬 Створено Room',
|
||||
};
|
||||
|
||||
export const AuditDashboard: React.FC = () => {
|
||||
const [filter, setFilter] = useState<AuditEventFilter>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [selectedEvent, setSelectedEvent] = useState<GovernanceEvent | null>(null);
|
||||
|
||||
// Fetch events
|
||||
const { data: eventsData, isLoading: loadingEvents } = useQuery({
|
||||
queryKey: ['audit', 'events', filter],
|
||||
queryFn: () => auditApi.getAuditEvents(filter),
|
||||
});
|
||||
|
||||
// Fetch stats
|
||||
const { data: stats, isLoading: loadingStats } = useQuery({
|
||||
queryKey: ['audit', 'stats'],
|
||||
queryFn: () => auditApi.getAuditStats(),
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: keyof AuditEventFilter, value: string | undefined) => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
[key]: value || undefined,
|
||||
offset: 0, // Reset pagination on filter change
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-900/50 to-cyan-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
📊 Audit Dashboard
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Перегляд governance-подій та аудит дій
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-6 border-b border-slate-700">
|
||||
<StatCard
|
||||
label="Всього подій"
|
||||
value={stats.totalEvents}
|
||||
icon="📈"
|
||||
/>
|
||||
<StatCard
|
||||
label="Типів подій"
|
||||
value={Object.keys(stats.eventsByType).length}
|
||||
icon="🏷️"
|
||||
/>
|
||||
<StatCard
|
||||
label="Активних учасників"
|
||||
value={stats.topActors.length}
|
||||
icon="👥"
|
||||
/>
|
||||
<StatCard
|
||||
label="Подій за 30 днів"
|
||||
value={stats.eventsByDay.reduce((sum, d) => sum + d.count, 0)}
|
||||
icon="📅"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-6 border-b border-slate-700 bg-slate-800/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Тип події</label>
|
||||
<select
|
||||
value={filter.eventType || ''}
|
||||
onChange={(e) => handleFilterChange('eventType', e.target.value as GovernanceEventType)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі типи</option>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
|
||||
<option key={type} value={type}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Actor ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.actorId || ''}
|
||||
onChange={(e) => handleFilterChange('actorId', e.target.value)}
|
||||
placeholder="Фільтр по актору..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Target ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.targetId || ''}
|
||||
onChange={(e) => handleFilterChange('targetId', e.target.value)}
|
||||
placeholder="Фільтр по цілі..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Scope</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.scope || ''}
|
||||
onChange={(e) => handleFilterChange('scope', e.target.value)}
|
||||
placeholder="city, district:..., microdao:..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="p-6">
|
||||
{loadingEvents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : eventsData?.events.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">Немає подій за заданими фільтрами</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{eventsData?.events.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{eventsData && eventsData.total > filter.limit! && (
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: Math.max(0, (prev.offset || 0) - prev.limit!) }))}
|
||||
disabled={!filter.offset}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
← Попередні
|
||||
</button>
|
||||
<span className="px-4 py-2 text-slate-400">
|
||||
{(filter.offset || 0) + 1} - {Math.min((filter.offset || 0) + filter.limit!, eventsData.total)} з {eventsData.total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: (prev.offset || 0) + prev.limit! }))}
|
||||
disabled={(filter.offset || 0) + filter.limit! >= eventsData.total}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
Наступні →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Detail Modal */}
|
||||
{selectedEvent && (
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Stat Card Component
|
||||
const StatCard: React.FC<{ label: string; value: number; icon: string }> = ({ label, value, icon }) => (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm mb-1">
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Event Row Component
|
||||
const EventRow: React.FC<{ event: GovernanceEvent; onClick: () => void }> = ({ event, onClick }) => {
|
||||
const label = EVENT_TYPE_LABELS[event.eventType] || event.eventType;
|
||||
|
||||
const statusColors = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||
published: 'bg-green-500/20 text-green-400',
|
||||
failed: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 hover:border-slate-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{label.split(' ')[0]}</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">{label.split(' ').slice(1).join(' ')}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{event.actorId} → {event.targetId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${statusColors[event.status]}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(event.createdAt).toLocaleString('uk-UA')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event Detail Modal
|
||||
const EventDetailModal: React.FC<{ event: GovernanceEvent; onClose: () => void }> = ({ event, onClose }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Деталі події</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">ID</div>
|
||||
<div className="text-white font-mono text-sm">{event.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Тип</div>
|
||||
<div className="text-white">{event.eventType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Actor</div>
|
||||
<div className="text-white">{event.actorId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Target</div>
|
||||
<div className="text-white">{event.targetId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Scope</div>
|
||||
<div className="text-white">{event.scope}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Статус</div>
|
||||
<div className="text-white">{event.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Створено</div>
|
||||
<div className="text-white">{new Date(event.createdAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
{event.publishedAt && (
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Опубліковано</div>
|
||||
<div className="text-white">{new Date(event.publishedAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-400 mb-2">Payload</div>
|
||||
<pre className="bg-slate-900 rounded-lg p-4 text-sm text-green-400 overflow-auto">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuditDashboard;
|
||||
|
||||
246
src/features/governance/components/CityGovernancePanel.tsx
Normal file
246
src/features/governance/components/CityGovernancePanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* City Governance Panel
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Shows city-level governance: DAARWIZZ, DARIO, DARIA, districts, city incidents
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { governanceApi } from '../../../api/governance';
|
||||
import { incidentsApi } from '../../../api/incidents';
|
||||
import { GovernanceLevelBadge } from './GovernanceLevelBadge';
|
||||
import type { Incident } from '../../../types/governance';
|
||||
|
||||
interface CityGovernancePanelProps {
|
||||
actorId?: string; // Current user's agent ID for actions
|
||||
}
|
||||
|
||||
export const CityGovernancePanel: React.FC<CityGovernancePanelProps> = ({ actorId }) => {
|
||||
const [activeTab, setActiveTab] = useState<'agents' | 'districts' | 'incidents'>('agents');
|
||||
|
||||
// Fetch city governance agents
|
||||
const { data: cityAgents, isLoading: loadingAgents } = useQuery({
|
||||
queryKey: ['governance', 'city-agents'],
|
||||
queryFn: () => governanceApi.getCityGovernanceAgents(),
|
||||
});
|
||||
|
||||
// Fetch district leads
|
||||
const { data: districtLeads, isLoading: loadingDistricts } = useQuery({
|
||||
queryKey: ['governance', 'district-leads'],
|
||||
queryFn: () => governanceApi.getDistrictLeadAgents(),
|
||||
});
|
||||
|
||||
// Fetch city-level incidents
|
||||
const { data: incidentsData, isLoading: loadingIncidents } = useQuery({
|
||||
queryKey: ['incidents', 'city'],
|
||||
queryFn: () => incidentsApi.listIncidents({ escalationLevel: 'city', status: 'open' }),
|
||||
});
|
||||
|
||||
// Fetch incidents count
|
||||
const { data: incidentsCount } = useQuery({
|
||||
queryKey: ['incidents', 'count'],
|
||||
queryFn: () => incidentsApi.getIncidentsCount(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-red-900/50 to-purple-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
🏛️ City Governance
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
DAARION.city — управління на рівні міста
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Incidents Counter */}
|
||||
{incidentsCount && incidentsCount.city > 0 && (
|
||||
<div className="bg-red-500/20 border border-red-500/50 rounded-lg px-4 py-2">
|
||||
<div className="text-2xl font-bold text-red-400">{incidentsCount.city}</div>
|
||||
<div className="text-xs text-red-300">відкритих інцидентів</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('agents')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'agents'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
👥 City Agents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('districts')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'districts'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
🏘️ Districts ({districtLeads?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('incidents')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
activeTab === 'incidents'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
⚠️ Incidents
|
||||
{incidentsCount && incidentsCount.city > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{incidentsCount.city}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* City Agents Tab */}
|
||||
{activeTab === 'agents' && (
|
||||
<div className="space-y-4">
|
||||
{loadingAgents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{cityAgents?.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-2xl">
|
||||
{agent.id === 'daarwizz' && '🧙'}
|
||||
{agent.id === 'dario' && '👋'}
|
||||
{agent.id === 'daria' && '⚙️'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{agent.name}</div>
|
||||
<div className="text-sm text-slate-400">{agent.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<GovernanceLevelBadge level="city_governance" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Districts Tab */}
|
||||
{activeTab === 'districts' && (
|
||||
<div className="space-y-4">
|
||||
{loadingDistricts ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : districtLeads?.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">Немає активних дистриктів</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{districtLeads?.map((district) => (
|
||||
<div
|
||||
key={district.districtId}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-white">{district.districtName}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
Lead: {district.agentName}
|
||||
</div>
|
||||
</div>
|
||||
<GovernanceLevelBadge level="district_lead" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incidents Tab */}
|
||||
{activeTab === 'incidents' && (
|
||||
<div className="space-y-4">
|
||||
{loadingIncidents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : incidentsData?.incidents.length === 0 ? (
|
||||
<div className="text-green-400 text-center py-8">
|
||||
✅ Немає відкритих інцидентів на рівні City
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{incidentsData?.incidents.map((incident: Incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Incident Card Component
|
||||
const IncidentCard: React.FC<{ incident: Incident }> = ({ incident }) => {
|
||||
const priorityColors = {
|
||||
low: 'border-gray-500 bg-gray-500/10',
|
||||
medium: 'border-yellow-500 bg-yellow-500/10',
|
||||
high: 'border-orange-500 bg-orange-500/10',
|
||||
critical: 'border-red-500 bg-red-500/10',
|
||||
};
|
||||
|
||||
const priorityLabels = {
|
||||
low: 'Низький',
|
||||
medium: 'Середній',
|
||||
high: 'Високий',
|
||||
critical: 'Критичний',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-4 border ${priorityColors[incident.priority]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">{incident.title}</div>
|
||||
{incident.description && (
|
||||
<div className="text-sm text-slate-400 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
<span>📍 {incident.targetScopeType}: {incident.targetScopeId}</span>
|
||||
<span>⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`
|
||||
px-2 py-1 rounded text-xs font-medium
|
||||
${incident.priority === 'critical' ? 'bg-red-500 text-white' : ''}
|
||||
${incident.priority === 'high' ? 'bg-orange-500 text-white' : ''}
|
||||
${incident.priority === 'medium' ? 'bg-yellow-500 text-black' : ''}
|
||||
${incident.priority === 'low' ? 'bg-gray-500 text-white' : ''}
|
||||
`}>
|
||||
{priorityLabels[incident.priority]}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{incident.status === 'open' ? '🔴 Відкрито' : '🟡 В роботі'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CityGovernancePanel;
|
||||
|
||||
72
src/features/governance/components/GovernanceLevelBadge.tsx
Normal file
72
src/features/governance/components/GovernanceLevelBadge.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Governance Level Badge
|
||||
* Displays agent governance level with color coding
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { AgentGovLevel, AgentStatus } from '../../../types/governance';
|
||||
import { GOV_LEVEL_LABELS, GOV_LEVEL_COLORS, AGENT_STATUS_LABELS } from '../../../types/governance';
|
||||
|
||||
interface GovernanceLevelBadgeProps {
|
||||
level: AgentGovLevel;
|
||||
status?: AgentStatus;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-3 py-1',
|
||||
lg: 'text-base px-4 py-1.5',
|
||||
};
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
blue: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
green: 'bg-green-100 text-green-700 border-green-300',
|
||||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
orange: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
purple: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
pink: 'bg-pink-100 text-pink-700 border-pink-300',
|
||||
red: 'bg-red-100 text-red-700 border-red-300',
|
||||
};
|
||||
|
||||
export const GovernanceLevelBadge: React.FC<GovernanceLevelBadgeProps> = ({
|
||||
level,
|
||||
status,
|
||||
showLabel = true,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const color = GOV_LEVEL_COLORS[level] || 'gray';
|
||||
const label = GOV_LEVEL_LABELS[level] || level;
|
||||
|
||||
const isRevoked = status === 'revoked';
|
||||
const isSuspended = status === 'suspended';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full border font-medium
|
||||
${sizeClasses[size]}
|
||||
${isRevoked ? 'bg-red-100 text-red-700 border-red-300 line-through' : colorClasses[color]}
|
||||
${isSuspended ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
{showLabel && label}
|
||||
</span>
|
||||
|
||||
{status && status !== 'active' && (
|
||||
<span className={`
|
||||
inline-flex items-center rounded-full text-xs px-2 py-0.5 font-medium
|
||||
${isRevoked ? 'bg-red-500 text-white' : 'bg-yellow-500 text-white'}
|
||||
`}>
|
||||
{AGENT_STATUS_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GovernanceLevelBadge;
|
||||
|
||||
519
src/features/governance/components/IncidentsList.tsx
Normal file
519
src/features/governance/components/IncidentsList.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Incidents List
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*
|
||||
* Shows incidents with filters and actions
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { incidentsApi } from '../../../api/incidents';
|
||||
import type {
|
||||
Incident,
|
||||
IncidentStatus,
|
||||
IncidentPriority,
|
||||
EscalationLevel,
|
||||
IncidentHistory,
|
||||
} from '../../../types/governance';
|
||||
import {
|
||||
INCIDENT_STATUS_LABELS,
|
||||
INCIDENT_PRIORITY_LABELS,
|
||||
INCIDENT_PRIORITY_COLORS,
|
||||
ESCALATION_LABELS,
|
||||
} from '../../../types/governance';
|
||||
|
||||
interface IncidentsListProps {
|
||||
defaultFilter?: {
|
||||
status?: IncidentStatus;
|
||||
escalationLevel?: EscalationLevel;
|
||||
targetScopeId?: string;
|
||||
};
|
||||
actorDaisId?: string;
|
||||
showCreateButton?: boolean;
|
||||
}
|
||||
|
||||
export const IncidentsList: React.FC<IncidentsListProps> = ({
|
||||
defaultFilter = {},
|
||||
actorDaisId,
|
||||
showCreateButton = false,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState({
|
||||
status: defaultFilter.status,
|
||||
escalationLevel: defaultFilter.escalationLevel,
|
||||
targetScopeId: defaultFilter.targetScopeId,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Fetch incidents
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['incidents', filter],
|
||||
queryFn: () => incidentsApi.listIncidents(filter),
|
||||
});
|
||||
|
||||
// Escalate mutation
|
||||
const escalateMutation = useMutation({
|
||||
mutationFn: (params: { incidentId: string; newLevel: EscalationLevel }) =>
|
||||
incidentsApi.escalateIncident({
|
||||
...params,
|
||||
actorDaisId: actorDaisId!,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['incidents'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve mutation
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (params: { incidentId: string; resolution: string }) =>
|
||||
incidentsApi.resolveIncident({
|
||||
...params,
|
||||
actorDaisId: actorDaisId!,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['incidents'] });
|
||||
setSelectedIncident(null);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-orange-900/50 to-red-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
⚠️ Incidents
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Управління інцидентами та ескалація
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreateButton && actorDaisId && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
+ Створити інцидент
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex flex-wrap gap-4">
|
||||
<select
|
||||
value={filter.status || ''}
|
||||
onChange={(e) => setFilter(prev => ({ ...prev, status: e.target.value as IncidentStatus || undefined, offset: 0 }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі статуси</option>
|
||||
{Object.entries(INCIDENT_STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filter.escalationLevel || ''}
|
||||
onChange={(e) => setFilter(prev => ({ ...prev, escalationLevel: e.target.value as EscalationLevel || undefined, offset: 0 }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі рівні</option>
|
||||
{Object.entries(ESCALATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : data?.incidents.length === 0 ? (
|
||||
<div className="text-green-400 text-center py-8">
|
||||
✅ Немає інцидентів за заданими фільтрами
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data?.incidents.map((incident) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
incident={incident}
|
||||
onSelect={() => setSelectedIncident(incident)}
|
||||
onEscalate={actorDaisId ? (level) => escalateMutation.mutate({ incidentId: incident.id, newLevel: level }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > filter.limit && (
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: Math.max(0, prev.offset - prev.limit) }))}
|
||||
disabled={!filter.offset}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
← Попередні
|
||||
</button>
|
||||
<span className="px-4 py-2 text-slate-400">
|
||||
{filter.offset + 1} - {Math.min(filter.offset + filter.limit, data.total)} з {data.total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: prev.offset + prev.limit }))}
|
||||
disabled={filter.offset + filter.limit >= data.total}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
Наступні →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedIncident && (
|
||||
<IncidentDetailModal
|
||||
incident={selectedIncident}
|
||||
actorDaisId={actorDaisId}
|
||||
onClose={() => setSelectedIncident(null)}
|
||||
onResolve={(resolution) => resolveMutation.mutate({ incidentId: selectedIncident.id, resolution })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && actorDaisId && (
|
||||
<CreateIncidentModal
|
||||
actorDaisId={actorDaisId}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['incidents'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Incident Card
|
||||
const IncidentCard: React.FC<{
|
||||
incident: Incident;
|
||||
onSelect: () => void;
|
||||
onEscalate?: (level: EscalationLevel) => void;
|
||||
}> = ({ incident, onSelect, onEscalate }) => {
|
||||
const priorityBg = {
|
||||
low: 'border-gray-500/30 bg-gray-500/10',
|
||||
medium: 'border-yellow-500/30 bg-yellow-500/10',
|
||||
high: 'border-orange-500/30 bg-orange-500/10',
|
||||
critical: 'border-red-500/30 bg-red-500/10 animate-pulse',
|
||||
};
|
||||
|
||||
const nextLevel: Record<EscalationLevel, EscalationLevel | null> = {
|
||||
microdao: 'district',
|
||||
district: 'city',
|
||||
city: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-4 border ${priorityBg[incident.priority]} cursor-pointer hover:brightness-110 transition-all`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1" onClick={onSelect}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${incident.priority === 'critical' ? 'bg-red-500 text-white' : ''}
|
||||
${incident.priority === 'high' ? 'bg-orange-500 text-white' : ''}
|
||||
${incident.priority === 'medium' ? 'bg-yellow-500 text-black' : ''}
|
||||
${incident.priority === 'low' ? 'bg-gray-500 text-white' : ''}
|
||||
`}>
|
||||
{INCIDENT_PRIORITY_LABELS[incident.priority]}
|
||||
</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${incident.status === 'open' ? 'bg-red-900/50 text-red-300' : ''}
|
||||
${incident.status === 'in_progress' ? 'bg-yellow-900/50 text-yellow-300' : ''}
|
||||
${incident.status === 'resolved' ? 'bg-green-900/50 text-green-300' : ''}
|
||||
${incident.status === 'closed' ? 'bg-gray-900/50 text-gray-300' : ''}
|
||||
`}>
|
||||
{INCIDENT_STATUS_LABELS[incident.status]}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-purple-900/50 text-purple-300">
|
||||
{ESCALATION_LABELS[incident.escalationLevel]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-medium text-white mt-2">{incident.title}</div>
|
||||
|
||||
{incident.description && (
|
||||
<div className="text-sm text-slate-400 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span>📍 {incident.targetScopeType}: {incident.targetScopeId}</span>
|
||||
<span>⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')}</span>
|
||||
{incident.assignedToDaisId && (
|
||||
<span>👤 {incident.assignedToDaisId}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{onEscalate && nextLevel[incident.escalationLevel] && incident.status !== 'resolved' && incident.status !== 'closed' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEscalate(nextLevel[incident.escalationLevel]!);
|
||||
}}
|
||||
className="px-3 py-1 bg-orange-600 hover:bg-orange-500 text-white rounded text-xs font-medium"
|
||||
>
|
||||
Ескалювати → {ESCALATION_LABELS[nextLevel[incident.escalationLevel]!]}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Incident Detail Modal
|
||||
const IncidentDetailModal: React.FC<{
|
||||
incident: Incident;
|
||||
actorDaisId?: string;
|
||||
onClose: () => void;
|
||||
onResolve: (resolution: string) => void;
|
||||
}> = ({ incident, actorDaisId, onClose, onResolve }) => {
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
// Fetch history
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ['incident', incident.id, 'history'],
|
||||
queryFn: () => incidentsApi.getIncidentHistory(incident.id),
|
||||
});
|
||||
|
||||
const handleResolve = () => {
|
||||
if (resolution.trim()) {
|
||||
onResolve(resolution);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Деталі інциденту</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-white text-lg">{incident.title}</h4>
|
||||
{incident.description && (
|
||||
<p className="text-slate-400 mt-2">{incident.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
incident.priority === 'critical' ? 'bg-red-500 text-white' :
|
||||
incident.priority === 'high' ? 'bg-orange-500 text-white' :
|
||||
incident.priority === 'medium' ? 'bg-yellow-500 text-black' :
|
||||
'bg-gray-500 text-white'
|
||||
}`}>
|
||||
{INCIDENT_PRIORITY_LABELS[incident.priority]}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full text-sm bg-purple-600 text-white">
|
||||
{ESCALATION_LABELS[incident.escalationLevel]}
|
||||
</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
incident.status === 'open' ? 'bg-red-600' :
|
||||
incident.status === 'in_progress' ? 'bg-yellow-600' :
|
||||
incident.status === 'resolved' ? 'bg-green-600' :
|
||||
'bg-gray-600'
|
||||
} text-white`}>
|
||||
{INCIDENT_STATUS_LABELS[incident.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-400">Створено</div>
|
||||
<div className="text-white">{new Date(incident.createdAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-400">Ціль</div>
|
||||
<div className="text-white">{incident.targetScopeType}: {incident.targetScopeId}</div>
|
||||
</div>
|
||||
{incident.assignedToDaisId && (
|
||||
<div>
|
||||
<div className="text-slate-400">Призначено</div>
|
||||
<div className="text-white">{incident.assignedToDaisId}</div>
|
||||
</div>
|
||||
)}
|
||||
{incident.resolution && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-slate-400">Рішення</div>
|
||||
<div className="text-green-400">{incident.resolution}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history && history.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-3">Історія</h4>
|
||||
<div className="space-y-2">
|
||||
{history.map((h) => (
|
||||
<div key={h.id} className="bg-slate-700/50 rounded p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white font-medium">{h.action}</span>
|
||||
<span className="text-slate-500 text-xs">
|
||||
{new Date(h.createdAt).toLocaleString('uk-UA')}
|
||||
</span>
|
||||
</div>
|
||||
{h.comment && <div className="text-slate-400 mt-1">{h.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve form */}
|
||||
{actorDaisId && incident.status !== 'resolved' && incident.status !== 'closed' && (
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-2">Вирішити інцидент</h4>
|
||||
<textarea
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
placeholder="Опишіть рішення..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm min-h-[80px]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={!resolution.trim()}
|
||||
className="mt-2 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
✅ Вирішити
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Incident Modal
|
||||
const CreateIncidentModal: React.FC<{
|
||||
actorDaisId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ actorDaisId, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium' as IncidentPriority,
|
||||
targetScopeType: 'microdao' as const,
|
||||
targetScopeId: '',
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => incidentsApi.createIncident({
|
||||
...form,
|
||||
createdByDaisId: actorDaisId,
|
||||
}),
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-lg w-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Створити інцидент</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Заголовок *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Опис</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Пріоритет</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, priority: e.target.value as IncidentPriority }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
{Object.entries(INCIDENT_PRIORITY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Тип цілі</label>
|
||||
<select
|
||||
value={form.targetScopeType}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, targetScopeType: e.target.value as any }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="microdao">MicroDAO</option>
|
||||
<option value="district">District</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="room">Room</option>
|
||||
<option value="node">Node</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">ID цілі *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.targetScopeId}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, targetScopeId: e.target.value }))}
|
||||
placeholder="Введіть ID..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.title || !form.targetScopeId || createMutation.isPending}
|
||||
className="w-full px-4 py-2 bg-red-600 hover:bg-red-500 disabled:opacity-50 text-white rounded-lg font-medium"
|
||||
>
|
||||
{createMutation.isPending ? 'Створення...' : 'Створити інцидент'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentsList;
|
||||
|
||||
295
src/types/governance.ts
Normal file
295
src/types/governance.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Governance Types
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE LEVELS
|
||||
// ============================================================================
|
||||
|
||||
export const AGENT_LEVELS = {
|
||||
GUEST: 0,
|
||||
PERSONAL: 1,
|
||||
MEMBER: 2,
|
||||
WORKER: 3,
|
||||
CORE_TEAM: 4,
|
||||
ORCHESTRATOR: 5,
|
||||
DISTRICT_LEAD: 6,
|
||||
CITY_GOVERNANCE: 7,
|
||||
} as const;
|
||||
|
||||
export type AgentLevelNum = typeof AGENT_LEVELS[keyof typeof AGENT_LEVELS];
|
||||
|
||||
export type AgentGovLevel =
|
||||
| 'guest'
|
||||
| 'personal'
|
||||
| 'member'
|
||||
| 'worker'
|
||||
| 'core_team'
|
||||
| 'orchestrator'
|
||||
| 'district_lead'
|
||||
| 'city_governance';
|
||||
|
||||
export const GOV_LEVEL_LABELS: Record<AgentGovLevel, string> = {
|
||||
guest: 'Guest',
|
||||
personal: 'Personal',
|
||||
member: 'Member',
|
||||
worker: 'Worker',
|
||||
core_team: 'Core Team',
|
||||
orchestrator: 'Orchestrator',
|
||||
district_lead: 'District Lead',
|
||||
city_governance: 'City Governance',
|
||||
};
|
||||
|
||||
export const GOV_LEVEL_COLORS: Record<AgentGovLevel, string> = {
|
||||
guest: 'gray',
|
||||
personal: 'blue',
|
||||
member: 'green',
|
||||
worker: 'yellow',
|
||||
core_team: 'orange',
|
||||
orchestrator: 'purple',
|
||||
district_lead: 'pink',
|
||||
city_governance: 'red',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AGENT STATUS
|
||||
// ============================================================================
|
||||
|
||||
export type AgentStatus = 'active' | 'suspended' | 'revoked';
|
||||
|
||||
export const AGENT_STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
active: 'Активний',
|
||||
suspended: 'Призупинено',
|
||||
revoked: 'Заблоковано',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE POWERS
|
||||
// ============================================================================
|
||||
|
||||
export type GovernancePower =
|
||||
| 'administrative'
|
||||
| 'moderation'
|
||||
| 'execution'
|
||||
| 'infrastructure'
|
||||
| 'identity'
|
||||
| 'protocol'
|
||||
| 'district';
|
||||
|
||||
export const POWER_LABELS: Record<GovernancePower, string> = {
|
||||
administrative: 'Адміністрування',
|
||||
moderation: 'Модерація',
|
||||
execution: 'Виконання',
|
||||
infrastructure: 'Інфраструктура',
|
||||
identity: 'Ідентичність',
|
||||
protocol: 'Протокол',
|
||||
district: 'Район',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE SCOPE
|
||||
// ============================================================================
|
||||
|
||||
export type GovernanceScope = 'city' | `district:${string}` | `microdao:${string}` | `node:${string}`;
|
||||
|
||||
// ============================================================================
|
||||
// REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
export type RevocationType = 'soft' | 'hard' | 'shadow';
|
||||
|
||||
export const REVOCATION_LABELS: Record<RevocationType, string> = {
|
||||
soft: 'Тимчасове',
|
||||
hard: 'Постійне',
|
||||
shadow: 'Тіньове',
|
||||
};
|
||||
|
||||
export interface AgentRevocation {
|
||||
id: string;
|
||||
agentId: string;
|
||||
daisId?: string;
|
||||
revokedBy: string;
|
||||
revocationType: RevocationType;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
keysInvalidated: boolean;
|
||||
walletDisabled: boolean;
|
||||
roomAccessRevoked: boolean;
|
||||
nodePrivilegesRemoved: boolean;
|
||||
assignmentsTerminated: boolean;
|
||||
reversible: boolean;
|
||||
reversedAt?: Date;
|
||||
reversedBy?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENTS
|
||||
// ============================================================================
|
||||
|
||||
export type IncidentStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
export type IncidentPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type EscalationLevel = 'microdao' | 'district' | 'city';
|
||||
export type TargetScopeType = 'city' | 'district' | 'microdao' | 'room' | 'node' | 'agent';
|
||||
|
||||
export const INCIDENT_STATUS_LABELS: Record<IncidentStatus, string> = {
|
||||
open: 'Відкрито',
|
||||
in_progress: 'В роботі',
|
||||
resolved: 'Вирішено',
|
||||
closed: 'Закрито',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_LABELS: Record<IncidentPriority, string> = {
|
||||
low: 'Низький',
|
||||
medium: 'Середній',
|
||||
high: 'Високий',
|
||||
critical: 'Критичний',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_COLORS: Record<IncidentPriority, string> = {
|
||||
low: 'gray',
|
||||
medium: 'yellow',
|
||||
high: 'orange',
|
||||
critical: 'red',
|
||||
};
|
||||
|
||||
export const ESCALATION_LABELS: Record<EscalationLevel, string> = {
|
||||
microdao: 'MicroDAO',
|
||||
district: 'District',
|
||||
city: 'City',
|
||||
};
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
status: IncidentStatus;
|
||||
priority: IncidentPriority;
|
||||
assignedToDaisId?: string;
|
||||
escalationLevel: EscalationLevel;
|
||||
title: string;
|
||||
description?: string;
|
||||
resolution?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
resolvedAt?: Date;
|
||||
closedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IncidentHistory {
|
||||
id: string;
|
||||
incidentId: string;
|
||||
action: 'created' | 'assigned' | 'escalated' | 'resolved' | 'closed' | 'comment';
|
||||
actorDaisId: string;
|
||||
oldValue?: Record<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
comment?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIT EVENTS
|
||||
// ============================================================================
|
||||
|
||||
export type GovernanceEventType =
|
||||
| 'agent.promoted'
|
||||
| 'agent.demoted'
|
||||
| 'agent.revoked'
|
||||
| 'agent.reinstated'
|
||||
| 'agent.assigned'
|
||||
| 'agent.unassigned'
|
||||
| 'permission.granted'
|
||||
| 'permission.revoked'
|
||||
| 'incident.created'
|
||||
| 'incident.assigned'
|
||||
| 'incident.escalated'
|
||||
| 'incident.resolved'
|
||||
| 'incident.closed'
|
||||
| 'microdao.created'
|
||||
| 'district.created'
|
||||
| 'node.registered'
|
||||
| 'room.created'
|
||||
| 'room.published_to_city';
|
||||
|
||||
export interface GovernanceEvent {
|
||||
id: string;
|
||||
eventType: GovernanceEventType;
|
||||
subject: string;
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
scope: GovernanceScope;
|
||||
payload: Record<string, unknown>;
|
||||
version: string;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: Date;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API REQUESTS/RESPONSES
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentRolesResponse {
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
powers: GovernancePower[];
|
||||
assignments: Array<{
|
||||
microdaoId: string;
|
||||
role: string;
|
||||
scope: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PromoteAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
newLevel: AgentGovLevel;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RevokeAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
revocationType?: RevocationType;
|
||||
}
|
||||
|
||||
export interface CreateIncidentRequest {
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
priority?: IncidentPriority;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AuditEventFilter {
|
||||
eventType?: GovernanceEventType;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
scope?: GovernanceScope;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
totalEvents: number;
|
||||
eventsByType: Record<string, number>;
|
||||
eventsByDay: Array<{ date: string; count: number }>;
|
||||
topActors: Array<{ actorId: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface IncidentsCount {
|
||||
microdao: number;
|
||||
district: number;
|
||||
city: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user