feat: add control panel
This commit is contained in:
@@ -6,6 +6,10 @@ import { Governance } from './pages/Governance';
|
||||
import { Security } from './pages/Security';
|
||||
import { Incidents } from './pages/Incidents';
|
||||
import { Traceability } from './pages/Traceability';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { FinOps } from './pages/FinOps';
|
||||
import { Approvals } from './pages/Approvals';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -17,6 +21,10 @@ export default function App() {
|
||||
<Route path="/security" element={<Security />} />
|
||||
<Route path="/incidents" element={<Incidents />} />
|
||||
<Route path="/traceability" element={<Traceability />} />
|
||||
<Route path="/finops" element={<FinOps />} />
|
||||
<Route path="/approvals" element={<Approvals />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/command" element={<CommandCenter />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom';
|
||||
const NAV = [
|
||||
['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'],
|
||||
['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'],
|
||||
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'],
|
||||
];
|
||||
export function Sidebar() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Single axios client for the read-only Ops Console API. Mirrors the OKR app's api.ts:
|
||||
// relative baseURL, unwrap response.data.data. All GET (read-only).
|
||||
// Single axios client for the Ops Console API. Mirrors the OKR app's api.ts:
|
||||
// relative baseURL, unwrap response.data.data.
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
@@ -11,6 +11,16 @@ async function get<T>(path: string): Promise<T> {
|
||||
return res.data.data as T;
|
||||
}
|
||||
|
||||
async function getWithHeaders<T>(path: string, headers: Record<string, string>): Promise<T> {
|
||||
const res = await client.get(`/${path}`, { headers });
|
||||
return res.data.data as T;
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
|
||||
const res = await client.post(`/${path}`, body, { headers });
|
||||
return res.data.data as T;
|
||||
}
|
||||
|
||||
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
|
||||
|
||||
export interface Overview extends Freshness {
|
||||
@@ -23,6 +33,73 @@ export interface Overview extends Freshness {
|
||||
audit_chain: { records: number; head: string | null; last_decision: string | null };
|
||||
}
|
||||
|
||||
export interface SettingsActor {
|
||||
actor: string;
|
||||
role: string;
|
||||
project: string;
|
||||
tenant: string;
|
||||
}
|
||||
|
||||
export interface KillSwitchState {
|
||||
count: number;
|
||||
engaged: Array<{ scope: string; id: string; reason?: string; engaged_at?: string; actor?: string; raw?: string }>;
|
||||
}
|
||||
|
||||
export interface ApprovalsState {
|
||||
count: number;
|
||||
proposals: any[];
|
||||
oversight: any[];
|
||||
audit_verify: { ok: boolean; output: string };
|
||||
}
|
||||
|
||||
export interface CommandEnvelope {
|
||||
source: string;
|
||||
artifact_path: string;
|
||||
commit: string | null;
|
||||
run_at: string | null;
|
||||
verified: boolean;
|
||||
status: 'verified' | 'present_unverified' | 'missing';
|
||||
}
|
||||
|
||||
export interface CommandWidget {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
metrics: Record<string, unknown>;
|
||||
envelope: CommandEnvelope;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CommandCenterState extends Freshness {
|
||||
generated_at: string;
|
||||
widgets: Record<string, CommandWidget>;
|
||||
briefing: Array<{ label: string; value: string; status: string; widget: string }>;
|
||||
ticker: Array<{ at: string | null; kind: string; text: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
can_write_standard: boolean;
|
||||
can_write_sensitive: boolean;
|
||||
can_rollback: boolean;
|
||||
};
|
||||
policy: Record<string, { securitySensitive: boolean; description: string }>;
|
||||
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
|
||||
audit: any[];
|
||||
audit_verify: { ok: boolean; output: string };
|
||||
}
|
||||
|
||||
function actorHeaders(actor: SettingsActor): Record<string, string> {
|
||||
return {
|
||||
'x-casan-actor': actor.actor,
|
||||
'x-casan-role': actor.role,
|
||||
'x-casan-project': actor.project,
|
||||
'x-casan-tenant': actor.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
overview: () => get<Overview>('overview'),
|
||||
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
|
||||
@@ -31,6 +108,22 @@ export const api = {
|
||||
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
|
||||
traceability: () => get<Freshness & { matrix: any }>('traceability'),
|
||||
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
|
||||
command: () => get<CommandCenterState>('command'),
|
||||
settings: (actor: SettingsActor) => getWithHeaders<SettingsState>('settings', actorHeaders(actor)),
|
||||
setSetting: (actor: SettingsActor, body: { key: string; value: unknown; reason: string; approval?: string }) =>
|
||||
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings', body, actorHeaders(actor)),
|
||||
rollbackSetting: (actor: SettingsActor, body: { key: string; reason: string }) =>
|
||||
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings/rollback', body, actorHeaders(actor)),
|
||||
killSwitch: (actor: SettingsActor) => getWithHeaders<KillSwitchState>('kill-switch', actorHeaders(actor)),
|
||||
engageKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
|
||||
post<{ output: string; status: KillSwitchState }>('kill-switch/engage', body, actorHeaders(actor)),
|
||||
clearKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
|
||||
post<{ output: string; status: KillSwitchState }>('kill-switch/clear', body, actorHeaders(actor)),
|
||||
approvals: (actor: SettingsActor, status = 'pending') => getWithHeaders<ApprovalsState>(`approvals?status=${status}`, actorHeaders(actor)),
|
||||
submitApproval: (actor: SettingsActor, body: { action: string; target: string; risk?: string; sensitive?: boolean; reason: string; payload?: Record<string, unknown> }) =>
|
||||
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
|
||||
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
|
||||
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'project-admin', 'approver', 'org-admin', 'auditor'];
|
||||
const STATUS = ['pending', 'approved', 'rejected', 'auto_allowed', 'all'];
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function Approvals() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actor, setActor] = useState<SettingsActor>({ actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' });
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [key, setKey] = useState('security.strict');
|
||||
const [value, setValue] = useState('true');
|
||||
const [sensitive, setSensitive] = useState(true);
|
||||
const [reason, setReason] = useState('review requested from Control Panel');
|
||||
const [decisionReason, setDecisionReason] = useState('reviewed in approval inbox');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const inbox = useQuery({
|
||||
queryKey: ['approvals', actor, status],
|
||||
queryFn: () => api.approvals(actor, status),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () => api.submitApproval(actor, {
|
||||
action: 'settings.write',
|
||||
target: key,
|
||||
risk: sensitive ? 'high' : 'standard',
|
||||
sensitive,
|
||||
reason,
|
||||
payload: { key, value: parseValue(value) },
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`submitted ${res.proposal.id} status=${res.proposal.status}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'submit failed'),
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) =>
|
||||
api.decideApproval(actor, { id, decision, reason: decisionReason }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`${res.proposal.status} ${res.proposal.id}${res.applied ? ` applied v${res.applied.version}` : ''}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'decision failed'),
|
||||
});
|
||||
|
||||
if (inbox.isLoading || !inbox.data) return <div className="text-gray-500">Loading…</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Approval identity" right={<StatusBadge value={inbox.data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Project</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
||||
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tenant</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
||||
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Status</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Submit settings proposal">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Setting key</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Value (JSON or string)</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-gray-500">Reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={sensitive} onChange={(e) => setSensitive(e.target.checked)} />
|
||||
<span className="text-gray-700">Security-sensitive / high risk</span>
|
||||
</label>
|
||||
<div>
|
||||
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
|
||||
disabled={submit.isPending} onClick={() => submit.mutate()}>
|
||||
Submit proposal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <div className="mt-3 rounded border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">{message}</div>}
|
||||
</Card>
|
||||
|
||||
<Card title="Inbox">
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-gray-500">Decision reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={decisionReason} onChange={(e) => setDecisionReason(e.target.value)} />
|
||||
</label>
|
||||
{inbox.data.proposals.map((p) => (
|
||||
<div key={p.id} className="rounded border border-gray-200 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{p.id} · {p.action} · {p.target}</div>
|
||||
<div className="text-xs text-gray-500">{p.proposer} · {p.created_at} · {p.delegation?.level} · {p.delegation?.reason}</div>
|
||||
</div>
|
||||
<StatusBadge value={p.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">{p.reason}</div>
|
||||
<pre className="mt-2 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-700">{JSON.stringify(p.payload, null, 2)}</pre>
|
||||
{p.status === 'pending' && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="rounded bg-green-600 px-3 py-2 text-sm text-white disabled:bg-gray-300"
|
||||
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'approve' })}>
|
||||
Approve
|
||||
</button>
|
||||
<button className="rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 disabled:text-gray-300"
|
||||
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'reject' })}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{inbox.data.proposals.length === 0 && <div className="text-sm text-gray-500">No proposals for this status.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Oversight log">
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{inbox.data.oversight.slice().reverse().map((o: any) => (
|
||||
<div key={`${o.seq}-${o.hash}`} className="rounded border border-gray-200 p-3 text-sm">
|
||||
<div className="font-medium text-gray-800">{o.event} · {o.proposal_id}</div>
|
||||
<div className="text-xs text-gray-500">{o.actor} · {o.at} · hash {String(o.hash).slice(0, 12)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{o.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, CommandWidget } from '../lib/api';
|
||||
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ORDER = ['maturity', 'hitl', 'kill_switch', 'traceability', 'security', 'finops', 'certification', 'self_improve'];
|
||||
|
||||
const COPY = {
|
||||
en: {
|
||||
title: 'Executive briefing',
|
||||
maturity: 'Maturity',
|
||||
control: 'Control',
|
||||
safety: 'Safety',
|
||||
economy: 'Economy',
|
||||
certification: 'Certification',
|
||||
ticker: 'Live governance ticker',
|
||||
evidence: 'Evidence',
|
||||
},
|
||||
vi: {
|
||||
title: 'Tóm tắt điều hành',
|
||||
maturity: 'Trưởng thành',
|
||||
control: 'Kiểm soát',
|
||||
safety: 'An toàn',
|
||||
economy: 'Chi phí',
|
||||
certification: 'Chứng thực',
|
||||
ticker: 'Dòng sự kiện governance',
|
||||
evidence: 'Bằng chứng',
|
||||
},
|
||||
};
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? v.replace('T', ' ').replace('Z', '') : 'n/a';
|
||||
}
|
||||
|
||||
function metricValue(v: unknown): string {
|
||||
if (Array.isArray(v)) return v.length ? v.join(', ') : 'none';
|
||||
if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(4);
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||||
return v == null || v === '' ? 'n/a' : String(v);
|
||||
}
|
||||
|
||||
function WidgetPanel({ widget, onOpen }: { widget: CommandWidget; onOpen: (w: CommandWidget) => void }) {
|
||||
const metrics = Object.entries(widget.metrics).slice(0, 4);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(widget)}
|
||||
className="text-left bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:border-blue-300 hover:shadow-md transition min-h-[168px]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800 leading-snug">{widget.title}</div>
|
||||
<div className="text-sm text-gray-500 mt-2 leading-relaxed">{widget.summary}</div>
|
||||
</div>
|
||||
<StatusBadge value={widget.status} />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
|
||||
{metrics.map(([k, v]) => (
|
||||
<div key={k} className="rounded border border-gray-100 bg-gray-50 p-2 min-w-0">
|
||||
<div className="text-gray-400 truncate">{k.replaceAll('_', ' ')}</div>
|
||||
<div className="font-medium text-gray-700 truncate">{metricValue(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-400 truncate">{widget.envelope.artifact_path}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandCenter() {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['command'], queryFn: api.command });
|
||||
const [lang, setLang] = useState<'en' | 'vi'>('en');
|
||||
const [selected, setSelected] = useState<CommandWidget | null>(null);
|
||||
const labels = COPY[lang];
|
||||
|
||||
const widgets = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return ORDER.map((id) => data.widgets[id]).filter(Boolean);
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <div className="text-gray-500">Loading…</div>;
|
||||
if (isError || !data) return <div className="text-red-600">Cannot reach Command Center API.</div>;
|
||||
|
||||
const verified = widgets.filter((w) => w.envelope.verified).length;
|
||||
const failing = widgets.filter((w) => ['fail', 'warn'].includes(w.status)).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatTile label="Widgets" value={widgets.length} sub="Plan-13 §8.6" />
|
||||
<StatTile label="Verified sources" value={`${verified}/${widgets.length}`} />
|
||||
<StatTile label="Attention" value={failing} sub="warn/fail widgets" />
|
||||
<StatTile label="Generated" value={fmtTime(data.generated_at).slice(11, 19)} sub={fmtTime(data.generated_at).slice(0, 10)} />
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={labels.title}
|
||||
right={
|
||||
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-xs">
|
||||
{(['en', 'vi'] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLang(l)}
|
||||
className={`px-3 py-1.5 ${lang === l ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
{data.briefing.map((b) => (
|
||||
<button
|
||||
type="button"
|
||||
key={b.widget}
|
||||
onClick={() => setSelected(data.widgets[b.widget])}
|
||||
className="text-left rounded-lg border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 min-h-[112px]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-800">{(labels as Record<string, string>)[b.label.toLowerCase()] ?? b.label}</div>
|
||||
<StatusBadge value={b.status} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-3 leading-relaxed">{b.value}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4">
|
||||
<div className="xl:col-span-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{widgets.map((w) => <WidgetPanel key={w.id} widget={w} onOpen={setSelected} />)}
|
||||
</div>
|
||||
<Card title={labels.ticker}>
|
||||
<div className="space-y-3">
|
||||
{data.ticker.map((e, i) => (
|
||||
<div key={`${e.kind}-${e.at}-${i}`} className="border-b border-gray-100 pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-gray-400">{fmtTime(e.at)}</span>
|
||||
<StatusBadge value={e.status} />
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mt-1">{e.text}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{e.kind}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.ticker.length === 0 && <div className="text-sm text-gray-500">No governance events.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-50 bg-gray-900/20 flex justify-end" onClick={() => setSelected(null)}>
|
||||
<aside className="h-full w-full max-w-2xl bg-white border-l border-gray-200 shadow-xl p-6 overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{selected.title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{selected.summary}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setSelected(null)} className="px-3 py-1.5 rounded border border-gray-200 text-sm text-gray-600 hover:bg-gray-50">Close</button>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-400">source</div>
|
||||
<div className="font-medium text-gray-700 break-all">{selected.envelope.source}</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-400">status</div>
|
||||
<div className="font-medium text-gray-700">{selected.envelope.status}</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-200 p-3 col-span-2">
|
||||
<div className="text-xs text-gray-400">commit</div>
|
||||
<div className="font-medium text-gray-700 break-all">{selected.envelope.commit ?? 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-200 p-3 col-span-2">
|
||||
<div className="text-xs text-gray-400">artifact</div>
|
||||
<div className="font-medium text-gray-700 break-all">{selected.envelope.artifact_path}</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="mt-5 text-xs bg-gray-950 text-gray-100 rounded-lg p-4 overflow-auto">{JSON.stringify(selected, null, 2)}</pre>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const viewer: SettingsActor = {
|
||||
actor: 'finops-viewer',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
};
|
||||
|
||||
function money(v: number | null | undefined): string {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? `$${v.toFixed(4)}` : 'n/a';
|
||||
}
|
||||
|
||||
export function FinOps() {
|
||||
const cost = useQuery({ queryKey: ['cost'], queryFn: api.cost });
|
||||
const settings = useQuery({ queryKey: ['settings', 'finops-viewer'], queryFn: () => api.settings(viewer), retry: false });
|
||||
|
||||
if (cost.isLoading || !cost.data) return <div className="text-gray-500">Loading…</div>;
|
||||
|
||||
const capRaw = settings.data?.settings['cost.absolute_cap_usd']?.value;
|
||||
const cap = typeof capRaw === 'number' ? capRaw : null;
|
||||
const actual = cost.data.provider_cost;
|
||||
const budgetStatus = cap === null ? 'not configured' : actual <= cap ? 'ok' : 'breach';
|
||||
const kpis = Array.isArray(cost.data.business_kpi?.kpis) ? cost.data.business_kpi.kpis : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatTile label="Provider cost" value={money(actual)} sub="from provider usage artifact" />
|
||||
<StatTile label="Provider tokens" value={cost.data.provider_tokens} />
|
||||
<StatTile label="Budget cap" value={money(cap)} sub="cost.absolute_cap_usd setting" />
|
||||
<StatTile label="Budget status" value={<StatusBadge value={budgetStatus} />} />
|
||||
</div>
|
||||
|
||||
<Card title="Provider usage">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="py-2">time</th><th>provider</th><th>model</th><th>step</th><th>tokens</th><th>cost</th><th>status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cost.data.by_provider.map((r: any, i: number) => (
|
||||
<tr key={`${r.timestamp}-${i}`} className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">{r.timestamp?.replace('T', ' ').replace('Z', '')}</td>
|
||||
<td>{r.provider}</td>
|
||||
<td>{r.model}</td>
|
||||
<td>{r.step}</td>
|
||||
<td>{r.total_tokens}</td>
|
||||
<td>{money(r.cost_usd)}</td>
|
||||
<td><StatusBadge value={r.status ?? 'unknown'} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{cost.data.by_provider.length === 0 && (
|
||||
<tr><td className="py-4 text-gray-500" colSpan={7}>No provider usage records.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="SLO / KPI board">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{kpis.map((k: any) => (
|
||||
<div key={k.id} className="rounded border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-gray-800">{String(k.id).replaceAll('_', ' ')}</div>
|
||||
<StatusBadge value={k.target_met ? 'ok' : 'breach'} />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600">
|
||||
<div><span className="block text-gray-400">baseline</span>{k.baseline}</div>
|
||||
<div><span className="block text-gray-400">current</span>{k.current}</div>
|
||||
<div><span className="block text-gray-400">target</span>{k.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{kpis.length === 0 && <div className="text-gray-500">No KPI artifact found.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,101 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'operator', 'org-admin'];
|
||||
const SCOPES = ['project', 'model', 'provider', 'tenant', 'global'];
|
||||
|
||||
export function Incidents() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['incidents'], queryFn: api.incidents });
|
||||
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'operator', project: 'default', tenant: 'default' });
|
||||
const [scope, setScope] = useState('project');
|
||||
const [id, setId] = useState('default');
|
||||
const [reason, setReason] = useState('incident containment drill');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const killSwitch = useQuery({ queryKey: ['kill-switch', actor], queryFn: () => api.killSwitch(actor), retry: false });
|
||||
|
||||
const engage = useMutation({
|
||||
mutationFn: () => api.engageKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(res.output);
|
||||
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'engage failed'),
|
||||
});
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(res.output);
|
||||
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'clear failed'),
|
||||
});
|
||||
|
||||
if (isLoading || !data) return <div className="text-gray-500">Loading…</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<StatTile label="Incidents" value={data.total} />
|
||||
<StatTile label="Kill-switch scopes" value={data.kill_switch_scopes.length} sub={data.kill_switch_scopes.join(', ') || 'none'} />
|
||||
<StatTile label="Kill-switch scopes" value={killSwitch.data?.count ?? data.kill_switch_scopes.length}
|
||||
sub={killSwitch.data?.engaged.map((k) => `${k.scope}/${k.id}`).join(', ') || data.kill_switch_scopes.join(', ') || 'none'} />
|
||||
</div>
|
||||
<Card title="Kill-switch control" right={<StatusBadge value={(killSwitch.data?.count ?? 0) > 0 ? 'blocked' : 'ok'} />}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Scope</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={scope} onChange={(e) => setScope(e.target.value)}>
|
||||
{SCOPES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">ID</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={scope === 'global' ? 'all' : id}
|
||||
disabled={scope === 'global'} onChange={(e) => setId(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-gray-500">Reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<button className="rounded bg-red-600 px-4 py-2 text-white disabled:bg-gray-300"
|
||||
disabled={engage.isPending} onClick={() => engage.mutate()}>
|
||||
Engage
|
||||
</button>
|
||||
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
|
||||
disabled={clear.isPending} onClick={() => clear.mutate()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{message && <div className="md:col-span-2 rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
|
||||
</div>
|
||||
<div className="space-y-2 max-h-72 overflow-auto">
|
||||
{(killSwitch.data?.engaged ?? []).map((k) => (
|
||||
<div key={`${k.scope}-${k.id}`} className="rounded border border-red-200 bg-red-50 p-3">
|
||||
<div className="font-medium text-red-800">{k.scope}/{k.id}</div>
|
||||
<div className="text-xs text-red-700">{k.actor ?? 'system'} · {k.engaged_at ?? 'unknown'}</div>
|
||||
<div className="text-xs text-red-700 mt-1">{k.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
{(killSwitch.data?.engaged.length ?? 0) === 0 && <div className="text-gray-500">No active kill-switches.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Incident log">
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-gray-500 border-b border-gray-200"><th className="py-2">time</th><th>event</th><th>severity</th><th>scope</th><th>action</th></tr></thead>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'operator', 'project-admin', 'org-admin', 'auditor'];
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
if (raw.trim() === '') return '';
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function valuePreview(value: unknown): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actor, setActor] = useState<SettingsActor>({
|
||||
actor: 'local-operator',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
});
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
const [rawValue, setRawValue] = useState('true');
|
||||
const [reason, setReason] = useState('operator change from Ops Console');
|
||||
const [approval, setApproval] = useState('');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['settings', actor],
|
||||
queryFn: () => api.settings(actor),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const keys = useMemo(() => Object.keys(settingsQuery.data?.policy ?? {}).sort(), [settingsQuery.data]);
|
||||
const effectiveKey = selectedKey || keys[0] || '';
|
||||
const policy = effectiveKey ? settingsQuery.data?.policy[effectiveKey] : undefined;
|
||||
|
||||
const setMutation = useMutation({
|
||||
mutationFn: () => api.setSetting(actor, {
|
||||
key: effectiveKey,
|
||||
value: parseValue(rawValue),
|
||||
reason,
|
||||
approval: approval || undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`SET ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'SET failed'),
|
||||
});
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: () => api.rollbackSetting(actor, { key: effectiveKey, reason }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`ROLLBACK ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'ROLLBACK failed'),
|
||||
});
|
||||
|
||||
if (settingsQuery.isLoading) return <div className="text-gray-500">Loading…</div>;
|
||||
if (settingsQuery.isError || !settingsQuery.data) return <div className="text-red-600">Cannot reach settings API.</div>;
|
||||
|
||||
const data = settingsQuery.data;
|
||||
const current = effectiveKey ? data.settings[effectiveKey] : undefined;
|
||||
const canWrite = policy?.securitySensitive ? data.capabilities.can_write_sensitive : data.capabilities.can_write_standard;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Management identity" right={<StatusBadge value={data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Project</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
||||
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tenant</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
||||
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Governed settings">
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||
<tr><th className="py-2 pr-4">Key</th><th className="py-2 pr-4">Current</th><th className="py-2 pr-4">Version</th><th className="py-2">Policy</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((key) => {
|
||||
const row = data.settings[key];
|
||||
const p = data.policy[key];
|
||||
return (
|
||||
<tr key={key} className={`border-b border-gray-100 cursor-pointer ${effectiveKey === key ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => { setSelectedKey(key); setRawValue(valuePreview(row?.value ?? '')); }}>
|
||||
<td className="py-3 pr-4 font-medium text-gray-800">{key}</td>
|
||||
<td className="py-3 pr-4 text-gray-600"><code>{row ? valuePreview(row.value) : 'unset'}</code></td>
|
||||
<td className="py-3 pr-4 text-gray-600">{row?.version ?? '—'}</td>
|
||||
<td className="py-3">{p.securitySensitive ? <StatusBadge value="sensitive" /> : <StatusBadge value="standard" />}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Apply or rollback">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-gray-500">Selected key</div>
|
||||
<div className="font-medium text-gray-800">{effectiveKey || 'none'}</div>
|
||||
{policy && <div className="text-gray-500 mt-1">{policy.description}</div>}
|
||||
{current && <div className="text-gray-500 mt-1">current v{current.version} by {current.actor}</div>}
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Value (JSON or string)</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={rawValue} onChange={(e) => setRawValue(e.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Approval token</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={approval} onChange={(e) => setApproval(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
|
||||
disabled={!effectiveKey || !canWrite || setMutation.isPending}
|
||||
onClick={() => setMutation.mutate()}>
|
||||
Apply
|
||||
</button>
|
||||
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
|
||||
disabled={!effectiveKey || !data.capabilities.can_rollback || rollbackMutation.isPending}
|
||||
onClick={() => rollbackMutation.mutate()}>
|
||||
Rollback
|
||||
</button>
|
||||
</div>
|
||||
{message && <div className="rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase text-gray-500">Recent settings audit</div>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
{data.audit.map((a) => (
|
||||
<div key={`${a.seq}-${a.hash}`} className="rounded border border-gray-200 p-3">
|
||||
<div className="font-medium text-gray-800">{a.action} {a.key}</div>
|
||||
<div className="text-xs text-gray-500">{a.actor} · {a.at} · vhash {String(a.hash).slice(0, 12)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{a.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.audit.length === 0 && <div className="text-gray-500">No settings audit records yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user