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:
nogueiraguh
2026-03-29 00:43:20 -03:00
parent 937d0c8318
commit 35a2bf786b
14 changed files with 2134 additions and 75 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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 />} />

View File

@@ -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[] }>,
};

View File

@@ -8,6 +8,7 @@ export interface User {
last_name?: string;
email?: string;
mfa_enabled?: boolean;
timezone?: string;
}
export interface LoginResponse {

View File

@@ -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>,
};

View File

@@ -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' },
];

View File

@@ -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:',

View File

@@ -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:',

View 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 &middot; {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>
);
}

View File

@@ -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"

View File

@@ -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)' }}>