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:
Apple
2025-11-29 16:02:06 -08:00
parent 2627205663
commit e233d32ae7
20 changed files with 5837 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;