feat: OCI Services page, compliance report v3, DOCX with camo strip, timezone per-user
- New OCI Services page with Service Status (auto-detect + overrides) and OCI Health (real-time Oracle status) tabs - Compliance report: OCI Services section with Cloud Guard recommendations table, back page with Connect with us - DOCX generation: professional styling with Pillow camouflage strip, green header tables, result boxes - Separate Download DOCX button in reports page - Timezone now per-user (not global) — each user configures their own timezone - ADB connection pre-check before report generation (fail fast 503) - Compliance report iframe shows friendly HTML instead of JSON 404 during generation - OCI Health dashboard: proxies ocistatus.oraclecloud.com with search, geo filters, expandable region cards
This commit is contained in:
@@ -3,4 +3,5 @@ APP_SECRET=change-me-generate-a-secure-random-string
|
||||
JWT_EXPIRY_HOURS=12
|
||||
PORT=8080
|
||||
CORS_ORIGINS=http://localhost:8080
|
||||
TZ=America/Sao_Paulo
|
||||
OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True
|
||||
|
||||
1605
backend/app.py
1605
backend/app.py
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,5 @@ mcp>=1.0.0
|
||||
requests>=2.32.0
|
||||
PyPDF2>=3.0.0
|
||||
cryptography>=44.0.0
|
||||
python-docx>=1.1.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12}
|
||||
- DATA_DIR=/data
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
- TZ=${TZ:-America/Sao_Paulo}
|
||||
- OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True
|
||||
volumes:
|
||||
- agent-data:/data
|
||||
@@ -34,6 +35,8 @@ services:
|
||||
image: nginx:alpine
|
||||
container_name: oci-agent-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=${TZ:-America/Sao_Paulo}
|
||||
volumes:
|
||||
- ./frontend-react/dist:/usr/share/nginx/html/app:ro
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
@@ -12,6 +12,7 @@ const TerraformPage = lazy(() => import('@/pages/TerraformPage'));
|
||||
const PromptGeneratorPage = lazy(() => import('@/pages/PromptGeneratorPage'));
|
||||
const WorkspacesPage = lazy(() => import('@/pages/WorkspacesPage'));
|
||||
const ExplorerPage = lazy(() => import('@/pages/ExplorerPage'));
|
||||
const OciServicesPage = lazy(() => import('@/pages/OciServicesPage'));
|
||||
const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
|
||||
const OciConfigPage = lazy(() => import('@/pages/config/OciConfigPage'));
|
||||
const GenAiConfigPage = lazy(() => import('@/pages/config/GenAiConfigPage'));
|
||||
@@ -56,6 +57,7 @@ export default function App() {
|
||||
<Route path="/terraform/prompt" element={<PromptGeneratorPage />} />
|
||||
<Route path="/terraform/workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="/explorer" element={<ExplorerPage />} />
|
||||
<Route path="/oci-services" element={<OciServicesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/config/oci" element={<OciConfigPage />} />
|
||||
<Route path="/config/genai" element={<GenAiConfigPage />} />
|
||||
|
||||
@@ -69,4 +69,13 @@ export const adminApi = {
|
||||
qs.set('limit', String(params.limit ?? 50));
|
||||
return client.get(`/chat-logs?${qs}`) as unknown as Promise<ChatLogEntry[]>;
|
||||
},
|
||||
|
||||
getMyTimezone: () =>
|
||||
client.get('/users/me/timezone') as unknown as Promise<{ timezone: string }>,
|
||||
|
||||
setMyTimezone: (timezone: string) =>
|
||||
client.put('/users/me/timezone', { timezone }) as unknown as Promise<{ ok: boolean; timezone: string }>,
|
||||
|
||||
getTimezoneOptions: () =>
|
||||
client.get('/settings/timezone/options') as unknown as Promise<{ timezones: string[] }>,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
mfa_enabled?: boolean;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
@@ -5,6 +5,7 @@ import client from '../client';
|
||||
export interface Report {
|
||||
id: string;
|
||||
tenancy_name: string;
|
||||
config_id?: string;
|
||||
status: string;
|
||||
level: number | null;
|
||||
regions?: string[];
|
||||
@@ -74,6 +75,37 @@ export interface CisEngineUpdate {
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export interface OciServiceStatus {
|
||||
service: string;
|
||||
auto_status?: string;
|
||||
auto_description?: string;
|
||||
status: string;
|
||||
description: string;
|
||||
overridden: boolean;
|
||||
}
|
||||
|
||||
export interface OciHealthService {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
serviceCanonicalName: string;
|
||||
serviceCategoryName: string;
|
||||
serviceStatus: string;
|
||||
incidents: { incidentId: string; title: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface OciHealthRegion {
|
||||
regionId: string;
|
||||
regionName: string;
|
||||
regionCanonicalName: string;
|
||||
geographicAreaName: string;
|
||||
serviceHealthReports: OciHealthService[];
|
||||
}
|
||||
|
||||
export interface OciHealthResponse {
|
||||
realm?: string;
|
||||
regionHealthReports: OciHealthRegion[];
|
||||
}
|
||||
|
||||
/* ── API ── */
|
||||
|
||||
export const reportsApi = {
|
||||
@@ -95,9 +127,9 @@ export const reportsApi = {
|
||||
summary: (rid: string) =>
|
||||
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
|
||||
|
||||
htmlUrl: (rid: string) => `/api/reports/${rid}/html`,
|
||||
htmlUrl: (rid: string) => `/api/reports/${rid}/html?_t=${Date.now()}`,
|
||||
|
||||
complianceReportUrl: (rid: string) => `/api/reports/${rid}/compliance-report`,
|
||||
complianceReportUrl: (rid: string) => `/api/reports/${rid}/compliance-report?_t=${Date.now()}`,
|
||||
|
||||
generateComplianceReport: (rid: string) =>
|
||||
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
|
||||
@@ -110,6 +142,11 @@ export const reportsApi = {
|
||||
return `/api/reports/${rid}/compliance-report/download?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
complianceReportDocxUrl: (rid: string) => {
|
||||
const token = localStorage.getItem('t') || '';
|
||||
return `/api/reports/${rid}/compliance-report/docx?token=${encodeURIComponent(token)}`;
|
||||
},
|
||||
|
||||
files: (rid: string) =>
|
||||
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,
|
||||
|
||||
@@ -147,4 +184,13 @@ export const reportsApi = {
|
||||
|
||||
embeddingStatus: (taskId: string) =>
|
||||
client.get(`/embeddings/status/${taskId}`) as unknown as Promise<{ status: string; message: string; inserted?: number; total?: number }>,
|
||||
|
||||
getOciServices: (configId: string, detect = true) =>
|
||||
client.get(`/oci-services/${configId}?detect=${detect ? 1 : 0}`) as unknown as Promise<OciServiceStatus[]>,
|
||||
|
||||
setOciServices: (configId: string, services: OciServiceStatus[]) =>
|
||||
client.put(`/oci-services/${configId}`, services) as unknown as Promise<{ ok: boolean }>,
|
||||
|
||||
getOciHealth: () =>
|
||||
client.get('/oci-health') as unknown as Promise<OciHealthResponse>,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
MessageSquare, Search, BarChart3, Cloud,
|
||||
MessageSquare, Search, BarChart3, Cloud, Shield,
|
||||
Brain, Plug, Database, Dna, Users, FileText,
|
||||
Sparkles, Sun, Moon, LogOut, FolderOpen, Languages, Settings, ArrowLeft
|
||||
} from 'lucide-react';
|
||||
@@ -48,6 +48,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/terraform/prompt', icon: <Sparkles size={SZ} />, labelKey: 'nav.promptGen', sub: true },
|
||||
{ to: '/terraform/workspaces', icon: <FolderOpen size={SZ} />, labelKey: 'nav.workspaces', sub: true },
|
||||
{ to: '/explorer', icon: <Search size={SZ} />, labelKey: 'nav.explorer' },
|
||||
{ to: '/oci-services', icon: <Shield size={SZ} />, labelKey: 'nav.ociServices' },
|
||||
{ to: '/reports', icon: <BarChart3 size={SZ} />, labelKey: 'nav.reports' },
|
||||
{ to: '/config/embeddings/consult', icon: <MessageSquare size={SZ} />, labelKey: 'nav.embConsult' },
|
||||
];
|
||||
|
||||
@@ -7,6 +7,12 @@ const en: Record<string, string> = {
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.ociServices': 'OCI Services',
|
||||
'svc.title': 'OCI Services Status',
|
||||
'svc.subtitle': 'Check and configure OCI security services status per tenancy',
|
||||
'svc.redetect': 'Re-detect',
|
||||
'svc.checking': 'Checking OCI services...',
|
||||
'svc.save': 'Save',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.ociCreds': 'OCI Credentials',
|
||||
'nav.genai': 'GenAI Config',
|
||||
@@ -705,6 +711,7 @@ const en: Record<string, string> = {
|
||||
'usr.errorUpdateRole': 'Error updating role',
|
||||
'usr.errorDeactivate': 'Error deactivating user',
|
||||
'usr.mfaSelfOnly': 'MFA can only be activated by the user themselves.',
|
||||
'usr.myTimezone': 'My Timezone',
|
||||
|
||||
'ws.typeDestroyLabel': 'Type <strong>DESTROY</strong> to confirm:',
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ const pt: Record<string, string> = {
|
||||
'nav.promptGen': 'Prompt Generator',
|
||||
'nav.workspaces': 'Workspaces',
|
||||
'nav.explorer': 'OCI Explorer',
|
||||
'nav.ociServices': 'OCI Services',
|
||||
'svc.title': 'OCI Services Status',
|
||||
'svc.subtitle': 'Verifique e configure o status dos servicos de seguranca OCI por tenancy',
|
||||
'svc.redetect': 'Re-detect',
|
||||
'svc.checking': 'Verificando servicos OCI...',
|
||||
'svc.save': 'Salvar',
|
||||
'nav.reports': 'Relatórios',
|
||||
'nav.ociCreds': 'Credenciais OCI',
|
||||
'nav.genai': 'GenAI Config',
|
||||
@@ -706,6 +712,7 @@ const pt: Record<string, string> = {
|
||||
'usr.errorUpdateRole': 'Erro ao atualizar role',
|
||||
'usr.errorDeactivate': 'Erro ao desativar usuario',
|
||||
'usr.mfaSelfOnly': 'MFA so pode ser ativado pelo proprio usuario.',
|
||||
'usr.myTimezone': 'Meu Timezone',
|
||||
|
||||
'ws.typeDestroyLabel': 'Digite <strong>DESTROY</strong> para confirmar:',
|
||||
|
||||
|
||||
454
frontend-react/src/pages/OciServicesPage.tsx
Normal file
454
frontend-react/src/pages/OciServicesPage.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import {
|
||||
reportsApi,
|
||||
type OciServiceStatus,
|
||||
type OciHealthRegion,
|
||||
} from '@/api/endpoints/reports';
|
||||
import {
|
||||
Shield, RefreshCw, Check, Loader2, ChevronDown, Server,
|
||||
Activity, Search, X, Globe, CheckCircle2, AlertTriangle, XCircle, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Status helpers ── */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; bg: string; dot: string }> = {
|
||||
NormalPerformance: { label: 'Operational', color: '#16a34a', bg: '#dcfce7', dot: '#22c55e' },
|
||||
DegradedPerformance: { label: 'Degraded', color: '#d97706', bg: '#fef3c7', dot: '#f59e0b' },
|
||||
ServiceDisruption: { label: 'Disruption', color: '#dc2626', bg: '#fef2f2', dot: '#ef4444' },
|
||||
MaintenanceInProgress: { label: 'Maintenance', color: '#2563eb', bg: '#eff6ff', dot: '#3b82f6' },
|
||||
};
|
||||
const getStatus = (s: string) => STATUS_MAP[s] || STATUS_MAP.NormalPerformance;
|
||||
|
||||
const GEO_ORDER = ['North America', 'LAD', 'EMEA', 'APAC', 'EU Sovereign'];
|
||||
const GEO_LABELS: Record<string, string> = {
|
||||
'North America': 'Americas',
|
||||
LAD: 'Latin America',
|
||||
EMEA: 'Europe, Middle East & Africa',
|
||||
APAC: 'Asia Pacific',
|
||||
'EU Sovereign': 'EU Sovereign',
|
||||
};
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
SERVICE STATUS TAB (existing functionality)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function ServiceStatusTab() {
|
||||
const { t } = useI18n();
|
||||
const ociCfg = useAppStore((s) => s.ociCfg);
|
||||
const [selectedConfig, setSelectedConfig] = useState('');
|
||||
const [services, setServices] = useState<OciServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: 's' | 'e'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConfig && ociCfg.length > 0) setSelectedConfig(ociCfg[0].id);
|
||||
}, [ociCfg, selectedConfig]);
|
||||
|
||||
const loadServices = useCallback(async (detect = true) => {
|
||||
if (!selectedConfig) return;
|
||||
setLoading(true); setMsg(null);
|
||||
try { setServices(await reportsApi.getOciServices(selectedConfig, detect)); }
|
||||
catch { setMsg({ type: 'e', text: 'Erro ao verificar servicos' }); }
|
||||
finally { setLoading(false); }
|
||||
}, [selectedConfig]);
|
||||
|
||||
useEffect(() => { if (selectedConfig) loadServices(true); }, [selectedConfig]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveServices = useCallback(async () => {
|
||||
if (!selectedConfig) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await reportsApi.setOciServices(selectedConfig, services);
|
||||
setMsg({ type: 's', text: 'Status salvo com sucesso' });
|
||||
} catch { setMsg({ type: 'e', text: 'Erro ao salvar' }); }
|
||||
finally { setSaving(false); }
|
||||
}, [selectedConfig, services]);
|
||||
|
||||
const toggleStatus = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.status === 'OK' ? 'WARNING' : 'OK', overridden: true }));
|
||||
};
|
||||
const updateDescription = (idx: number, desc: string) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, description: desc, overridden: true }));
|
||||
};
|
||||
const resetToAuto = (idx: number) => {
|
||||
setServices((p) => p.map((s, i) => i !== idx ? s : { ...s, status: s.auto_status || s.status, description: s.auto_description || s.description, overridden: false }));
|
||||
};
|
||||
|
||||
const configName = ociCfg.find((c) => c.id === selectedConfig)?.tenancy_name || '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tenancy selector */}
|
||||
<div className="card" style={{ padding: '14px 20px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>Tenancy</span>
|
||||
<div className="relative flex-1" style={{ maxWidth: 350 }}>
|
||||
<select value={selectedConfig} onChange={(e) => setSelectedConfig(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-xs outline-none cursor-pointer appearance-none pr-8"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}>
|
||||
<option value="">Selecione...</option>
|
||||
{ociCfg.map((c) => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="px-4 py-2 rounded-lg text-xs font-medium"
|
||||
style={{ background: msg.type === 's' ? 'var(--gnl)' : 'var(--rdl)', color: msg.type === 's' ? 'var(--gn)' : 'var(--rd)' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConfig && (
|
||||
<div className="card overflow-hidden" style={{ padding: 0 }}>
|
||||
<div className="px-5 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>{configName}</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full font-bold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>
|
||||
{services.filter((s) => s.status === 'OK').length}/{services.length || 6} OK
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => loadServices(true)} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{loading ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />} Re-detect
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 gap-2">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.76rem]" style={{ color: 'var(--t3)' }}>Verificando servicos OCI...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[.74rem]" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)' }}>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 90 }}>STATUS</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)', width: 200 }}>SERVICE</th>
|
||||
<th className="py-2.5 px-4 text-left font-semibold" style={{ color: 'var(--t3)' }}>DESCRIPTION</th>
|
||||
<th className="py-2.5 px-4 text-center font-semibold" style={{ color: 'var(--t3)', width: 70 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((svc, i) => (
|
||||
<tr key={svc.service} style={{ borderBottom: '1px solid var(--bd)' }}>
|
||||
<td className="py-2.5 px-4">
|
||||
<button onClick={() => toggleStatus(i)} className="px-3 py-1 rounded text-[.68rem] font-bold cursor-pointer"
|
||||
style={{ background: svc.status === 'OK' ? '#dcfce7' : '#fef3c7', color: svc.status === 'OK' ? '#16a34a' : '#d97706', border: 'none', minWidth: 65 }}
|
||||
title="Clique para alternar OK/WARNING">{svc.status}</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<span className="font-semibold" style={{ color: 'var(--t1)' }}>{svc.service}</span>
|
||||
{svc.overridden && <span className="ml-2 text-[.58rem] px-1.5 py-0.5 rounded" style={{ background: 'var(--bll)', color: 'var(--bl)' }}>editado</span>}
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input type="text" value={svc.description} onChange={(e) => updateDescription(i, e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg text-[.72rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
{svc.overridden && <button onClick={() => resetToAuto(i)} className="text-[.6rem] px-2 py-0.5 rounded cursor-pointer"
|
||||
style={{ background: 'var(--bg3)', color: 'var(--t3)', border: '1px solid var(--bd)' }}>reset</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && services.length > 0 && (
|
||||
<div className="px-5 py-3 flex justify-end" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<button onClick={saveServices} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.74rem] font-semibold cursor-pointer"
|
||||
style={{ background: 'var(--ac)', color: '#fff', border: 'none' }}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
OCI HEALTH TAB (Oracle public status)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
function OciHealthTab() {
|
||||
const [regions, setRegions] = useState<OciHealthRegion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [geoFilter, setGeoFilter] = useState('all');
|
||||
const [expandedRegion, setExpandedRegion] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await reportsApi.getOciHealth();
|
||||
setRegions(data.regionHealthReports || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch {}
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadHealth(); }, [loadHealth]);
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
let totalServices = 0, operational = 0, degraded = 0, disrupted = 0;
|
||||
regions.forEach((r) => r.serviceHealthReports.forEach((s) => {
|
||||
totalServices++;
|
||||
if (s.serviceStatus === 'NormalPerformance') operational++;
|
||||
else if (s.serviceStatus === 'DegradedPerformance') degraded++;
|
||||
else disrupted++;
|
||||
}));
|
||||
return { totalServices, operational, degraded, disrupted, regions: regions.length };
|
||||
}, [regions]);
|
||||
|
||||
// Filter
|
||||
const filteredRegions = useMemo(() => {
|
||||
let filtered = regions;
|
||||
if (geoFilter !== 'all') filtered = filtered.filter((r) => r.geographicAreaName === geoFilter);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
filtered = filtered.map((r) => ({
|
||||
...r,
|
||||
serviceHealthReports: r.serviceHealthReports.filter((s) =>
|
||||
s.serviceName.toLowerCase().includes(q) || r.regionName.toLowerCase().includes(q)
|
||||
),
|
||||
})).filter((r) => r.serviceHealthReports.length > 0);
|
||||
}
|
||||
return filtered;
|
||||
}, [regions, geoFilter, search]);
|
||||
|
||||
// Group by geography
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, OciHealthRegion[]> = {};
|
||||
filteredRegions.forEach((r) => {
|
||||
const geo = r.geographicAreaName || 'Other';
|
||||
if (!map[geo]) map[geo] = [];
|
||||
map[geo].push(r);
|
||||
});
|
||||
return GEO_ORDER.filter((g) => map[g]).map((g) => ({ geo: g, label: GEO_LABELS[g] || g, regions: map[g] }));
|
||||
}, [filteredRegions]);
|
||||
|
||||
// Geos available
|
||||
const availableGeos = useMemo(() => {
|
||||
const s = new Set(regions.map((r) => r.geographicAreaName));
|
||||
return GEO_ORDER.filter((g) => s.has(g));
|
||||
}, [regions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 size={28} className="animate-spin" style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-[.8rem]" style={{ color: 'var(--t3)' }}>Loading OCI Health Status...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Global Status Banner ── */}
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: stats.disrupted > 0 ? 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)' : stats.degraded > 0 ? 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)' : 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)', border: '1px solid var(--bd)' }}>
|
||||
<div className="px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
style={{ background: stats.disrupted > 0 ? '#fee2e2' : stats.degraded > 0 ? '#fef3c7' : '#dcfce7' }}>
|
||||
{stats.disrupted > 0 ? <XCircle size={24} style={{ color: '#dc2626' }} /> :
|
||||
stats.degraded > 0 ? <AlertTriangle size={24} style={{ color: '#d97706' }} /> :
|
||||
<CheckCircle2 size={24} style={{ color: '#16a34a' }} />}
|
||||
</div>
|
||||
{stats.disrupted === 0 && stats.degraded === 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full animate-ping" style={{ background: '#22c55e', opacity: 0.5 }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[1rem] font-bold" style={{ color: stats.disrupted > 0 ? '#dc2626' : stats.degraded > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{stats.disrupted > 0 ? 'Service Disruption Detected' : stats.degraded > 0 ? 'Degraded Performance' : 'All Systems Operational'}
|
||||
</h2>
|
||||
<p className="text-[.72rem] mt-0.5" style={{ color: 'var(--t3)' }}>
|
||||
{stats.regions} regions · {stats.totalServices.toLocaleString()} services monitored
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastUpdate && (
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>
|
||||
Updated {lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={loadHealth} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer"
|
||||
style={{ background: 'rgba(255,255,255,.7)', color: 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<RefreshCw size={12} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats pills */}
|
||||
<div className="px-6 pb-4 flex gap-3">
|
||||
{[
|
||||
{ label: 'Operational', count: stats.operational, color: '#16a34a', bg: '#f0fdf4' },
|
||||
{ label: 'Degraded', count: stats.degraded, color: '#d97706', bg: '#fffbeb' },
|
||||
{ label: 'Disrupted', count: stats.disrupted, color: '#dc2626', bg: '#fef2f2' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-[.7rem] font-semibold"
|
||||
style={{ background: s.bg, color: s.color, border: `1px solid ${s.color}20` }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
|
||||
{s.count.toLocaleString()} {s.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + Geo Filter ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--t4)' }} />
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search services or regions..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg text-[.76rem] outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
{search && <button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: 'var(--t4)' }}><X size={14} /></button>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setGeoFilter('all')}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === 'all' ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === 'all' ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
<Globe size={12} className="inline mr-1" style={{ verticalAlign: -2 }} />All
|
||||
</button>
|
||||
{availableGeos.map((g) => (
|
||||
<button key={g} onClick={() => setGeoFilter(geoFilter === g ? 'all' : g)}
|
||||
className="px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
style={{ background: geoFilter === g ? 'var(--ac)' : 'var(--bg2)', color: geoFilter === g ? '#fff' : 'var(--t2)', border: '1px solid var(--bd)' }}>
|
||||
{GEO_LABELS[g]?.split(',')[0]?.split('&')[0]?.trim() || g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Region Cards ── */}
|
||||
{grouped.map(({ geo, label, regions: geoRegions }) => (
|
||||
<div key={geo}>
|
||||
<h3 className="text-[.72rem] font-bold uppercase tracking-wider mb-2 px-1" style={{ color: 'var(--t4)' }}>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{geoRegions.map((region) => {
|
||||
const svcs = region.serviceHealthReports;
|
||||
const ok = svcs.filter((s) => s.serviceStatus === 'NormalPerformance').length;
|
||||
const issues = svcs.length - ok;
|
||||
const isExpanded = expandedRegion === region.regionName;
|
||||
|
||||
return (
|
||||
<div key={region.regionName} className="rounded-xl overflow-hidden transition-all"
|
||||
style={{ background: 'var(--bg1)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
||||
onClick={() => setExpandedRegion(isExpanded ? null : region.regionName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: issues > 0 ? '#f59e0b' : '#22c55e', boxShadow: issues > 0 ? '0 0 8px #f59e0b40' : '0 0 8px #22c55e40' }} />
|
||||
<span className="text-[.78rem] font-semibold" style={{ color: 'var(--t1)' }}>
|
||||
{region.regionName}
|
||||
</span>
|
||||
<span className="text-[.65rem] px-2 py-0.5 rounded-full" style={{ background: 'var(--bg3)', color: 'var(--t3)' }}>
|
||||
{region.regionCanonicalName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[.65rem] font-medium" style={{ color: issues > 0 ? '#d97706' : '#16a34a' }}>
|
||||
{issues > 0 ? `${issues} issue(s)` : `${ok} services OK`}
|
||||
</span>
|
||||
<ChevronRight size={14}
|
||||
style={{ color: 'var(--t4)', transition: 'transform .2s', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||||
{svcs.map((svc) => {
|
||||
const st = getStatus(svc.serviceStatus);
|
||||
return (
|
||||
<div key={svc.serviceId} className="flex items-center gap-2 px-2.5 py-2 rounded-lg transition-colors"
|
||||
style={{ background: 'var(--bg2)' }}>
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: st.dot }} />
|
||||
<span className="text-[.65rem] truncate" style={{ color: 'var(--t2)' }} title={svc.serviceName}>
|
||||
{svc.serviceName.replace(/^Oracle Cloud Infrastructure /, '').replace(/^Oracle /, '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
MAIN PAGE WITH TABS
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
export default function OciServicesPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'health'>('status');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'status' as const, label: 'Service Status', icon: <Shield size={14} /> },
|
||||
{ id: 'health' as const, label: 'OCI Health', icon: <Activity size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-5">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
<Shield size={24} style={{ color: 'var(--ac)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('svc.title') || 'OCI Services'}</h1>
|
||||
<p className="text-[.78rem]" style={{ color: 'var(--t3)' }}>
|
||||
{activeTab === 'status'
|
||||
? (t('svc.subtitle') || 'Verifique e configure o status dos servicos de seguranca OCI por tenancy')
|
||||
: 'Real-time Oracle Cloud Infrastructure service health across all regions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-xl" style={{ background: 'var(--bg2)', width: 'fit-content' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[.76rem] font-semibold cursor-pointer transition-all"
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--bg1)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--ac)' : 'var(--t3)',
|
||||
boxShadow: activeTab === tab.id ? '0 1px 3px rgba(0,0,0,.08)' : 'none',
|
||||
border: 'none',
|
||||
}}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'status' ? <ServiceStatusTab /> : <OciHealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -743,9 +743,16 @@ export default function ReportsPage() {
|
||||
if (!selectedRid || complianceGenerating) return;
|
||||
setComplianceReady(false);
|
||||
setComplianceGenerating(true);
|
||||
setError('');
|
||||
try {
|
||||
await reportsApi.generateComplianceReport(selectedRid);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err: unknown) {
|
||||
setComplianceGenerating(false);
|
||||
const msg = (err && typeof err === 'object' && 'response' in err)
|
||||
? ((err as { response?: { data?: { detail?: string } } }).response?.data?.detail || 'Erro ao gerar report')
|
||||
: 'Erro ao gerar report';
|
||||
setError(msg);
|
||||
}
|
||||
// polling is already running via usePolling above
|
||||
}, [selectedRid, complianceGenerating]);
|
||||
|
||||
@@ -1420,7 +1427,7 @@ export default function ReportsPage() {
|
||||
complianceReady && !complianceGenerating ? (
|
||||
<div>
|
||||
<iframe
|
||||
key={selectedRid + '-' + complianceReady}
|
||||
key={selectedRid + '-compliance-ready'}
|
||||
src={reportsApi.complianceReportUrl(selectedRid)}
|
||||
className="w-full border-0"
|
||||
style={{ height: 700, background: '#fff' }}
|
||||
@@ -1434,6 +1441,13 @@ export default function ReportsPage() {
|
||||
>
|
||||
<Download size={12} /> {t('rpt.complianceDownloadZip')}
|
||||
</a>
|
||||
<a
|
||||
href={reportsApi.complianceReportDocxUrl(selectedRid)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
|
||||
style={{ background: 'var(--bl)', color: '#fff' }}
|
||||
>
|
||||
<Download size={12} /> Download DOCX
|
||||
</a>
|
||||
<button
|
||||
onClick={startComplianceGeneration}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react';
|
||||
import { Users, Plus, Shield, Eye, User as UserIcon, Trash2, X, AlertTriangle, CheckCircle, Loader2, UserPlus, Lock, ShieldCheck, ShieldOff, KeyRound, Clock } from 'lucide-react';
|
||||
import client from '@/api/client';
|
||||
import { adminApi } from '@/api/endpoints/admin';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
@@ -37,6 +38,29 @@ export default function UsersPage() {
|
||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Timezone
|
||||
const [timezone, setTimezone] = useState('');
|
||||
const [tzOptions, setTzOptions] = useState<string[]>([]);
|
||||
const [tzSaving, setTzSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getMyTimezone().then((d) => setTimezone(d.timezone)).catch(() => {});
|
||||
adminApi.getTimezoneOptions().then((d) => setTzOptions(d.timezones)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleTzChange = async (tz: string) => {
|
||||
setTzSaving(true);
|
||||
try {
|
||||
await adminApi.setMyTimezone(tz);
|
||||
setTimezone(tz);
|
||||
setMsg({ text: `Timezone alterado para ${tz}`, type: 'success' });
|
||||
} catch (err) {
|
||||
setMsg({ text: err instanceof Error ? err.message : 'Erro ao alterar timezone', type: 'error' });
|
||||
} finally {
|
||||
setTzSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// MFA modal state
|
||||
const [mfaUserId, setMfaUserId] = useState<string | null>(null);
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
@@ -233,6 +257,27 @@ export default function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-user Timezone */}
|
||||
<div className="card" style={{ padding: '14px 20px', marginBottom: 16 }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock size={16} style={{ color: 'var(--ac)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>{t('usr.myTimezone') || 'Meu Timezone'}</span>
|
||||
</div>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => handleTzChange(e.target.value)}
|
||||
disabled={tzSaving}
|
||||
className="px-3 py-1.5 rounded-lg text-xs outline-none cursor-pointer"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 200 }}
|
||||
>
|
||||
{tzOptions.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--acl)' }}>
|
||||
|
||||
Reference in New Issue
Block a user